diff --git a/.github/workflows/python-host-check.yml b/.github/workflows/python-host-check.yml new file mode 100644 index 000000000..bb188b414 --- /dev/null +++ b/.github/workflows/python-host-check.yml @@ -0,0 +1,93 @@ +name: Python host check + +# Checks the scripting host API and the bundled Python plugin on Linux and Windows, +# where the plugin is loaded from `plugins/opencad.python` next to the executable. +# The plugin tests talk to a real plugin process over IPC. They are skipped, and +# report success, unless OCS_TEST_PYTHON_PLUGIN and OCS_PLUGIN_RUNNER_EXE are set, +# so this workflow sets both to the freshly built plugin and executable. + +on: + pull_request: + paths: + - 'crates/ocs_plugin_api/**' + - 'plugins/opencad-python/**' + - 'src/app/plugin_host.rs' + - 'src/app/control/**' + - 'src/app/commands/**' + - 'src/app/settings.rs' + - 'Cargo.toml' + - 'Cargo.lock' + - '.github/workflows/python-host-check.yml' + push: + tags: ['pr-check-*'] + workflow_dispatch: + +env: + CARGO_TERM_COLOR: always + +jobs: + check: + name: ${{ matrix.name }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - name: linux + os: ubuntu-22.04 + library: libopencad_python.so + exe: OpenCADStudio + - name: windows + os: windows-latest + library: opencad_python.dll + exe: OpenCADStudio.exe + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + with: + key: python-host-check + workspaces: | + . + plugins/opencad-python + + - name: Install dependencies (Linux) + if: runner.os == 'Linux' + run: | + sudo apt-get update -q + sudo apt-get install -y \ + libgl1-mesa-dev libx11-dev libxcursor-dev libxi-dev libxrandr-dev \ + libxkbcommon-dev libwayland-dev libfontconfig1-dev libfreetype6-dev + + - name: Plugin API tests + run: cargo test --locked -p ocs_plugin_api --features host + + - name: Stage the bundled plugin + run: bash plugins/opencad-python/tools/stage-bundled.sh "$PWD/work/plugin" --debug + + - name: Build the application (the plugin runner) + run: cargo build --locked --bin OpenCADStudio + + - name: Plugin crate tests + working-directory: plugins/opencad-python + run: cargo test --locked + + - name: Python model unit tests + working-directory: plugins/opencad-python + run: python3 -m unittest discover -s tests + + - name: Host tests over real IPC + env: + OCS_TEST_PYTHON_PLUGIN: ${{ github.workspace }}/work/plugin/${{ matrix.library }} + OCS_PLUGIN_RUNNER_EXE: ${{ github.workspace }}/target/debug/${{ matrix.exe }} + run: | + test -f "$OCS_TEST_PYTHON_PLUGIN" || { echo "staged plugin missing: $OCS_TEST_PYTHON_PLUGIN"; exit 1; } + test -f "$OCS_PLUGIN_RUNNER_EXE" || { echo "runner missing: $OCS_PLUGIN_RUNNER_EXE"; exit 1; } + cargo test --locked --lib -- --test-threads=1 python diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b773bbebd..9769a3d7c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -478,6 +478,11 @@ jobs: chmod +x "$APP/Contents/MacOS/OpenCADStudio" "$APP/Contents/MacOS/OpenCADStudio-App" cp AppIcon.icns "$APP/Contents/Resources/AppIcon.icns" cp DWG.icns DXF.icns "$APP/Contents/Resources/" + # First-party Python scripting plugin. It remains out of process and + # uses the same API/toolchain as this host build. + bash plugins/opencad-python/tools/stage-bundled.sh \ + "$APP/Contents/Resources/plugins/opencad.python" \ + --target aarch64-apple-darwin # QuickLook thumbnail extension. cp -R DWGThumbnail.appex "$APP/Contents/PlugIns/" # Substitute version into Info.plist. diff --git a/.gitignore b/.gitignore index 262043fce..41b6c098c 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,6 @@ __pycache__/ # Single-instance lock files the app writes next to an open drawing *.ocs.lock + +# macOS Finder metadata +.DS_Store diff --git a/crates/ocs_plugin_api/ARCHITECTURE.md b/crates/ocs_plugin_api/ARCHITECTURE.md index 61bc16b05..a964cd793 100644 --- a/crates/ocs_plugin_api/ARCHITECTURE.md +++ b/crates/ocs_plugin_api/ARCHITECTURE.md @@ -44,12 +44,14 @@ The host and each plugin run in separate OS processes. The host re-executes itse ## Versioning and ABI stability -- `API_VERSION` (currently `5`) is the host's advertised major. +- `API_VERSION` (currently `6`) is the host's advertised major. - `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. `4` to disable V5). +- `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)). - 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`). The runtime enforces three gates: diff --git a/crates/ocs_plugin_api/Cargo.toml b/crates/ocs_plugin_api/Cargo.toml index 0212d8dae..76f48daa2 100644 --- a/crates/ocs_plugin_api/Cargo.toml +++ b/crates/ocs_plugin_api/Cargo.toml @@ -10,6 +10,7 @@ license = "GPL-3.0-only" # registry types (`type_registry_types.rs`). The host feature no longer needs # to enable it explicitly. serde = { version = "1", features = ["derive"] } +serde_json = { version = "1", optional = true } # Pulled in only by the `host` feature, which adds the `acadrust`-typed # `HostApi` runtime surface. The default crate stays dependency-free so engine @@ -30,7 +31,7 @@ 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:interprocess", "dep:bincode", "dep:serde_bytes", "dep:thiserror", "dep:libloading", "dep:memmap2", "dep:rkyv", "dep:getrandom"] +host = ["dep:acadrust", "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" diff --git a/crates/ocs_plugin_api/README.md b/crates/ocs_plugin_api/README.md index 2ad586863..eee1f09d8 100644 --- a/crates/ocs_plugin_api/README.md +++ b/crates/ocs_plugin_api/README.md @@ -9,7 +9,7 @@ cheaply. The runtime host surface is enabled by the `host` feature. ## Plugin Architecture OCS is scriptable and extensible through a versioned plugin API. The API -supports four major generations: **V2**, **V3**, **V4**, and **V5**. Each +supports five major generations: **V2**, **V3**, **V4**, **V5**, and **V6**. Each generation keeps the previous ABI stable by appending new vtable entries and new enum variants at the end, so older plugins continue to load on newer hosts. @@ -19,10 +19,11 @@ enum variants at the end, so older plugins continue to load on newer hosts. | **V3** | Out-of-process runner | Local cached `document()`/`document_mut()` + shared-memory `DocumentReader`/`document_view` for large reads | New vtable entries appended; new `PluginRequest`/`PluginResponse` variants appended | | **V4** | Out-of-process runner over a multiplexed local socket | Full-duplex notifications + asynchronous REPL `ExecuteCode` | New `HostToPluginV4`/`PluginToHostV4` frame layer; `BuiltinPlugin::on_notification` and `start_execute_code` appended to trait | | **V5** | V4 multiplexed runner | One-shot initialization + tab-keyed document paths | `BuiltinPlugin::on_load` and `HostApi::document_path` appended to the traits | +| **V6** | V4 multiplexed runner | Read and write supported host system variables | `HostApi::system_variable` and `HostApi::set_system_variable` appended to the trait; request/response variants appended | The host controls which generations are accepted at runtime through -`OCS_PLUGIN_MAX_API_VERSION` (e.g. `2` for V2-only mode, `4` to disable V5). The -current host advertises [`API_VERSION = 5`](src/manifest.rs) and supports plugins +`OCS_PLUGIN_MAX_API_VERSION` (e.g. `2` for V2-only mode, `5` to disable V6). The +current host advertises [`API_VERSION = 6`](src/manifest.rs) and supports plugins back to `API_VERSION_MIN_SUPPORTED = 2`. V5 calls `BuiltinPlugin::on_load` once after the runner connects and before the @@ -30,6 +31,10 @@ first user command. Plugins can cache `HostApi::plugin_request_sender()` there for worker-thread access. A panic in the callback fails plugin loading. `HostApi::document_path(tab_id)` returns the saved path for a document tab. +V6 adds a typed system-variable interface. This fork currently implements +`CLAYER` (current drawing layer) and `SNAPANG` (snap-angle degrees). Unknown +variables return an error rather than silently accepting a value. + ### High-level layout ```mermaid diff --git a/crates/ocs_plugin_api/build.rs b/crates/ocs_plugin_api/build.rs index 7a284bc60..87387e1c5 100644 --- a/crates/ocs_plugin_api/build.rs +++ b/crates/ocs_plugin_api/build.rs @@ -14,6 +14,32 @@ use serde_reflection::{ // Include the stable schema types so the same definitions are used at build // time and at runtime. The file is self-contained and only depends on serde. include!("src/type_registry_types.rs"); +mod entity_coverage_schema { + include!("src/entity_coverage_types.rs"); +} +use entity_coverage_schema::{ + EntityCoverageCatalog, EntityKindCoverage, EntityScope, ModelAccess, PropertyCoverage, +}; + +#[derive(serde::Deserialize)] +struct SyntheticProperty { + name: String, + type_id: String, + access: String, +} + +#[derive(serde::Deserialize)] +struct EntityCoveragePolicy { + internal_kinds: Vec, + opaque_kinds: Vec, + editable: BTreeMap>, + readable: BTreeMap>, + aliases: BTreeMap>, + /// Properties for kinds whose payload is a nested enum (Dimension), where + /// the traced variant has no single field list to map. + #[serde(default)] + synthetic: BTreeMap>, +} fn main() { let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap()); @@ -23,6 +49,8 @@ fn main() { "cargo:rerun-if-changed={}", workspace_cargo_lock_path().display() ); + println!("cargo:rerun-if-changed=entity_coverage_policy.json"); + println!("cargo:rerun-if-changed=src/entity_coverage_types.rs"); } // ════════════════════════════════════════════════════════════════════════════ @@ -486,6 +514,353 @@ fn generate_type_registry(out_dir: &Path) { ); let json = serde_json::to_string_pretty(®istry).unwrap(); fs::write(out_dir.join("type_registry.json"), json).unwrap(); + generate_entity_coverage(out_dir, ®istry); +} + +fn generate_entity_coverage(out_dir: &Path, registry: &TypeRegistry) { + use std::collections::HashSet; + let policy: EntityCoveragePolicy = + serde_json::from_str(include_str!("entity_coverage_policy.json")) + .expect("valid entity coverage policy"); + let variants = ®istry.types[&TypeId::new("EntityType")].variants; + let variant_names: HashSet<&str> = variants.iter().map(|v| v.name.as_str()).collect(); + for name in policy + .internal_kinds + .iter() + .chain(&policy.opaque_kinds) + .chain(policy.editable.keys()) + .chain(policy.readable.keys()) + { + assert!( + variant_names.contains(name.as_str()), + "coverage policy names unknown kind: {name}" + ); + } + for name in &policy.internal_kinds { + assert!( + !policy.opaque_kinds.contains(name) && !policy.editable.contains_key(name), + "internal kind has conflicting coverage: {name}" + ); + } + for name in &policy.opaque_kinds { + assert!( + !policy.editable.contains_key(name), + "opaque kind is editable: {name}" + ); + } + for name in policy.aliases.keys() { + assert!( + policy.editable.contains_key(name), + "aliases for non-editable kind: {name}" + ); + } + + let common = ®istry.types[&TypeId::new("EntityCommon")]; + let mut entity_kinds = Vec::with_capacity(variants.len()); + for variant in variants { + let kind = &variant.name; + let scope = if policy.internal_kinds.contains(kind) { + EntityScope::Internal + } else if policy.opaque_kinds.contains(kind) { + EntityScope::Opaque + } else { + EntityScope::Canvas + }; + let editable = policy.editable.get(kind).cloned().unwrap_or_default(); + let readable = policy.readable.get(kind).cloned().unwrap_or_default(); + let mut unresolved: HashSet = editable.iter().chain(&readable).cloned().collect(); + assert_eq!( + unresolved.len(), + editable.len() + readable.len(), + "duplicate mapped property in {kind}" + ); + let aliases = policy.aliases.get(kind); + let mut properties = vec![PropertyCoverage { + name: "kind".into(), + source_path: "".into(), + type_id: "String".into(), + optional: false, + is_sequence: false, + snapshot_readable: true, + model_access: ModelAccess::ReadOnly, + validation: "none".into(), + }]; + for field in &common.fields { + let (name, access) = match field.name.as_str() { + "handle" => ("handle".to_owned(), ModelAccess::ReadOnly), + "owner_handle" => ("owner_handle".to_owned(), ModelAccess::ReadOnly), + "layer" if scope == EntityScope::Canvas => { + unresolved.remove("layer"); + ("layer".to_owned(), ModelAccess::ReadWrite) + } + "layer" => ("layer".to_owned(), ModelAccess::ReadOnly), + other => (format!("common.{other}"), ModelAccess::Unmapped), + }; + properties.push(PropertyCoverage { + name, + source_path: format!("common.{}", field.name), + type_id: field.type_id.as_str().to_owned(), + optional: field.optional, + is_sequence: field.is_sequence, + snapshot_readable: true, + validation: if access == ModelAccess::ReadWrite && field.name == "layer" { + "transaction_nonempty" + } else if access == ModelAccess::ReadWrite { + "type_conversion_only" + } else { + "none" + } + .into(), + model_access: access, + }); + } + let shape_type = variant + .fields + .first() + .expect("entity variant payload") + .type_id + .clone(); + let shape = ®istry.types[&shape_type]; + for field in &shape.fields { + if field.name == "common" { + continue; + } + let exposed = editable.iter().chain(&readable).find(|name| { + aliases + .and_then(|map| map.get(*name)) + .map_or(name.as_str(), String::as_str) + == field.name + }); + let (name, access) = match exposed { + Some(name) => { + unresolved.remove(name); + ( + (*name).clone(), + if editable.contains(name) { + ModelAccess::ReadWrite + } else { + ModelAccess::ReadOnly + }, + ) + } + None => (field.name.clone(), ModelAccess::Unmapped), + }; + let validation = if access != ModelAccess::ReadWrite { + "none" + } else if matches!( + (kind.as_str(), name.as_str()), + ("Point", "location") + | ("Line", "start" | "end") + | ("Circle" | "Arc", "center" | "radius") + | ("Ray" | "XLine", "base_point" | "direction") + | ( + "Solid", + "first_corner" + | "second_corner" + | "third_corner" + | "fourth_corner" + | "normal" + | "thickness" + ) + | ( + "Face3D", + "first_corner" + | "second_corner" + | "third_corner" + | "fourth_corner" + | "invisible_edges" + ) + | ( + "Insert", + "insert_point" + | "x_scale" + | "y_scale" + | "z_scale" + | "rotation" + | "normal" + | "column_count" + | "row_count" + | "column_spacing" + | "row_spacing" + ) + | ( + "Tolerance", + "insertion_point" + | "direction" + | "normal" + | "text" + | "dimension_style_name" + | "text_height" + | "dimension_gap" + ) + | ( + "Shape", + "insertion_point" + | "size" + | "shape_name" + | "shape_number" + | "rotation" + | "relative_x_scale" + | "oblique_angle" + | "normal" + | "thickness" + | "style_name" + ) + | ( + "AttributeDefinition", + "tag" + | "prompt" + | "default_value" + | "insertion_point" + | "alignment_point" + | "height" + | "rotation" + | "width_factor" + | "oblique_angle" + | "text_style" + | "text_generation_flags" + | "horizontal_alignment" + | "vertical_alignment" + | "flags" + | "field_length" + | "normal" + | "mtext_flag" + | "is_multiline" + | "line_count" + | "lock_position" + ) + | ( + "AttributeEntity", + "tag" + | "value" + | "insertion_point" + | "alignment_point" + | "height" + | "rotation" + | "width_factor" + | "oblique_angle" + | "text_style" + | "text_generation_flags" + | "horizontal_alignment" + | "vertical_alignment" + | "flags" + | "field_length" + | "normal" + | "mtext_flag" + | "is_multiline" + | "line_count" + | "lock_position" + ) + | ( + "Hatch", + "elevation" + | "normal" + | "is_solid" + | "pattern" + | "pattern_angle" + | "pattern_scale" + | "pattern_type" + | "is_double" + | "style" + | "is_associative" + | "pixel_size" + | "paths" + | "seed_points" + ) + | ( + "Leader", + "dimension_style" + | "arrow_enabled" + | "path_type" + | "creation_type" + | "hookline_direction" + | "hookline_enabled" + | "text_height" + | "text_width" + | "vertices" + | "override_color" + | "annotation_handle" + | "normal" + | "horizontal_direction" + | "block_offset" + | "annotation_offset" + ) + | ("MultiLeader", _) + | ("Table", _) + | ("PolygonMesh", _) + | ("PolyfaceMesh", _) + | ("Mesh", _) + | ("Helix", _) + | ("RasterImage", _) + | ("Wipeout", _) + | ("SectionSymbol", _) + | ("Surface", "u_isolines" | "v_isolines") + | ("Ole2Frame", _) + | ("Underlay", _) + | ("Viewport", _) + | ("ViewBorder", _) + | ("Light", _) + | ( + "MLine", + "flags" + | "justification" + | "normal" + | "scale_factor" + | "style_name" + | "vertices" + ) + ) { + "transaction_geometry" + } else { + "type_conversion_only" + }; + properties.push(PropertyCoverage { + name, + source_path: field.name.clone(), + type_id: if exposed.is_some_and(|name| name == "closed") { + "bool".into() + } else { + field.type_id.as_str().to_owned() + }, + optional: field.optional, + is_sequence: field.is_sequence, + snapshot_readable: true, + validation: validation.into(), + model_access: access, + }); + } + for synthetic in policy.synthetic.get(kind).into_iter().flatten() { + let writable = synthetic.access == "read_write"; + properties.push(PropertyCoverage { + name: synthetic.name.clone(), + source_path: format!(".{}", synthetic.name), + type_id: synthetic.type_id.clone(), + optional: false, + is_sequence: false, + snapshot_readable: true, + validation: if writable { "transaction_geometry" } else { "none" }.into(), + model_access: if writable { ModelAccess::ReadWrite } else { ModelAccess::ReadOnly }, + }); + } + assert!( + unresolved.is_empty(), + "{kind} has coverage keys absent from registry: {unresolved:?}" + ); + entity_kinds.push(EntityKindCoverage { + kind: kind.clone(), + scope, + properties, + }); + } + let catalog = EntityCoverageCatalog { + schema_version: 1, + entity_kinds, + }; + fs::write( + out_dir.join("entity_coverage.json"), + serde_json::to_string_pretty(&catalog).unwrap(), + ) + .expect("write entity coverage"); } fn trace(tracer: &mut Tracer, samples: &Samples) @@ -908,7 +1283,7 @@ fn generate_version_info(out_dir: &Path) { "acadrust_version": acadrust.version.to_string(), "acadrust_source": acadrust.source.as_ref().map(|s| s.to_string()), "rustc_version": rustc_version, - "api_version": 5, + "api_version": 7, "api_version_min_supported": 2, "build_timestamp": build_timestamp, }); diff --git a/crates/ocs_plugin_api/entity_coverage_policy.json b/crates/ocs_plugin_api/entity_coverage_policy.json new file mode 100644 index 000000000..79b1fe3ec --- /dev/null +++ b/crates/ocs_plugin_api/entity_coverage_policy.json @@ -0,0 +1,866 @@ +{ + "internal_kinds": [ + "Block", + "BlockEnd", + "Seqend" + ], + "opaque_kinds": [ + "Extended", + "Unknown" + ], + "editable": { + "Point": [ + "layer", + "location", + "thickness", + "normal", + "x_axis_angle" + ], + "Line": [ + "layer", + "start", + "end", + "thickness", + "normal" + ], + "Circle": [ + "layer", + "center", + "radius", + "thickness", + "normal" + ], + "Arc": [ + "layer", + "center", + "radius", + "start_angle", + "end_angle", + "thickness", + "normal" + ], + "Ellipse": [ + "layer", + "center", + "major_axis", + "minor_axis_ratio", + "start_parameter", + "end_parameter", + "normal" + ], + "Polyline": [ + "layer", + "closed", + "vertices" + ], + "Polyline2D": [ + "layer", + "closed", + "smooth_surface", + "start_width", + "end_width", + "thickness", + "elevation", + "normal", + "vertices" + ], + "Polyline3D": [ + "layer", + "flags", + "default_start_width", + "default_end_width", + "mesh_m_count", + "mesh_n_count", + "smooth_m_density", + "smooth_n_density", + "elevation", + "normal", + "vertices" + ], + "LwPolyline": [ + "layer", + "vertices", + "is_closed", + "plinegen", + "constant_width", + "elevation", + "thickness", + "normal" + ], + "Spline": [ + "layer", + "degree", + "flags", + "knots", + "control_points", + "weights", + "fit_points", + "normal", + "knot_tolerance", + "control_tolerance", + "fit_tolerance", + "begin_tangent", + "end_tangent", + "knot_parameterization", + "cv_frame_visible", + "dwg_flags1", + "dxf_flags" + ], + "Text": [ + "layer", + "text", + "insertion", + "alignment_point", + "height", + "rotation", + "width_factor", + "oblique_angle", + "style", + "horizontal_alignment", + "vertical_alignment", + "normal", + "thickness", + "generation_flags" + ], + "MText": [ + "layer", + "text", + "insertion", + "height", + "rectangle_width", + "rectangle_height", + "rotation", + "dwg_x_direction", + "style", + "attachment_point", + "drawing_direction", + "line_spacing_factor", + "line_spacing_style", + "normal", + "background_fill_flags", + "background_scale", + "background_transparency", + "is_annotative", + "column_data", + "extents_width", + "extents_height" + ], + "Ray": [ + "layer", + "base_point", + "direction" + ], + "XLine": [ + "layer", + "base_point", + "direction" + ], + "Solid": [ + "layer", + "first_corner", + "second_corner", + "third_corner", + "fourth_corner", + "normal", + "thickness", + "is_trace" + ], + "Face3D": [ + "layer", + "first_corner", + "second_corner", + "third_corner", + "fourth_corner", + "invisible_edges" + ], + "Insert": [ + "layer", + "block_name", + "insert_point", + "x_scale", + "y_scale", + "z_scale", + "rotation", + "normal", + "column_count", + "row_count", + "column_spacing", + "row_spacing" + ], + "Tolerance": [ + "layer", + "insertion_point", + "direction", + "normal", + "text", + "dimension_style_name", + "text_height", + "dimension_gap" + ], + "Shape": [ + "layer", + "insertion_point", + "size", + "shape_name", + "shape_number", + "rotation", + "relative_x_scale", + "oblique_angle", + "normal", + "thickness", + "style_name" + ], + "AttributeDefinition": [ + "layer", + "tag", + "prompt", + "default_value", + "insertion_point", + "alignment_point", + "height", + "rotation", + "width_factor", + "oblique_angle", + "text_style", + "text_generation_flags", + "horizontal_alignment", + "vertical_alignment", + "flags", + "field_length", + "normal", + "mtext_flag", + "is_multiline", + "line_count", + "lock_position" + ], + "AttributeEntity": [ + "layer", + "tag", + "value", + "insertion_point", + "alignment_point", + "height", + "rotation", + "width_factor", + "oblique_angle", + "text_style", + "text_generation_flags", + "horizontal_alignment", + "vertical_alignment", + "flags", + "field_length", + "normal", + "mtext_flag", + "is_multiline", + "line_count", + "lock_position" + ], + "Hatch": [ + "layer", + "elevation", + "normal", + "is_solid", + "pattern", + "pattern_angle", + "pattern_scale", + "pattern_type", + "is_double", + "style", + "is_associative", + "pixel_size", + "paths", + "seed_points" + ], + "Leader": [ + "layer", + "dimension_style", + "arrow_enabled", + "path_type", + "creation_type", + "hookline_direction", + "hookline_enabled", + "text_height", + "text_width", + "vertices", + "override_color", + "annotation_handle", + "normal", + "horizontal_direction", + "block_offset", + "annotation_offset" + ], + "MLine": [ + "layer", + "flags", + "justification", + "normal", + "scale_factor", + "style_name", + "vertices" + ], + "MultiLeader": [ + "layer", + "content_type", + "path_type", + "line_color", + "line_weight", + "enable_landing", + "enable_dogleg", + "dogleg_length", + "arrowhead_size", + "text_color", + "text_frame", + "text_left_attachment", + "text_right_attachment", + "text_top_attachment", + "text_bottom_attachment", + "text_attachment_direction", + "text_attachment_point", + "text_alignment", + "text_angle_type", + "text_direction_negative", + "scale_factor", + "enable_annotation_scale", + "extend_leader_to_text", + "block_content_color", + "block_connection_type", + "block_rotation", + "block_scale", + "context", + "style_handle", + "text_style_handle", + "arrowhead_handle", + "line_type_handle", + "block_content_handle" + ], + "Table": [ + "layer", + "insertion_point", + "horizontal_direction", + "normal", + "table_style_handle", + "rows", + "columns", + "merged_ranges", + "break_spacing", + "break_flow_direction", + "break_options" + ], + "PolygonMesh": [ + "layer", + "flags", + "m_vertex_count", + "n_vertex_count", + "m_smooth_density", + "n_smooth_density", + "smooth_type", + "elevation", + "normal", + "vertices" + ], + "PolyfaceMesh": [ + "layer", + "elevation", + "flags", + "normal", + "start_width", + "end_width", + "smooth_surface", + "thickness", + "vertices", + "faces" + ], + "Mesh": [ + "layer", + "blend_crease", + "subdivision_level", + "vertices", + "faces", + "edges", + "override_option" + ], + "Helix": [ + "layer", + "axis_base_point", + "start_point", + "axis_vector", + "radius", + "turns", + "turn_height", + "handedness", + "constraint" + ], + "RasterImage": [ + "layer", + "insertion_point", + "u_vector", + "v_vector", + "flags", + "clipping_enabled", + "brightness", + "contrast", + "fade", + "clip_boundary", + "file_path" + ], + "Wipeout": [ + "layer", + "insertion_point", + "u_vector", + "v_vector", + "size", + "flags", + "clipping_enabled", + "brightness", + "contrast", + "fade", + "clip_mode", + "clip_type", + "clip_boundary_vertices" + ], + "Underlay": [ + "layer", + "underlay_type", + "definition_handle", + "insertion_point", + "x_scale", + "y_scale", + "z_scale", + "rotation", + "normal", + "flags", + "contrast", + "fade", + "clip_boundary_vertices", + "clip_inverted" + ], + "Viewport": [ + "layer", + "center", + "width", + "height", + "status", + "view_center", + "view_direction", + "view_target", + "lens_length", + "front_clip_z", + "back_clip_z", + "view_height", + "twist_angle", + "frozen_layers", + "render_mode", + "circle_sides", + "ucs_icon_visible" + ], + "ViewBorder": [ + "layer", + "min", + "max", + "center", + "scale", + "rotation_angle", + "active_viewport", + "scale_handle" + ], + "Light": [ + "layer", + "name", + "light_type", + "position", + "target", + "status", + "light_color", + "plot_glyph", + "intensity", + "attenuation_type", + "use_attenuation_limits", + "attenuation_start_limit", + "attenuation_end_limit", + "hotspot_angle", + "falloff_angle", + "cast_shadows", + "shadow_type", + "shadow_map_size", + "shadow_map_softness", + "photometric_mode", + "photometric_data" + ], + "Ole2Frame": [ + "layer", + "upper_left_corner", + "lower_right_corner", + "lock_aspect", + "is_paper_space" + ], + "Solid3D": [ + "layer" + ], + "Body": [ + "layer" + ], + "Region": [ + "layer" + ], + "Surface": [ + "layer", + "u_isolines", + "v_isolines" + ], + "SectionSymbol": [ + "layer", + "symbol_scale", + "points", + "style_handle", + "view_rep_handle" + ] + }, + "readable": { + "Tolerance": [ + "dimension_style_handle" + ], + "Shape": [ + "style_handle" + ], + "MLine": [ + "start_point", + "style_handle", + "style_element_count" + ], + "Leader": [ + "origin", + "dimension_gap", + "arrowhead_type", + "arrow_size", + "byblock_color" + ], + "AttributeEntity": [ + "attdef_handle" + ], + "MultiLeader": [ + "dwg_version", + "property_override_flags", + "text_align_in_ipe", + "text_height" + ], + "Table": [ + "block_name", + "block_record_handle", + "data_version", + "value_flags", + "override_flag", + "override_border_color", + "override_border_line_weight", + "override_border_visibility", + "legacy_style_override", + "name", + "description", + "field_handles", + "base_style", + "break_data", + "break_ranges" + ], + "PolyfaceMesh": [ + "seqend_handle" + ], + "Mesh": [ + "version" + ], + "Helix": [ + "spline", + "major_version", + "maintenance_version" + ], + "RasterImage": [ + "class_version", + "definition_handle", + "definition_reactor_handle", + "size" + ], + "Wipeout": [ + "class_version", + "definition_handle", + "definition_reactor_handle" + ], + "Viewport": [ + "id", + "snap_base", + "snap_spacing", + "grid_spacing", + "snap_angle", + "clip_boundary_handle", + "style_sheet", + "ucs_at_origin", + "ucs_per_viewport", + "ucs_origin", + "ucs_x_axis", + "ucs_y_axis", + "ucs_handle", + "base_ucs_handle", + "ucs_ortho_type", + "elevation", + "shade_plot_mode", + "grid_flags", + "grid_major", + "background_handle", + "shade_plot_handle", + "visual_style_handle", + "sun_handle", + "default_lighting", + "default_lighting_type", + "brightness", + "contrast", + "ambient_color", + "custom_scale" + ], + "ViewBorder": [ + "version" + ], + "Light": [ + "class_version" + ], + "Ole2Frame": [ + "version", + "source_application", + "ole_object_type", + "dwg_mode" + ], + "Solid3D": [ + "uid", + "point_of_reference", + "history_handle" + ], + "Body": [ + "uid", + "point_of_reference", + "history_handle" + ], + "Region": [ + "uid", + "point_of_reference", + "history_handle" + ], + "Surface": [ + "surface_kind", + "modeler_format_version", + "point_of_reference", + "history_handle" + ], + "SectionSymbol": [ + "view_symbol_version", + "raw_view_symbol_70", + "version", + "raw_point_count_90", + "raw_flags_90", + "raw_point_record_count", + "end_a", + "end_b", + "tick_a", + "tick_b", + "label" + ] + }, + "aliases": { + "Polyline": { + "closed": "flags" + }, + "Polyline2D": { + "closed": "flags" + }, + "Text": { + "text": "value", + "insertion": "insertion_point" + }, + "MText": { + "text": "value", + "insertion": "insertion_point" + }, + "Surface": { + "surface_kind": "kind" + } + }, + "synthetic": { + "Dimension": [ + { + "name": "subtype", + "type_id": "String", + "access": "read_only" + }, + { + "name": "actual_measurement", + "type_id": "f64", + "access": "read_only" + }, + { + "name": "text", + "type_id": "String", + "access": "read_write" + }, + { + "name": "style_name", + "type_id": "String", + "access": "read_write" + }, + { + "name": "normal", + "type_id": "Vector3", + "access": "read_write" + }, + { + "name": "text_middle_point", + "type_id": "Vector3", + "access": "read_write" + }, + { + "name": "attachment_point", + "type_id": "AttachmentPointType", + "access": "read_write" + }, + { + "name": "text_rotation", + "type_id": "f64", + "access": "read_write" + }, + { + "name": "horizontal_direction", + "type_id": "f64", + "access": "read_write" + }, + { + "name": "flip_arrow1", + "type_id": "bool", + "access": "read_write" + }, + { + "name": "flip_arrow2", + "type_id": "bool", + "access": "read_write" + }, + { + "name": "text_user_positioned", + "type_id": "bool", + "access": "read_write" + }, + { + "name": "definition_point", + "type_id": "Vector3", + "access": "read_write" + }, + { + "name": "first_point", + "type_id": "Vector3", + "access": "read_write" + }, + { + "name": "second_point", + "type_id": "Vector3", + "access": "read_write" + }, + { + "name": "angle_vertex", + "type_id": "Vector3", + "access": "read_write" + }, + { + "name": "dimension_arc", + "type_id": "Vector3", + "access": "read_write" + }, + { + "name": "feature_location", + "type_id": "Vector3", + "access": "read_write" + }, + { + "name": "leader_endpoint", + "type_id": "Vector3", + "access": "read_write" + }, + { + "name": "first_extension_point", + "type_id": "Vector3", + "access": "read_write" + }, + { + "name": "second_extension_point", + "type_id": "Vector3", + "access": "read_write" + }, + { + "name": "center_point", + "type_id": "Vector3", + "access": "read_write" + }, + { + "name": "first_leader_point", + "type_id": "Vector3", + "access": "read_write" + }, + { + "name": "second_leader_point", + "type_id": "Vector3", + "access": "read_write" + }, + { + "name": "chord_point", + "type_id": "Vector3", + "access": "read_write" + }, + { + "name": "override_center", + "type_id": "Vector3", + "access": "read_write" + }, + { + "name": "jog_point", + "type_id": "Vector3", + "access": "read_write" + }, + { + "name": "rotation", + "type_id": "f64", + "access": "read_write" + }, + { + "name": "ext_line_rotation", + "type_id": "f64", + "access": "read_write" + }, + { + "name": "leader_length", + "type_id": "f64", + "access": "read_write" + }, + { + "name": "arc_start_parameter", + "type_id": "f64", + "access": "read_write" + }, + { + "name": "arc_end_parameter", + "type_id": "f64", + "access": "read_write" + }, + { + "name": "jog_angle", + "type_id": "f64", + "access": "read_write" + }, + { + "name": "is_ordinate_type_x", + "type_id": "bool", + "access": "read_write" + }, + { + "name": "is_partial", + "type_id": "bool", + "access": "read_write" + }, + { + "name": "has_leader", + "type_id": "bool", + "access": "read_write" + } + ] + } +} diff --git a/crates/ocs_plugin_api/src/entity_coverage.rs b/crates/ocs_plugin_api/src/entity_coverage.rs new file mode 100644 index 000000000..a58dba1da --- /dev/null +++ b/crates/ocs_plugin_api/src/entity_coverage.rs @@ -0,0 +1,3468 @@ +//! Host-owned coverage generated from the traced entity registry and an +//! explicit policy. `unmapped` fields remain accessible through the typed +//! `CadDocument` snapshot but are not promised by the Python document model. + +pub use crate::entity_coverage_types::*; + +pub const EMBEDDED_ENTITY_COVERAGE_JSON: &str = + include_str!(concat!(env!("OUT_DIR"), "/entity_coverage.json")); + +pub fn get_embedded_entity_coverage_json() -> &'static str { + EMBEDDED_ENTITY_COVERAGE_JSON +} + +/// Read-only JSON serialization of one typed entity. The outer object +/// has one key: the `EntityType` variant name used by the coverage catalog. +/// JSON does not preserve non-finite floating-point values, so this is an +/// inspection format and must remain separate from the typed entity write path. +#[cfg(feature = "host")] +pub fn entity_snapshot( + entity: &crate::host::EntityType, +) -> Result { + serde_json::to_value(entity) +} + +#[cfg(feature = "host")] +fn finite_vector(name: &str, value: &crate::host::acadrust::types::Vector3) -> Result<(), String> { + if value.x.is_finite() && value.y.is_finite() && value.z.is_finite() { + Ok(()) + } else { + Err(format!("{name} must contain finite coordinates")) + } +} + +#[cfg(feature = "host")] +fn unit_direction(name: &str, value: &crate::host::acadrust::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 { + Ok(()) + } else { + Err(format!("{name} must be a unit vector")) + } +} + +#[cfg(feature = "host")] +fn solid_normal(name: &str, value: &crate::host::acadrust::types::Vector3) -> Result<(), String> { + finite_vector(name, value)?; + if value.x.hypot(value.y).hypot(value.z) > 0.0 { + Ok(()) + } else { + Err(format!("{name} must be nonzero")) + } +} + +#[cfg(feature = "host")] +fn insert_scale(name: &str, value: f64) -> Result<(), String> { + if value.is_finite() && value.abs() >= 1e-12 { + Ok(()) + } else { + Err(format!("{name} must be finite and nonzero")) + } +} + +#[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; + 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( + center.x + major.x * angle.cos() - major.y * ratio * angle.sin(), + center.y + major.y * angle.cos() + major.x * ratio * angle.sin(), + ) + }; + if paths.is_empty() { + return Err("Hatch requires at least one boundary path".into()); + } + for (pi, path) in paths.iter().enumerate() { + if path.edges.is_empty() { + return Err(format!("Hatch boundary path {pi} has no edges")); + } + if path.flags.is_not_closed() { + return Err(format!("Hatch boundary path {pi} is marked open")); + } + if path.flags.bits() & !0x1ff != 0 { + return Err(format!("Hatch boundary path {pi} has unknown flag bits")); + } + let mut endpoints = Vec::with_capacity(path.edges.len()); + for (ei, edge) in path.edges.iter().enumerate() { + let pair = match edge { + BoundaryEdge::Line(line) => { + if !line.start.x.is_finite() || !line.start.y.is_finite() || !line.end.x.is_finite() || !line.end.y.is_finite() { + return Err(format!("Hatch boundary path {pi} edge {ei} line has non-finite coordinates")); + } + if (line.start.x - line.end.x).hypot(line.start.y - line.end.y) < 1e-12 { + return Err(format!("Hatch boundary path {pi} edge {ei} line has zero length")); + } + (line.start, line.end) + } + BoundaryEdge::CircularArc(arc) => { + if !arc.center.x.is_finite() || !arc.center.y.is_finite() { + return Err(format!("Hatch boundary path {pi} edge {ei} arc center has non-finite coordinates")); + } + if !arc.radius.is_finite() || arc.radius <= 0.0 { + return Err(format!("Hatch boundary path {pi} edge {ei} arc radius must be finite and positive")); + } + if !arc.start_angle.is_finite() || !arc.end_angle.is_finite() { + return Err(format!("Hatch boundary path {pi} edge {ei} arc angles must be finite")); + } + let point = |angle: f64| Vector2::new( + arc.center.x + arc.radius * angle.cos(), + arc.center.y + arc.radius * angle.sin(), + ); + let (start, end) = (point(arc.start_angle), point(arc.end_angle)); + if arc.counter_clockwise { (start, end) } else { (end, start) } + } + BoundaryEdge::EllipticArc(arc) => { + if !arc.center.x.is_finite() || !arc.center.y.is_finite() { + return Err(format!("Hatch boundary path {pi} edge {ei} ellipse center has non-finite coordinates")); + } + if !arc.major_axis_endpoint.x.is_finite() || !arc.major_axis_endpoint.y.is_finite() { + return Err(format!("Hatch boundary path {pi} edge {ei} ellipse major axis has non-finite coordinates")); + } + if arc.major_axis_endpoint.x.hypot(arc.major_axis_endpoint.y) < 1e-12 { + return Err(format!("Hatch boundary path {pi} edge {ei} ellipse major axis has zero length")); + } + if !arc.minor_axis_ratio.is_finite() || !(0.0..=1.0).contains(&arc.minor_axis_ratio) || arc.minor_axis_ratio == 0.0 { + return Err(format!("Hatch boundary path {pi} edge {ei} ellipse ratio must be within (0, 1]")); + } + if !arc.start_angle.is_finite() || !arc.end_angle.is_finite() { + return Err(format!("Hatch boundary path {pi} edge {ei} ellipse angles must be finite")); + } + let start = ellipse_point(arc.center, arc.major_axis_endpoint, arc.minor_axis_ratio, arc.start_angle); + let end = ellipse_point(arc.center, arc.major_axis_endpoint, arc.minor_axis_ratio, arc.end_angle); + if arc.counter_clockwise { (start, end) } else { (end, start) } + } + BoundaryEdge::Polyline(poly) => { + if poly.vertices.len() < 3 { + return Err(format!("Hatch boundary path {pi} edge {ei} polyline must have at least 3 vertices")); + } + for (vi, v) in poly.vertices.iter().enumerate() { + if !v.x.is_finite() || !v.y.is_finite() || !v.z.is_finite() { + return Err(format!("Hatch boundary path {pi} edge {ei} vertex {vi} has non-finite coordinates")); + } + } + if !poly.is_closed { + return Err(format!("Hatch boundary path {pi} edge {ei} polyline is open")); + } + let first = poly.vertices.first().unwrap(); + (Vector2::new(first.x, first.y), Vector2::new(first.x, first.y)) + } + BoundaryEdge::Spline(spline) => { + if spline.degree < 1 { + return Err(format!("Hatch boundary path {pi} edge {ei} spline degree must be at least 1")); + } + if spline.control_points.len() < spline.degree as usize + 1 { + return Err(format!("Hatch boundary path {pi} edge {ei} spline has too few control points for its degree")); + } + if spline.knots.iter().any(|knot| !knot.is_finite()) + || spline.knots.windows(2).any(|pair| pair[0] > pair[1]) + { + return Err(format!("Hatch boundary path {pi} edge {ei} spline knots must be finite and nondecreasing")); + } + for (ci, cp) in spline.control_points.iter().enumerate() { + if !cp.x.is_finite() || !cp.y.is_finite() || !cp.z.is_finite() { + return Err(format!("Hatch boundary path {pi} edge {ei} control point {ci} has non-finite coordinates")); + } + } + for (fi, fp) in spline.fit_points.iter().enumerate() { + if !fp.x.is_finite() || !fp.y.is_finite() { + return Err(format!("Hatch boundary path {pi} edge {ei} fit point {fi} has non-finite coordinates")); + } + } + for (name, tangent) in [("start", spline.start_tangent), ("end", spline.end_tangent)] { + if !tangent.x.is_finite() || !tangent.y.is_finite() { + return Err(format!("Hatch boundary path {pi} edge {ei} spline {name} tangent is non-finite")); + } + } + let points = if spline.fit_points.len() >= 2 { + spline.fit_points.clone() + } else { + spline.control_points.iter().map(|p| Vector2::new(p.x, p.y)).collect() + }; + if points.len() < 2 { + return Err(format!("Hatch boundary path {pi} edge {ei} spline has too few points")); + } + (*points.first().unwrap(), *points.last().unwrap()) + } + }; + endpoints.push(pair); + } + for ei in 0..endpoints.len() { + let next = (ei + 1) % endpoints.len(); + if !close(endpoints[ei].1, endpoints[next].0) { + return Err(format!("Hatch boundary path {pi} is not closed between edges {ei} and {next}")); + } + } + } + Ok(()) +} + +#[cfg(feature = "host")] +fn validate_hatch_pattern(value: &crate::host::acadrust::entities::Hatch) -> Result<(), String> { + if value.pattern.name.trim().is_empty() { + return Err("Hatch.pattern.name is empty".into()); + } + if value.is_solid { + if !value.pattern.name.eq_ignore_ascii_case("SOLID") || !value.pattern.lines.is_empty() { + return Err("solid Hatch requires the SOLID pattern without pattern lines".into()); + } + return Ok(()); + } + if !value.pattern_scale.is_finite() || value.pattern_scale <= 0.0 { + return Err("Hatch.pattern_scale must be finite and greater than zero".into()); + } + if value.pattern.lines.is_empty() { + return Err("patterned Hatch requires at least one pattern line".into()); + } + for (index, line) in value.pattern.lines.iter().enumerate() { + if !line.angle.is_finite() + || !line.base_point.x.is_finite() || !line.base_point.y.is_finite() + || !line.offset.x.is_finite() || !line.offset.y.is_finite() + || line.dash_lengths.iter().any(|dash| !dash.is_finite()) + { + return Err(format!("Hatch.pattern.lines[{index}] contains non-finite values")); + } + if line.offset.x.hypot(line.offset.y) < 1e-12 { + return Err(format!("Hatch.pattern.lines[{index}].offset must be nonzero")); + } + } + Ok(()) +} + +/// Field checks shared by Leader creation and mutation. `old` limits the +/// 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, +) -> Result<(), String> { + use crate::host::acadrust::entities::LeaderCreationType; + let vec_changed = |a: &crate::host::acadrust::types::Vector3, + b: &crate::host::acadrust::types::Vector3| { + old.is_none() + || a.x.to_bits() != b.x.to_bits() + || a.y.to_bits() != b.y.to_bits() + || a.z.to_bits() != b.z.to_bits() + }; + if old.is_none_or(|old| old.vertices != new.vertices) { + if new.vertices.len() < 2 { + return Err("Leader.vertices requires at least 2 points".into()); + } + for (index, vertex) in new.vertices.iter().enumerate() { + finite_vector(&format!("Leader.vertices[{index}]"), vertex)?; + } + if new.vertices.windows(2).all(|pair| pair[0] == pair[1]) { + return Err("Leader.vertices must not all coincide".into()); + } + } + if let Some(old_leader) = old { + if vec_changed(&old_leader.normal, &new.normal) { + solid_normal("Leader.normal", &new.normal)?; + } + } else { + solid_normal("Leader.normal", &new.normal)?; + } + if vec_changed( + old.map_or(&new.horizontal_direction, |o| &o.horizontal_direction), + &new.horizontal_direction, + ) { + solid_normal("Leader.horizontal_direction", &new.horizontal_direction)?; + } + for (name, before, after) in [ + ( + "block_offset", + old.map_or(&new.block_offset, |o| &o.block_offset), + &new.block_offset, + ), + ( + "annotation_offset", + old.map_or(&new.annotation_offset, |o| &o.annotation_offset), + &new.annotation_offset, + ), + ] { + if vec_changed(before, after) { + finite_vector(&format!("Leader.{name}"), after)?; + } + } + for (name, before, after) in [ + ( + "text_height", + old.map_or(new.text_height, |o| o.text_height), + new.text_height, + ), + ( + "text_width", + old.map_or(new.text_width, |o| o.text_width), + new.text_width, + ), + ] { + if (old.is_none() || before.to_bits() != after.to_bits()) + && (!after.is_finite() || after < 0.0 || (name == "text_height" && after == 0.0)) + { + return Err(format!( + "Leader.{name} must be finite and {}", + if name == "text_height" { "greater than zero" } else { "non-negative" } + )); + } + } + if old.is_none_or(|o| o.dimension_style != new.dimension_style) + && new.dimension_style.trim().is_empty() + { + return Err("Leader.dimension_style is empty".into()); + } + if new.creation_type == LeaderCreationType::NoAnnotation && !new.annotation_handle.is_null() { + return Err("Leader with no annotation cannot carry an annotation handle".into()); + } + Ok(()) +} + +/// Field checks shared by MLine creation and mutation. Vertex `direction`, +/// `miter` and segment cut parameters are derived by the host, so only their +/// finiteness is checked here. +#[cfg(feature = "host")] +fn validate_mline( + old: Option<&crate::host::acadrust::entities::MLine>, + new: &crate::host::acadrust::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()); + } + if old.is_none_or(|old| old.scale_factor.to_bits() != new.scale_factor.to_bits()) + && (!new.scale_factor.is_finite() || new.scale_factor == 0.0) + { + return Err("MLine.scale_factor must be finite and nonzero".into()); + } + if old.is_none_or(|old| old.normal != new.normal) { + solid_normal("MLine.normal", &new.normal)?; + } + if old.is_none_or(|old| old.style_name != new.style_name) && new.style_name.trim().is_empty() { + return Err("MLine.style_name is empty".into()); + } + if old.is_none_or(|old| old.vertices != new.vertices) { + if new.vertices.len() < 2 { + return Err("MLine.vertices requires at least 2 points".into()); + } + for (vi, vertex) in new.vertices.iter().enumerate() { + finite_vector(&format!("MLine.vertices[{vi}].position"), &vertex.position)?; + finite_vector(&format!("MLine.vertices[{vi}].direction"), &vertex.direction)?; + finite_vector(&format!("MLine.vertices[{vi}].miter"), &vertex.miter)?; + for (si, segment) in vertex.segments.iter().enumerate() { + if segment + .parameters + .iter() + .chain(&segment.area_fill_parameters) + .any(|value| !value.is_finite()) + { + return Err(format!( + "MLine.vertices[{vi}].segments[{si}] contains non-finite parameters" + )); + } + } + } + if new + .vertices + .windows(2) + .all(|pair| pair[0].position == pair[1].position) + { + return Err("MLine.vertices must not all coincide".into()); + } + } + if new.flags.contains(crate::host::acadrust::entities::MLineFlags::CLOSED) + && new.vertices.len() < 3 + { + return Err("closed MLine requires at least 3 vertices".into()); + } + Ok(()) +} + +/// Field checks for every Dimension subtype. Derived fields (base definition +/// point, `actual_measurement`) are recomputed by the host, so they are not +/// 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, +) -> Result<(), String> { + use crate::host::acadrust::entities::Dimension as D; + if old == Some(new) { + return Ok(()); + } + let base = new.base(); + solid_normal("Dimension.normal", &base.normal)?; + finite_vector("Dimension.text_middle_point", &base.text_middle_point)?; + if !base.text_rotation.is_finite() || !base.horizontal_direction.is_finite() { + return Err("Dimension.text_rotation and horizontal_direction must be finite".into()); + } + 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 { + 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)], + vec![("first_point", "second_point")], + ), + D::Linear(d) => ( + vec![("definition_point", &d.definition_point), ("first_point", &d.first_point), ("second_point", &d.second_point)], + vec![("rotation", d.rotation), ("ext_line_rotation", d.ext_line_rotation)], + vec![("first_point", "second_point")], + ), + D::Radius(d) => ( + vec![("definition_point", &d.definition_point), ("angle_vertex", &d.angle_vertex)], + vec![("leader_length", d.leader_length)], + vec![("definition_point", "angle_vertex")], + ), + D::Diameter(d) => ( + vec![("definition_point", &d.definition_point), ("angle_vertex", &d.angle_vertex)], + vec![("leader_length", d.leader_length)], + vec![("definition_point", "angle_vertex")], + ), + D::Angular2Ln(d) => ( + vec![("definition_point", &d.definition_point), ("dimension_arc", &d.dimension_arc), ("first_point", &d.first_point), ("second_point", &d.second_point), ("angle_vertex", &d.angle_vertex)], + vec![], + vec![], + ), + D::Angular3Pt(d) => ( + vec![("definition_point", &d.definition_point), ("first_point", &d.first_point), ("second_point", &d.second_point), ("angle_vertex", &d.angle_vertex)], + vec![], + vec![("first_point", "angle_vertex"), ("second_point", "angle_vertex")], + ), + D::Ordinate(d) => ( + vec![("definition_point", &d.definition_point), ("feature_location", &d.feature_location), ("leader_endpoint", &d.leader_endpoint)], + vec![], + vec![], + ), + D::Arc(d) => ( + vec![("definition_point", &d.definition_point), ("first_extension_point", &d.first_extension_point), ("second_extension_point", &d.second_extension_point), ("center_point", &d.center_point), ("first_leader_point", &d.first_leader_point), ("second_leader_point", &d.second_leader_point)], + vec![("arc_start_parameter", d.arc_start_parameter), ("arc_end_parameter", d.arc_end_parameter)], + vec![("first_extension_point", "second_extension_point")], + ), + D::LargeRadial(d) => ( + vec![("definition_point", &d.definition_point), ("chord_point", &d.chord_point), ("override_center", &d.override_center), ("jog_point", &d.jog_point)], + vec![("jog_angle", d.jog_angle)], + vec![("definition_point", "chord_point")], + ), + }; + for (name, point) in &points { + finite_vector(&format!("Dimension.{name}"), point)?; + } + for (name, value) in &scalars { + if !value.is_finite() { + return Err(format!("Dimension.{name} must be finite")); + } + } + let find = |name: &str| points.iter().find(|(n, _)| *n == name).map(|(_, p)| **p); + for (a, b) in distinct { + if find(a) == find(b) { + return Err(format!("Dimension.{a} and {b} must not coincide")); + } + } + if !new.measurement().is_finite() { + return Err("Dimension geometry does not define a finite measurement".into()); + } + Ok(()) +} + +/// 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, +) -> Result<(), String> { + use crate::host::acadrust::entities::LeaderContentType; + if old == Some(new) { + return Ok(()); + } + for (name, value, allow_zero) in [ + ("dogleg_length", new.dogleg_length, true), + ("arrowhead_size", new.arrowhead_size, true), + ("text_height", new.text_height, false), + ("scale_factor", new.scale_factor, false), + ] { + if !value.is_finite() || value < 0.0 || (!allow_zero && value == 0.0) { + return Err(format!( + "MultiLeader.{name} must be finite and {}", + if allow_zero { "non-negative" } else { "greater than zero" } + )); + } + } + if !new.block_rotation.is_finite() { + return Err("MultiLeader.block_rotation must be finite".into()); + } + finite_vector("MultiLeader.block_scale", &new.block_scale)?; + let context = &new.context; + if !context.scale_factor.is_finite() || context.scale_factor <= 0.0 { + return Err("MultiLeader.context.scale_factor must be finite and greater than zero".into()); + } + for (name, value) in [ + ("text_height", context.text_height), + ("text_width", context.text_width), + ("text_rotation", context.text_rotation), + ("landing_gap", context.landing_gap), + ("arrowhead_size", context.arrowhead_size), + ] { + if !value.is_finite() { + return Err(format!("MultiLeader.context.{name} must be finite")); + } + } + for (name, point) in [ + ("content_base_point", &context.content_base_point), + ("text_location", &context.text_location), + ("block_content_location", &context.block_content_location), + ("base_point", &context.base_point), + ] { + finite_vector(&format!("MultiLeader.context.{name}"), point)?; + } + if context.transform_matrix.iter().any(|value| !value.is_finite()) { + return Err("MultiLeader.context.transform_matrix must be finite".into()); + } + for (ri, root) in context.leader_roots.iter().enumerate() { + finite_vector(&format!("MultiLeader.context.leader_roots[{ri}].connection_point"), &root.connection_point)?; + finite_vector(&format!("MultiLeader.context.leader_roots[{ri}].direction"), &root.direction)?; + if !root.landing_distance.is_finite() { + return Err(format!("MultiLeader.context.leader_roots[{ri}].landing_distance must be finite")); + } + for (li, line) in root.lines.iter().enumerate() { + if line.points.is_empty() { + return Err(format!("MultiLeader.context.leader_roots[{ri}].lines[{li}] has no points")); + } + for (pi, point) in line.points.iter().enumerate() { + finite_vector(&format!("MultiLeader.context.leader_roots[{ri}].lines[{li}].points[{pi}]"), point)?; + } + } + } + match new.content_type { + LeaderContentType::MText if !context.has_text_contents => { + return Err("MText MultiLeader requires context.has_text_contents".into()); + } + LeaderContentType::Block if !context.has_block_contents => { + return Err("Block MultiLeader requires context.has_block_contents".into()); + } + LeaderContentType::None if context.has_text_contents || context.has_block_contents => { + return Err("MultiLeader without content cannot carry text or block contents".into()); + } + _ => {} + } + Ok(()) +} + +/// 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, +) -> Result<(), String> { + if old == Some(new) { + return Ok(()); + } + finite_vector("Table.insertion_point", &new.insertion_point)?; + solid_normal("Table.normal", &new.normal)?; + solid_normal("Table.horizontal_direction", &new.horizontal_direction)?; + if !new.break_spacing.is_finite() || new.break_spacing < 0.0 { + return Err("Table.break_spacing must be finite and non-negative".into()); + } + if new.rows.is_empty() || new.columns.is_empty() { + return Err("Table requires at least one row and one column".into()); + } + for (ci, column) in new.columns.iter().enumerate() { + if !column.width.is_finite() || column.width <= 0.0 { + return Err(format!("Table.columns[{ci}].width must be finite and greater than zero")); + } + } + for (ri, row) in new.rows.iter().enumerate() { + if !row.height.is_finite() || row.height <= 0.0 { + return Err(format!("Table.rows[{ri}].height must be finite and greater than zero")); + } + if row.cells.len() != new.columns.len() { + return Err(format!( + "Table.rows[{ri}] has {} cells but the table has {} columns", + row.cells.len(), + new.columns.len() + )); + } + for (ci, cell) in row.cells.iter().enumerate() { + if !cell.rotation.is_finite() || !cell.geometry_scale.is_finite() || !cell.block_scale.is_finite() { + return Err(format!("Table.rows[{ri}].cells[{ci}] has non-finite rotation or scale")); + } + for (ki, content) in cell.contents.iter().enumerate() { + if !content.rotation.is_finite() || !content.scale.is_finite() || !content.text_height.is_finite() || content.text_height < 0.0 { + return Err(format!( + "Table.rows[{ri}].cells[{ci}].contents[{ki}] has invalid rotation, scale or text height" + )); + } + if !content.value.numeric_value.is_finite() { + return Err(format!( + "Table.rows[{ri}].cells[{ci}].contents[{ki}].value.numeric_value must be finite" + )); + } + } + } + } + let (rows, columns) = (new.rows.len(), new.columns.len()); + for (mi, range) in new.merged_ranges.iter().enumerate() { + if range.top_row > range.bottom_row + || range.left_col > range.right_col + || range.bottom_row >= rows + || range.right_col >= columns + { + return Err(format!("Table.merged_ranges[{mi}] lies outside the {rows}x{columns} grid")); + } + for (mj, other) in new.merged_ranges.iter().enumerate().skip(mi + 1) { + let separate = range.bottom_row < other.top_row + || other.bottom_row < range.top_row + || range.right_col < other.left_col + || other.right_col < range.left_col; + if !separate { + return Err(format!("Table.merged_ranges[{mi}] overlaps merged_ranges[{mj}]")); + } + } + } + Ok(()) +} + +/// 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, +) -> Result<(), String> { + if old == Some(new) { + return Ok(()); + } + if new.m_vertex_count < 2 || new.n_vertex_count < 2 { + return Err("PolygonMesh.m_vertex_count and n_vertex_count must be at least 2".into()); + } + let expected = new.m_vertex_count as usize * new.n_vertex_count as usize; + if new.vertices.len() != expected { + return Err(format!( + "PolygonMesh has {} vertices but {}x{} requires {expected}", + new.vertices.len(), + new.m_vertex_count, + new.n_vertex_count + )); + } + if new.m_smooth_density < 0 || new.n_smooth_density < 0 { + return Err("PolygonMesh smooth densities must be non-negative".into()); + } + if !new.elevation.is_finite() { + return Err("PolygonMesh.elevation must be finite".into()); + } + solid_normal("PolygonMesh.normal", &new.normal)?; + for (index, vertex) in new.vertices.iter().enumerate() { + finite_vector(&format!("PolygonMesh.vertices[{index}].location"), &vertex.location)?; + } + Ok(()) +} + +/// PolyfaceMesh: faces index the vertex list (1-based; a negative index hides +/// 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, +) -> Result<(), String> { + if old == Some(new) { + return Ok(()); + } + if !new.elevation.is_finite() || !new.thickness.is_finite() || !new.start_width.is_finite() || !new.end_width.is_finite() { + return Err("PolyfaceMesh elevation, thickness and widths must be finite".into()); + } + solid_normal("PolyfaceMesh.normal", &new.normal)?; + if new.vertices.len() < 3 { + return Err("PolyfaceMesh.vertices requires at least 3 vertices".into()); + } + for (index, vertex) in new.vertices.iter().enumerate() { + finite_vector(&format!("PolyfaceMesh.vertices[{index}].location"), &vertex.location)?; + if !vertex.bulge.is_finite() || !vertex.start_width.is_finite() || !vertex.end_width.is_finite() || !vertex.curve_tangent.is_finite() { + return Err(format!("PolyfaceMesh.vertices[{index}] has non-finite bulge, width or tangent")); + } + } + if new.faces.is_empty() { + return Err("PolyfaceMesh.faces requires at least 1 face".into()); + } + let count = new.vertices.len() as i64; + for (index, face) in new.faces.iter().enumerate() { + let indices = [face.index1, face.index2, face.index3, face.index4]; + for value in indices { + if i64::from(value).abs() > count { + return Err(format!( + "PolyfaceMesh.faces[{index}] references vertex {} but only {count} exist", + value.abs() + )); + } + } + if indices.iter().filter(|value| **value != 0).count() < 3 { + return Err(format!("PolyfaceMesh.faces[{index}] needs at least 3 vertex indices")); + } + } + Ok(()) +} + +/// 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, +) -> Result<(), String> { + if old == Some(new) { + return Ok(()); + } + if new.subdivision_level < 0 { + return Err("Mesh.subdivision_level must be non-negative".into()); + } + if new.vertices.len() < 3 { + return Err("Mesh.vertices requires at least 3 vertices".into()); + } + for (index, vertex) in new.vertices.iter().enumerate() { + finite_vector(&format!("Mesh.vertices[{index}]"), vertex)?; + } + for (index, face) in new.faces.iter().enumerate() { + if face.vertices.len() < 3 { + return Err(format!("Mesh.faces[{index}] needs at least 3 vertices")); + } + if let Some(bad) = face.vertices.iter().find(|vertex| **vertex >= new.vertices.len()) { + return Err(format!( + "Mesh.faces[{index}] references vertex {bad} but only {} exist", + new.vertices.len() + )); + } + } + for (index, edge) in new.edges.iter().enumerate() { + if edge.start >= new.vertices.len() || edge.end >= new.vertices.len() { + return Err(format!("Mesh.edges[{index}] references a vertex out of range")); + } + if edge.start == edge.end { + return Err(format!("Mesh.edges[{index}] has identical endpoints")); + } + if edge.crease.is_some_and(|crease| !crease.is_finite() || crease < 0.0) { + return Err(format!("Mesh.edges[{index}].crease must be finite and non-negative")); + } + } + Ok(()) +} + +/// Helix generating parameters. The spline is derived by the host, so only +/// the parameters are validated. +#[cfg(feature = "host")] +fn validate_helix( + old: Option<&crate::host::acadrust::entities::Helix>, + new: &crate::host::acadrust::entities::Helix, +) -> Result<(), String> { + if old.is_some_and(|old| { + old.axis_base_point == new.axis_base_point + && old.start_point == new.start_point + && old.axis_vector == new.axis_vector + && old.radius.to_bits() == new.radius.to_bits() + && old.turns.to_bits() == new.turns.to_bits() + && old.turn_height.to_bits() == new.turn_height.to_bits() + && old.handedness == new.handedness + && old.constraint == new.constraint + }) { + return Ok(()); + } + finite_vector("Helix.axis_base_point", &new.axis_base_point)?; + finite_vector("Helix.start_point", &new.start_point)?; + solid_normal("Helix.axis_vector", &new.axis_vector)?; + for (name, value) in [("radius", new.radius), ("turns", new.turns), ("turn_height", new.turn_height)] { + if !value.is_finite() || value <= 0.0 { + return Err(format!("Helix.{name} must be finite and greater than zero")); + } + } + let axis = new.axis_vector; + let length = axis.x.hypot(axis.y).hypot(axis.z); + let delta = new.start_point - new.axis_base_point; + let along = (delta.x * axis.x + delta.y * axis.y + delta.z * axis.z) / length; + let radial = ((delta.x * delta.x + delta.y * delta.y + delta.z * delta.z) - along * along).max(0.0).sqrt(); + if radial <= 1.0e-9 { + return Err("Helix.start_point must not lie on the axis".into()); + } + Ok(()) +} + +/// RasterImage display parameters. The image definition is created and owned +/// 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, +) -> Result<(), String> { + if old == Some(new) { + return Ok(()); + } + if let Some(old) = old { + if old.file_path != new.file_path { + return Err("RasterImage.file_path is fixed at creation; the image definition owns it".into()); + } + } else if new.file_path.trim().is_empty() { + return Err("RasterImage.file_path is empty".into()); + } else if new.definition_handle.is_none_or(|handle| handle.is_null()) + && !std::path::Path::new(&new.file_path).is_file() + { + return Err(format!("RasterImage.file_path {:?} is not a readable file", new.file_path)); + } + finite_vector("RasterImage.insertion_point", &new.insertion_point)?; + finite_vector("RasterImage.u_vector", &new.u_vector)?; + finite_vector("RasterImage.v_vector", &new.v_vector)?; + let cross = new.u_vector.cross(&new.v_vector); + if cross.x.hypot(cross.y).hypot(cross.z) <= 1.0e-12 { + return Err("RasterImage.u_vector and v_vector must be nonzero and not parallel".into()); + } + if !new.size.x.is_finite() || !new.size.y.is_finite() || new.size.x < 0.0 || new.size.y < 0.0 { + return Err("RasterImage.size must be finite and non-negative".into()); + } + if new.brightness > 100 || new.contrast > 100 || new.fade > 100 { + return Err("RasterImage brightness, contrast and fade must be within 0..=100".into()); + } + if new.flags.bits() & !0x0f != 0 { + return Err("RasterImage.flags has unknown bits".into()); + } + for (index, vertex) in new.clip_boundary.vertices.iter().enumerate() { + if !vertex.x.is_finite() || !vertex.y.is_finite() { + return Err(format!("RasterImage.clip_boundary.vertices[{index}] must be finite")); + } + } + if new.clipping_enabled && new.clip_boundary.vertices.len() < 2 { + return Err("RasterImage clipping requires at least 2 clip boundary vertices".into()); + } + Ok(()) +} + +/// Wipeout: a masking region placed like an image; the clip boundary (in +/// 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, +) -> Result<(), String> { + use crate::host::acadrust::entities::WipeoutClipType; + if old == Some(new) { + return Ok(()); + } + finite_vector("Wipeout.insertion_point", &new.insertion_point)?; + finite_vector("Wipeout.u_vector", &new.u_vector)?; + finite_vector("Wipeout.v_vector", &new.v_vector)?; + let cross = new.u_vector.cross(&new.v_vector); + if cross.x.hypot(cross.y).hypot(cross.z) <= 1.0e-12 { + return Err("Wipeout.u_vector and v_vector must be nonzero and not parallel".into()); + } + if !new.size.x.is_finite() || !new.size.y.is_finite() || new.size.x <= 0.0 || new.size.y <= 0.0 { + return Err("Wipeout.size must be finite and greater than zero".into()); + } + if new.brightness > 100 || new.contrast > 100 || new.fade > 100 { + return Err("Wipeout brightness, contrast and fade must be within 0..=100".into()); + } + if new.flags.bits() & !0x0f != 0 { + return Err("Wipeout.flags has unknown bits".into()); + } + for (index, vertex) in new.clip_boundary_vertices.iter().enumerate() { + if !vertex.x.is_finite() || !vertex.y.is_finite() { + return Err(format!("Wipeout.clip_boundary_vertices[{index}] must be finite")); + } + } + let count = new.clip_boundary_vertices.len(); + match new.clip_type { + WipeoutClipType::Rectangular if count != 2 => { + return Err("a rectangular Wipeout needs exactly 2 clip boundary vertices".into()); + } + WipeoutClipType::Polygonal if count < 3 => { + return Err("a polygonal Wipeout needs at least 3 clip boundary vertices".into()); + } + _ => {} + } + Ok(()) +} + +/// Underlay placement and display. The file and page live on the referenced +/// `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, +) -> Result<(), String> { + if old == Some(new) { + return Ok(()); + } + if let Some(old) = old { + if old.definition_handle != new.definition_handle { + return Err("Underlay.definition_handle is fixed at creation".into()); + } + if old.underlay_type != new.underlay_type { + return Err("Underlay.underlay_type is fixed at creation".into()); + } + } + finite_vector("Underlay.insertion_point", &new.insertion_point)?; + solid_normal("Underlay.normal", &new.normal)?; + for (name, value) in [("x_scale", new.x_scale), ("y_scale", new.y_scale), ("z_scale", new.z_scale)] { + if !value.is_finite() || value == 0.0 { + return Err(format!("Underlay.{name} must be finite and nonzero")); + } + } + if !new.rotation.is_finite() { + return Err("Underlay.rotation must be finite".into()); + } + if new.contrast > 100 || new.fade > 100 { + return Err("Underlay contrast and fade must be within 0..=100".into()); + } + if new.flags.bits() & !0x0f != 0 { + return Err("Underlay.flags has unknown bits".into()); + } + for (index, vertex) in new.clip_boundary_vertices.iter().enumerate() { + if !vertex.x.is_finite() || !vertex.y.is_finite() { + return Err(format!("Underlay.clip_boundary_vertices[{index}] must be finite")); + } + } + if !new.clip_boundary_vertices.is_empty() && new.clip_boundary_vertices.len() < 2 { + return Err("Underlay clip boundary needs at least 2 vertices".into()); + } + Ok(()) +} + +/// Viewport view state. Owner and layer-handle references are checked with the +/// 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, +) -> Result<(), String> { + if old == Some(new) { + return Ok(()); + } + finite_vector("Viewport.center", &new.center)?; + finite_vector("Viewport.view_center", &new.view_center)?; + finite_vector("Viewport.view_target", &new.view_target)?; + solid_normal("Viewport.view_direction", &new.view_direction)?; + for (name, value) in [("width", new.width), ("height", new.height), ("view_height", new.view_height), ("lens_length", new.lens_length), ("custom_scale", new.custom_scale)] { + if !value.is_finite() || value <= 0.0 { + return Err(format!("Viewport.{name} must be finite and greater than zero")); + } + } + for (name, value) in [("front_clip_z", new.front_clip_z), ("back_clip_z", new.back_clip_z), ("twist_angle", new.twist_angle)] { + if !value.is_finite() { + return Err(format!("Viewport.{name} must be finite")); + } + } + if new.circle_sides < 3 { + return Err("Viewport.circle_sides must be at least 3".into()); + } + Ok(()) +} + +/// 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, +) -> Result<(), String> { + if old == Some(new) { + return Ok(()); + } + for (name, pair) in [("min", new.min), ("max", new.max), ("center", new.center)] { + if !pair[0].is_finite() || !pair[1].is_finite() { + return Err(format!("ViewBorder.{name} must be finite")); + } + } + if new.min[0] >= new.max[0] || new.min[1] >= new.max[1] { + return Err("ViewBorder.min must be below max on both axes".into()); + } + if !new.scale.is_finite() || new.scale <= 0.0 { + return Err("ViewBorder.scale must be finite and greater than zero".into()); + } + if !new.rotation_angle.is_finite() { + return Err("ViewBorder.rotation_angle must be finite".into()); + } + Ok(()) +} + +/// 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, +) -> Result<(), String> { + if old == Some(new) { + return Ok(()); + } + if new.name.trim().is_empty() { + return Err("Light.name is empty".into()); + } + if !(1..=3).contains(&new.light_type) { + return Err("Light.light_type must be 1 (distant), 2 (point) or 3 (spot)".into()); + } + finite_vector("Light.position", &new.position)?; + finite_vector("Light.target", &new.target)?; + if new.light_type != 2 && new.position == new.target { + return Err("a distant or spot Light needs a target different from its position".into()); + } + if !new.intensity.is_finite() || new.intensity < 0.0 { + return Err("Light.intensity must be finite and non-negative".into()); + } + for (name, value) in [ + ("attenuation_start_limit", new.attenuation_start_limit), + ("attenuation_end_limit", new.attenuation_end_limit), + ("hotspot_angle", new.hotspot_angle), + ("falloff_angle", new.falloff_angle), + ] { + if !value.is_finite() || value < 0.0 { + return Err(format!("Light.{name} must be finite and non-negative")); + } + } + if new.use_attenuation_limits && new.attenuation_start_limit > new.attenuation_end_limit { + return Err("Light attenuation start limit must not exceed the end limit".into()); + } + if new.light_type == 3 && new.hotspot_angle > new.falloff_angle { + return Err("Light.hotspot_angle must not exceed falloff_angle".into()); + } + if new.shadow_map_size < 0 { + return Err("Light.shadow_map_size must be non-negative".into()); + } + Ok(()) +} + +/// Every geometry problem of a first-generation mapped kind. Messages name +/// the field, so an edit can be compared against the entity it replaces. +#[cfg(feature = "host")] +fn basic_entity_errors(new: &crate::host::EntityType) -> Vec { + use crate::host::EntityType; + let mut errs: Vec = Vec::new(); + macro_rules! chk { + ($e:expr) => { + if let Err(error) = $e { + errs.push(error); + } + }; + } + let finite = |name: &str, value: f64| { + if value.is_finite() { + Ok(()) + } else { + Err(format!("{name} must be finite")) + } + }; + let positive = |name: &str, value: f64| { + if value.is_finite() && value > 0.0 { + Ok(()) + } else { + Err(format!("{name} must be finite and greater than zero")) + } + }; + match new { + EntityType::Point(v) => { + chk!(finite_vector("Point.location", &v.location)); + chk!(finite("Point.thickness", v.thickness)); + chk!(finite("Point.x_axis_angle", v.x_axis_angle)); + chk!(solid_normal("Point.normal", &v.normal)); + } + EntityType::Line(v) => { + chk!(finite_vector("Line.start", &v.start)); + chk!(finite_vector("Line.end", &v.end)); + chk!(finite("Line.thickness", v.thickness)); + chk!(solid_normal("Line.normal", &v.normal)); + } + EntityType::Circle(v) => { + chk!(finite_vector("Circle.center", &v.center)); + chk!(positive("Circle.radius", v.radius)); + chk!(finite("Circle.thickness", v.thickness)); + chk!(solid_normal("Circle.normal", &v.normal)); + } + EntityType::Arc(v) => { + chk!(finite_vector("Arc.center", &v.center)); + chk!(positive("Arc.radius", v.radius)); + chk!(finite("Arc.start_angle", v.start_angle)); + chk!(finite("Arc.end_angle", v.end_angle)); + chk!(finite("Arc.thickness", v.thickness)); + chk!(solid_normal("Arc.normal", &v.normal)); + } + EntityType::Ellipse(v) => { + chk!(finite_vector("Ellipse.center", &v.center)); + chk!(solid_normal("Ellipse.major_axis", &v.major_axis)); + if !v.minor_axis_ratio.is_finite() || v.minor_axis_ratio <= 0.0 || v.minor_axis_ratio > 1.0 { + errs.push("Ellipse.minor_axis_ratio must be within (0, 1]".into()); + } + chk!(finite("Ellipse.start_parameter", v.start_parameter)); + chk!(finite("Ellipse.end_parameter", v.end_parameter)); + chk!(solid_normal("Ellipse.normal", &v.normal)); + } + EntityType::Text(v) => { + chk!(finite_vector("Text.insertion_point", &v.insertion_point)); + if let Some(alignment) = &v.alignment_point { + chk!(finite_vector("Text.alignment_point", alignment)); + } + chk!(positive("Text.height", v.height)); + chk!(finite("Text.rotation", v.rotation)); + chk!(finite("Text.oblique_angle", v.oblique_angle)); + if !v.width_factor.is_finite() || v.width_factor <= 0.0 { + errs.push("Text.width_factor must be finite and greater than zero".into()); + } + chk!(finite("Text.thickness", v.thickness)); + chk!(solid_normal("Text.normal", &v.normal)); + } + EntityType::MText(v) => { + chk!(finite_vector("MText.insertion_point", &v.insertion_point)); + chk!(positive("MText.height", v.height)); + chk!(finite("MText.rotation", v.rotation)); + if !v.rectangle_width.is_finite() || v.rectangle_width < 0.0 { + errs.push("MText.rectangle_width must be finite and non-negative".into()); + } + if let Some(height) = v.rectangle_height { + chk!(finite("MText.rectangle_height", height)); + } + chk!(positive("MText.line_spacing_factor", v.line_spacing_factor)); + chk!(solid_normal("MText.normal", &v.normal)); + } + EntityType::LwPolyline(v) => { + if v.vertices.len() < 2 { + errs.push("LwPolyline.vertices requires at least 2 vertices".into()); + } + for (i, vertex) in v.vertices.iter().enumerate() { + if !vertex.location.x.is_finite() || !vertex.location.y.is_finite() { + errs.push(format!("LwPolyline.vertices[{i}].location must be finite")); + } + if !vertex.bulge.is_finite() || !vertex.start_width.is_finite() || !vertex.end_width.is_finite() { + errs.push(format!("LwPolyline.vertices[{i}] has a non-finite bulge or width")); + } + } + if !v.constant_width.is_finite() || v.constant_width < 0.0 { + errs.push("LwPolyline.constant_width must be finite and non-negative".into()); + } + chk!(finite("LwPolyline.elevation", v.elevation)); + chk!(finite("LwPolyline.thickness", v.thickness)); + chk!(solid_normal("LwPolyline.normal", &v.normal)); + } + EntityType::Polyline2D(v) => { + if v.vertices.len() < 2 { + errs.push("Polyline2D.vertices requires at least 2 vertices".into()); + } + for (i, vertex) in v.vertices.iter().enumerate() { + chk!(finite_vector(&format!("Polyline2D.vertices[{i}].location"), &vertex.location)); + if !vertex.bulge.is_finite() || !vertex.start_width.is_finite() || !vertex.end_width.is_finite() { + errs.push(format!("Polyline2D.vertices[{i}] has a non-finite bulge or width")); + } + } + for (name, value) in [("start_width", v.start_width), ("end_width", v.end_width), ("thickness", v.thickness), ("elevation", v.elevation)] { + chk!(finite(&format!("Polyline2D.{name}"), value)); + } + chk!(solid_normal("Polyline2D.normal", &v.normal)); + } + EntityType::Polyline(v) => { + if v.vertices.len() < 2 { + errs.push("Polyline.vertices requires at least 2 vertices".into()); + } + for (i, vertex) in v.vertices.iter().enumerate() { + chk!(finite_vector(&format!("Polyline.vertices[{i}].location"), &vertex.location)); + } + } + EntityType::Polyline3D(v) => { + if v.vertices.len() < 2 { + errs.push("Polyline3D.vertices requires at least 2 vertices".into()); + } + for (i, vertex) in v.vertices.iter().enumerate() { + chk!(finite_vector(&format!("Polyline3D.vertices[{i}].position"), &vertex.position)); + } + for (name, value) in [("default_start_width", v.default_start_width), ("default_end_width", v.default_end_width), ("elevation", v.elevation)] { + chk!(finite(&format!("Polyline3D.{name}"), value)); + } + chk!(solid_normal("Polyline3D.normal", &v.normal)); + } + EntityType::Spline(v) => { + if v.degree < 1 { + errs.push("Spline.degree must be at least 1".into()); + } + let degree = v.degree as usize; + if v.control_points.len() <= degree { + errs.push("Spline needs more control points than its degree".into()); + } + for (i, point) in v.control_points.iter().enumerate() { + chk!(finite_vector(&format!("Spline.control_points[{i}]"), point)); + } + for (i, point) in v.fit_points.iter().enumerate() { + chk!(finite_vector(&format!("Spline.fit_points[{i}]"), point)); + } + if v.knots.len() != v.control_points.len() + degree + 1 { + errs.push(format!( + "Spline.knots must hold {} values for {} control points of degree {degree}", + v.control_points.len() + degree + 1, + v.control_points.len() + )); + } + if v.knots.iter().any(|k| !k.is_finite()) || v.knots.windows(2).any(|w| w[1] < w[0]) { + errs.push("Spline.knots must be finite and nondecreasing".into()); + } + if !v.weights.is_empty() { + if v.weights.len() != v.control_points.len() { + errs.push("Spline.weights must match the control points".into()); + } + if v.weights.iter().any(|w| !w.is_finite() || *w <= 0.0) { + errs.push("Spline.weights must be finite and greater than zero".into()); + } + } + chk!(solid_normal("Spline.normal", &v.normal)); + } + _ => {} + } + errs +} + +/// Creation must be fully valid. An edit is refused only for problems it +/// introduces: a legacy value that was already invalid and is untouched +/// keeps the entity editable. +#[cfg(feature = "host")] +fn validate_basic_entity( + old: Option<&crate::host::EntityType>, + new: &crate::host::EntityType, +) -> Result<(), String> { + if old == Some(new) { + return Ok(()); + } + let errors = basic_entity_errors(new); + let existing = old.map(basic_entity_errors).unwrap_or_default(); + match errors.into_iter().find(|error| !existing.contains(error)) { + Some(error) => Err(error), + None => Ok(()), + } +} + +/// Ole2Frame: only the frame placement is scriptable; the embedded object +/// storage is never rewritten. +#[cfg(feature = "host")] +fn validate_ole_frame( + old: Option<&crate::host::acadrust::entities::Ole2Frame>, + new: &crate::host::acadrust::entities::Ole2Frame, +) -> Result<(), String> { + if old == Some(new) { + return Ok(()); + } + finite_vector("Ole2Frame.upper_left_corner", &new.upper_left_corner)?; + finite_vector("Ole2Frame.lower_right_corner", &new.lower_right_corner)?; + let (a, b) = (new.upper_left_corner, new.lower_right_corner); + if (a.x - b.x).abs() <= 1.0e-12 || (a.y - b.y).abs() <= 1.0e-12 { + return Err("Ole2Frame corners must span a nonzero width and height".into()); + } + if new.lock_aspect > 1 { + return Err("Ole2Frame.lock_aspect must be 0 or 1".into()); + } + Ok(()) +} + +/// Surface: only the display isoline density is scriptable; the geometry lives +/// 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, +) -> Result<(), String> { + if old.is_some_and(|old| old.u_isolines == new.u_isolines && old.v_isolines == new.v_isolines) { + return Ok(()); + } + for (name, value) in [("u_isolines", new.u_isolines), ("v_isolines", new.v_isolines)] { + if !(0..=200).contains(&value) { + return Err(format!("Surface.{name} must be between 0 and 200")); + } + } + Ok(()) +} + +/// SectionSymbol: the cut mark drawn on a Model-Documentation view. `points` +/// is the canonical geometry; the counts and the end/tick/label projections are +/// 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, +) -> Result<(), String> { + if old.is_some_and(|old| old.points == new.points && old.symbol_scale.to_bits() == new.symbol_scale.to_bits()) { + return Ok(()); + } + if !new.symbol_scale.is_finite() || new.symbol_scale <= 0.0 { + return Err("SectionSymbol.symbol_scale must be finite and greater than zero".into()); + } + if new.points.len() < 2 { + return Err("SectionSymbol.points requires at least 2 points".into()); + } + for (index, point) in new.points.iter().enumerate() { + finite_vector(&format!("SectionSymbol.points[{index}].point"), &point.point)?; + finite_vector(&format!("SectionSymbol.points[{index}].label_offset"), &point.label_offset)?; + if !point.bulge.is_finite() { + return Err(format!("SectionSymbol.points[{index}].bulge must be finite")); + } + } + Ok(()) +} + +/// Validate newly created geometry for the kinds whose mapped fields have +/// host checks. Called by the Python add path before an entity enters the document. +#[cfg(feature = "host")] +pub fn validate_new_canvas_entity(entity: &crate::host::EntityType) -> Result<(), String> { + use crate::host::EntityType; + if entity.common().layer.trim().is_empty() { + return Err("layer name is empty".into()); + } + validate_basic_entity(None, entity)?; + match entity { + EntityType::Ray(value) => { + finite_vector("Ray.base_point", &value.base_point)?; + unit_direction("Ray.direction", &value.direction)?; + } + EntityType::XLine(value) => { + finite_vector("XLine.base_point", &value.base_point)?; + unit_direction("XLine.direction", &value.direction)?; + } + EntityType::Solid(value) => { + for (name, corner) in [ + ("first_corner", &value.first_corner), + ("second_corner", &value.second_corner), + ("third_corner", &value.third_corner), + ("fourth_corner", &value.fourth_corner), + ] { + finite_vector(&format!("Solid.{name}"), corner)?; + } + solid_normal("Solid.normal", &value.normal)?; + if !value.thickness.is_finite() { + return Err("Solid.thickness must be finite".into()); + } + } + EntityType::Face3D(value) => { + for (name, corner) in [ + ("first_corner", &value.first_corner), + ("second_corner", &value.second_corner), + ("third_corner", &value.third_corner), + ("fourth_corner", &value.fourth_corner), + ] { + finite_vector(&format!("Face3D.{name}"), corner)?; + } + if value.invisible_edges.bits() & !0x0f != 0 { + return Err("Face3D.invisible_edges has unknown bits".into()); + } + } + EntityType::Tolerance(value) => { + finite_vector("Tolerance.insertion_point", &value.insertion_point)?; + unit_direction("Tolerance.direction", &value.direction)?; + solid_normal("Tolerance.normal", &value.normal)?; + if value.text.trim().is_empty() { + return Err("Tolerance.text is empty".into()); + } + if value.dimension_style_name.trim().is_empty() { + return Err("Tolerance.dimension_style_name is empty".into()); + } + if !value.text_height.is_finite() || value.text_height <= 0.0 { + return Err("Tolerance.text_height must be finite and greater than zero".into()); + } + if !value.dimension_gap.is_finite() { + return Err("Tolerance.dimension_gap must be finite".into()); + } + } + EntityType::Shape(value) => { + finite_vector("Shape.insertion_point", &value.insertion_point)?; + solid_normal("Shape.normal", &value.normal)?; + if !value.size.is_finite() || value.size <= 0.0 { + return Err("Shape.size must be finite and greater than zero".into()); + } + if value.shape_name.trim().is_empty() { + return Err("Shape.shape_name is empty".into()); + } + if !(1..=i16::MAX as i32).contains(&value.shape_number) { + return Err("Shape.shape_number must be between 1 and 32767".into()); + } + if !value.rotation.is_finite() { + return Err("Shape.rotation must be finite".into()); + } + if !value.relative_x_scale.is_finite() || value.relative_x_scale.abs() < 1e-12 { + return Err("Shape.relative_x_scale must be finite and nonzero".into()); + } + if !value.oblique_angle.is_finite() + || value.oblique_angle.abs() >= std::f64::consts::FRAC_PI_2 + { + return Err("Shape.oblique_angle must be finite and between -PI/2 and PI/2".into()); + } + if !value.thickness.is_finite() { + return Err("Shape.thickness must be finite".into()); + } + if value.style_name.trim().is_empty() { + return Err("Shape.style_name is empty".into()); + } + } + EntityType::Leader(value) => validate_leader(None, value)?, + EntityType::MLine(value) => validate_mline(None, value)?, + EntityType::Dimension(value) => validate_dimension(None, value)?, + EntityType::MultiLeader(value) => validate_multileader(None, value)?, + EntityType::Table(value) => validate_table(None, value)?, + EntityType::PolygonMesh(value) => validate_polygon_mesh(None, value)?, + EntityType::PolyfaceMesh(value) => validate_polyface_mesh(None, value)?, + EntityType::Mesh(value) => validate_mesh(None, value)?, + EntityType::Helix(value) => validate_helix(None, value)?, + EntityType::RasterImage(value) => validate_raster_image(None, value)?, + EntityType::Wipeout(value) => validate_wipeout(None, value)?, + EntityType::Underlay(value) => validate_underlay(None, value)?, + EntityType::Viewport(value) => validate_viewport(None, value)?, + EntityType::ViewBorder(value) => validate_view_border(None, value)?, + EntityType::Light(value) => validate_light(None, value)?, + EntityType::Insert(value) => { + if value.block_name.trim().is_empty() { + return Err("Insert.block_name is empty".into()); + } + finite_vector("Insert.insert_point", &value.insert_point)?; + solid_normal("Insert.normal", &value.normal)?; + for (name, scale) in [("x_scale", value.x_scale()), ("y_scale", value.y_scale()), ("z_scale", value.z_scale())] { + insert_scale(&format!("Insert.{name}"), scale)?; + } + for (name, number) in [("rotation", value.rotation), ("column_spacing", value.column_spacing), ("row_spacing", value.row_spacing)] { + if !number.is_finite() { + return Err(format!("Insert.{name} must be finite")); + } + } + if value.column_count == 0 || value.row_count == 0 { + return Err("Insert array counts must be greater than zero".into()); + } + } + EntityType::Ole2Frame(value) => validate_ole_frame(None, value)?, + EntityType::Surface(value) => validate_surface(None, value)?, + EntityType::SectionSymbol(value) => validate_section_symbol(None, value)?, + EntityType::AttributeDefinition(value) => { + finite_vector( + "AttributeDefinition.insertion_point", + &value.insertion_point, + )?; + finite_vector( + "AttributeDefinition.alignment_point", + &value.alignment_point, + )?; + solid_normal("AttributeDefinition.normal", &value.normal)?; + if value.tag.trim().is_empty() || value.tag.chars().any(char::is_whitespace) { + return Err( + "AttributeDefinition.tag must be nonempty and contain no whitespace".into(), + ); + } + if !value.height.is_finite() || value.height <= 0.0 { + return Err( + "AttributeDefinition.height must be finite and greater than zero".into(), + ); + } + if !value.rotation.is_finite() { + return Err("AttributeDefinition.rotation must be finite".into()); + } + if !value.width_factor.is_finite() || value.width_factor.abs() < 1e-12 { + return Err("AttributeDefinition.width_factor must be finite and nonzero".into()); + } + if !value.oblique_angle.is_finite() + || value.oblique_angle.abs() >= std::f64::consts::FRAC_PI_2 + { + return Err( + "AttributeDefinition.oblique_angle must be finite and between -PI/2 and PI/2" + .into(), + ); + } + if value.text_style.trim().is_empty() { + return Err("AttributeDefinition.text_style is empty".into()); + } + if value.field_length < 0 { + return Err("AttributeDefinition.field_length must be nonnegative".into()); + } + if value.line_count < 1 { + return Err("AttributeDefinition.line_count must be greater than zero".into()); + } + } + EntityType::AttributeEntity(value) => { + finite_vector("AttributeEntity.insertion_point", &value.insertion_point)?; + finite_vector("AttributeEntity.alignment_point", &value.alignment_point)?; + solid_normal("AttributeEntity.normal", &value.normal)?; + if value.tag.trim().is_empty() || value.tag.chars().any(char::is_whitespace) { + return Err( + "AttributeEntity.tag must be nonempty and contain no whitespace".into(), + ); + } + if !value.height.is_finite() || value.height <= 0.0 { + return Err("AttributeEntity.height must be finite and greater than zero".into()); + } + if !value.rotation.is_finite() { + return Err("AttributeEntity.rotation must be finite".into()); + } + if !value.width_factor.is_finite() || value.width_factor.abs() < 1e-12 { + return Err("AttributeEntity.width_factor must be finite and nonzero".into()); + } + if !value.oblique_angle.is_finite() + || value.oblique_angle.abs() >= std::f64::consts::FRAC_PI_2 + { + return Err( + "AttributeEntity.oblique_angle must be finite and between -PI/2 and PI/2" + .into(), + ); + } + if value.text_style.trim().is_empty() { + return Err("AttributeEntity.text_style is empty".into()); + } + if value.field_length < 0 { + return Err("AttributeEntity.field_length must be nonnegative".into()); + } + if value.line_count < 1 { + return Err("AttributeEntity.line_count must be greater than zero".into()); + } + } + EntityType::Hatch(value) => { + solid_normal("Hatch.normal", &value.normal)?; + if !value.elevation.is_finite() { + return Err("Hatch.elevation must be finite".into()); + } + validate_hatch_pattern(value)?; + if !value.pattern_angle.is_finite() { + return Err("Hatch.pattern_angle must be finite".into()); + } + if !value.pixel_size.is_finite() || value.pixel_size < 0.0 { + return Err("Hatch.pixel_size must be finite and non-negative".into()); + } + for (si, sp) in value.seed_points.iter().enumerate() { + if !sp.x.is_finite() || !sp.y.is_finite() { + return Err(format!("Hatch.seed_points[{si}] has non-finite coordinates")); + } + } + validate_hatch_paths(&value.paths)?; + } + _ => {} + } + Ok(()) +} + +/// Validate geometry changed by a typed replacement before an undoable +/// transaction commits. Legacy invalid values in untouched fields remain +/// untouched, so editing a layer does not unexpectedly reject an old drawing. +#[cfg(feature = "host")] +pub fn validate_entity_mutation( + before: &crate::host::EntityType, + after: &crate::host::EntityType, +) -> Result<(), String> { + use crate::host::EntityType; + if before.common().layer != after.common().layer { + if after.common().layer.trim().is_empty() { + return Err("layer name is empty".into()); + } + if matches!( + after, + EntityType::Block(_) + | EntityType::BlockEnd(_) + | EntityType::Seqend(_) + | EntityType::Extended(_) + | EntityType::Unknown(_) + ) { + return Err("entity kind does not support canvas layer edits".into()); + } + } + let finite3 = |name: &str, x: f64, y: f64, z: f64| { + if x.is_finite() && y.is_finite() && z.is_finite() { + Ok(()) + } else { + Err(format!("{name} must contain finite coordinates")) + } + }; + let same3 = |a: &crate::host::acadrust::types::Vector3, + b: &crate::host::acadrust::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() + }; + let radius = |name: &str, value: f64| { + if value.is_finite() && value > 0.0 { + Ok(()) + } else { + Err(format!("{name} must be finite and greater than zero")) + } + }; + let changed3 = |name: &str, + old: &crate::host::acadrust::types::Vector3, + new: &crate::host::acadrust::types::Vector3| { + if !same3(old, new) { + finite_vector(name, new) + } else { + Ok(()) + } + }; + validate_basic_entity(Some(before), after)?; + match (before, after) { + (EntityType::Point(old), EntityType::Point(new)) => { + if !same3(&old.location, &new.location) { + finite3( + "Point.location", + new.location.x, + new.location.y, + new.location.z, + )?; + } + } + (EntityType::Line(old), EntityType::Line(new)) => { + if !same3(&old.start, &new.start) { + finite3("Line.start", new.start.x, new.start.y, new.start.z)?; + } + if !same3(&old.end, &new.end) { + finite3("Line.end", new.end.x, new.end.y, new.end.z)?; + } + } + (EntityType::Circle(old), EntityType::Circle(new)) => { + if !same3(&old.center, &new.center) { + finite3("Circle.center", new.center.x, new.center.y, new.center.z)?; + } + if old.radius.to_bits() != new.radius.to_bits() { + radius("Circle.radius", new.radius)?; + } + } + (EntityType::Arc(old), EntityType::Arc(new)) => { + if !same3(&old.center, &new.center) { + finite3("Arc.center", new.center.x, new.center.y, new.center.z)?; + } + if old.radius.to_bits() != new.radius.to_bits() { + radius("Arc.radius", new.radius)?; + } + } + (EntityType::Ray(old), EntityType::Ray(new)) => { + changed3("Ray.base_point", &old.base_point, &new.base_point)?; + if !same3(&old.direction, &new.direction) { + unit_direction("Ray.direction", &new.direction)?; + } + } + (EntityType::XLine(old), EntityType::XLine(new)) => { + changed3("XLine.base_point", &old.base_point, &new.base_point)?; + if !same3(&old.direction, &new.direction) { + unit_direction("XLine.direction", &new.direction)?; + } + } + (EntityType::Solid(old), EntityType::Solid(new)) => { + for (name, old_corner, new_corner) in [ + ("first_corner", &old.first_corner, &new.first_corner), + ("second_corner", &old.second_corner, &new.second_corner), + ("third_corner", &old.third_corner, &new.third_corner), + ("fourth_corner", &old.fourth_corner, &new.fourth_corner), + ] { + changed3(&format!("Solid.{name}"), old_corner, new_corner)?; + } + if !same3(&old.normal, &new.normal) { + solid_normal("Solid.normal", &new.normal)?; + } + if old.thickness.to_bits() != new.thickness.to_bits() && !new.thickness.is_finite() { + return Err("Solid.thickness must be finite".into()); + } + } + (EntityType::Face3D(old), EntityType::Face3D(new)) => { + for (name, old_corner, new_corner) in [ + ("first_corner", &old.first_corner, &new.first_corner), + ("second_corner", &old.second_corner, &new.second_corner), + ("third_corner", &old.third_corner, &new.third_corner), + ("fourth_corner", &old.fourth_corner, &new.fourth_corner), + ] { + changed3(&format!("Face3D.{name}"), old_corner, new_corner)?; + } + if old.invisible_edges != new.invisible_edges && new.invisible_edges.bits() & !0x0f != 0 + { + return Err("Face3D.invisible_edges has unknown bits".into()); + } + } + (EntityType::Insert(old), EntityType::Insert(new)) => { + if old.block_name != new.block_name + || old.attributes != new.attributes + || old.view_rep_handle != new.view_rep_handle + || old.seqend_handle != new.seqend_handle + { + return Err("Insert block identity and attached records cannot change in a geometry transaction".into()); + } + changed3("Insert.insert_point", &old.insert_point, &new.insert_point)?; + if !same3(&old.normal, &new.normal) { + solid_normal("Insert.normal", &new.normal)?; + } + for (name, before, after) in [ + ("x_scale", old.x_scale(), new.x_scale()), + ("y_scale", old.y_scale(), new.y_scale()), + ("z_scale", old.z_scale(), new.z_scale()), + ] { + if before.to_bits() != after.to_bits() { + insert_scale(&format!("Insert.{name}"), after)?; + } + } + for (name, before, after) in [ + ("rotation", old.rotation, new.rotation), + ("column_spacing", old.column_spacing, new.column_spacing), + ("row_spacing", old.row_spacing, new.row_spacing), + ] { + if before.to_bits() != after.to_bits() && !after.is_finite() { + return Err(format!("Insert.{name} must be finite")); + } + } + if (old.column_count != new.column_count && new.column_count == 0) + || (old.row_count != new.row_count && new.row_count == 0) + { + return Err("Insert array counts must be greater than zero".into()); + } + } + (EntityType::Tolerance(old), EntityType::Tolerance(new)) => { + changed3( + "Tolerance.insertion_point", + &old.insertion_point, + &new.insertion_point, + )?; + if !same3(&old.direction, &new.direction) { + unit_direction("Tolerance.direction", &new.direction)?; + } + if !same3(&old.normal, &new.normal) { + solid_normal("Tolerance.normal", &new.normal)?; + } + if old.text != new.text && new.text.trim().is_empty() { + return Err("Tolerance.text is empty".into()); + } + if old.dimension_style_name != new.dimension_style_name + && new.dimension_style_name.trim().is_empty() + { + return Err("Tolerance.dimension_style_name is empty".into()); + } + if old.text_height.to_bits() != new.text_height.to_bits() + && (!new.text_height.is_finite() || new.text_height <= 0.0) + { + return Err("Tolerance.text_height must be finite and greater than zero".into()); + } + if old.dimension_gap.to_bits() != new.dimension_gap.to_bits() + && !new.dimension_gap.is_finite() + { + return Err("Tolerance.dimension_gap must be finite".into()); + } + if old.dimension_style_handle != new.dimension_style_handle + && old.dimension_style_name == new.dimension_style_name + { + return Err("Tolerance.dimension_style_handle is read-only".into()); + } + } + (EntityType::Shape(old), EntityType::Shape(new)) => { + changed3( + "Shape.insertion_point", + &old.insertion_point, + &new.insertion_point, + )?; + if !same3(&old.normal, &new.normal) { + solid_normal("Shape.normal", &new.normal)?; + } + if old.size.to_bits() != new.size.to_bits() + && (!new.size.is_finite() || new.size <= 0.0) + { + return Err("Shape.size must be finite and greater than zero".into()); + } + if old.shape_name != new.shape_name && new.shape_name.trim().is_empty() { + return Err("Shape.shape_name is empty".into()); + } + if old.shape_number != new.shape_number + && !(1..=i16::MAX as i32).contains(&new.shape_number) + { + return Err("Shape.shape_number must be between 1 and 32767".into()); + } + if old.rotation.to_bits() != new.rotation.to_bits() && !new.rotation.is_finite() { + return Err("Shape.rotation must be finite".into()); + } + if old.relative_x_scale.to_bits() != new.relative_x_scale.to_bits() + && (!new.relative_x_scale.is_finite() || new.relative_x_scale.abs() < 1e-12) + { + return Err("Shape.relative_x_scale must be finite and nonzero".into()); + } + if old.oblique_angle.to_bits() != new.oblique_angle.to_bits() + && (!new.oblique_angle.is_finite() + || new.oblique_angle.abs() >= std::f64::consts::FRAC_PI_2) + { + return Err("Shape.oblique_angle must be finite and between -PI/2 and PI/2".into()); + } + if old.thickness.to_bits() != new.thickness.to_bits() && !new.thickness.is_finite() { + return Err("Shape.thickness must be finite".into()); + } + if old.style_name != new.style_name && new.style_name.trim().is_empty() { + return Err("Shape.style_name is empty".into()); + } + if old.style_handle != new.style_handle + && !(old.style_name != new.style_name && new.style_handle.is_some()) + { + return Err("Shape.style_handle is read-only".into()); + } + } + (EntityType::Dimension(old), EntityType::Dimension(new)) => validate_dimension(Some(old), new)?, + (EntityType::MultiLeader(old), EntityType::MultiLeader(new)) => validate_multileader(Some(old), new)?, + (EntityType::Table(old), EntityType::Table(new)) => validate_table(Some(old), new)?, + (EntityType::PolygonMesh(old), EntityType::PolygonMesh(new)) => validate_polygon_mesh(Some(old), new)?, + (EntityType::PolyfaceMesh(old), EntityType::PolyfaceMesh(new)) => validate_polyface_mesh(Some(old), new)?, + (EntityType::Mesh(old), EntityType::Mesh(new)) => validate_mesh(Some(old), new)?, + (EntityType::Helix(old), EntityType::Helix(new)) => validate_helix(Some(old), new)?, + (EntityType::RasterImage(old), EntityType::RasterImage(new)) => validate_raster_image(Some(old), new)?, + (EntityType::Wipeout(old), EntityType::Wipeout(new)) => validate_wipeout(Some(old), new)?, + (EntityType::Underlay(old), EntityType::Underlay(new)) => validate_underlay(Some(old), new)?, + (EntityType::Viewport(old), EntityType::Viewport(new)) => validate_viewport(Some(old), new)?, + (EntityType::ViewBorder(old), EntityType::ViewBorder(new)) => validate_view_border(Some(old), new)?, + (EntityType::Light(old), EntityType::Light(new)) => validate_light(Some(old), new)?, + (EntityType::Ole2Frame(old), EntityType::Ole2Frame(new)) => { + validate_ole_frame(Some(old), new)?; + if old.storage != new.storage || old.envelope != new.envelope { + return Err("Ole2Frame embedded storage cannot change".into()); + } + } + (EntityType::Surface(old), EntityType::Surface(new)) => validate_surface(Some(old), new)?, + (EntityType::SectionSymbol(old), EntityType::SectionSymbol(new)) => validate_section_symbol(Some(old), new)?, + (EntityType::MLine(old), EntityType::MLine(new)) => validate_mline(Some(old), new)?, + (EntityType::Leader(old), EntityType::Leader(new)) => validate_leader(Some(old), new)?, + (EntityType::AttributeDefinition(old), EntityType::AttributeDefinition(new)) => { + changed3( + "AttributeDefinition.insertion_point", + &old.insertion_point, + &new.insertion_point, + )?; + changed3( + "AttributeDefinition.alignment_point", + &old.alignment_point, + &new.alignment_point, + )?; + if !same3(&old.normal, &new.normal) { + solid_normal("AttributeDefinition.normal", &new.normal)?; + } + if old.tag != new.tag + && (new.tag.trim().is_empty() || new.tag.chars().any(char::is_whitespace)) + { + return Err( + "AttributeDefinition.tag must be nonempty and contain no whitespace".into(), + ); + } + if old.height.to_bits() != new.height.to_bits() + && (!new.height.is_finite() || new.height <= 0.0) + { + return Err( + "AttributeDefinition.height must be finite and greater than zero".into(), + ); + } + for (name, before, after) in [ + ("rotation", old.rotation, new.rotation), + ("oblique_angle", old.oblique_angle, new.oblique_angle), + ] { + if before.to_bits() != after.to_bits() && !after.is_finite() { + return Err(format!("AttributeDefinition.{name} must be finite")); + } + } + if old.width_factor.to_bits() != new.width_factor.to_bits() + && (!new.width_factor.is_finite() || new.width_factor.abs() < 1e-12) + { + return Err("AttributeDefinition.width_factor must be finite and nonzero".into()); + } + if old.oblique_angle.to_bits() != new.oblique_angle.to_bits() + && new.oblique_angle.abs() >= std::f64::consts::FRAC_PI_2 + { + return Err( + "AttributeDefinition.oblique_angle must be between -PI/2 and PI/2".into(), + ); + } + if old.text_style != new.text_style && new.text_style.trim().is_empty() { + return Err("AttributeDefinition.text_style is empty".into()); + } + if old.field_length != new.field_length && new.field_length < 0 { + return Err("AttributeDefinition.field_length must be nonnegative".into()); + } + if old.line_count != new.line_count && new.line_count < 1 { + return Err("AttributeDefinition.line_count must be greater than zero".into()); + } + if old.embedded_mtext != new.embedded_mtext { + return Err("AttributeDefinition.embedded_mtext is unmapped and read-only".into()); + } + } + (EntityType::AttributeEntity(old), EntityType::AttributeEntity(new)) => { + changed3( + "AttributeEntity.insertion_point", + &old.insertion_point, + &new.insertion_point, + )?; + changed3( + "AttributeEntity.alignment_point", + &old.alignment_point, + &new.alignment_point, + )?; + if !same3(&old.normal, &new.normal) { + solid_normal("AttributeEntity.normal", &new.normal)?; + } + if old.tag != new.tag + && (new.tag.trim().is_empty() || new.tag.chars().any(char::is_whitespace)) + { + return Err( + "AttributeEntity.tag must be nonempty and contain no whitespace".into(), + ); + } + if old.height.to_bits() != new.height.to_bits() + && (!new.height.is_finite() || new.height <= 0.0) + { + return Err("AttributeEntity.height must be finite and greater than zero".into()); + } + for (name, before, after) in [ + ("rotation", old.rotation, new.rotation), + ("oblique_angle", old.oblique_angle, new.oblique_angle), + ] { + if before.to_bits() != after.to_bits() && !after.is_finite() { + return Err(format!("AttributeEntity.{name} must be finite")); + } + } + if old.width_factor.to_bits() != new.width_factor.to_bits() + && (!new.width_factor.is_finite() || new.width_factor.abs() < 1e-12) + { + return Err("AttributeEntity.width_factor must be finite and nonzero".into()); + } + if old.oblique_angle.to_bits() != new.oblique_angle.to_bits() + && new.oblique_angle.abs() >= std::f64::consts::FRAC_PI_2 + { + return Err("AttributeEntity.oblique_angle must be between -PI/2 and PI/2".into()); + } + if old.text_style != new.text_style && new.text_style.trim().is_empty() { + return Err("AttributeEntity.text_style is empty".into()); + } + if old.field_length != new.field_length && new.field_length < 0 { + return Err("AttributeEntity.field_length must be nonnegative".into()); + } + if old.line_count != new.line_count && new.line_count < 1 { + return Err("AttributeEntity.line_count must be greater than zero".into()); + } + if old.embedded_mtext != new.embedded_mtext { + return Err("AttributeEntity.embedded_mtext is unmapped and read-only".into()); + } + if old.attdef_handle != new.attdef_handle + && !(old.tag != new.tag && !new.attdef_handle.is_null()) + { + return Err("AttributeEntity.attdef_handle is read-only".into()); + } + } + (EntityType::Hatch(old), EntityType::Hatch(new)) => { + if !same3(&old.normal, &new.normal) { + solid_normal("Hatch.normal", &new.normal)?; + } + if old.elevation.to_bits() != new.elevation.to_bits() && !new.elevation.is_finite() { + return Err("Hatch.elevation must be finite".into()); + } + if old.pattern != new.pattern + || old.is_solid != new.is_solid + || old.pattern_scale.to_bits() != new.pattern_scale.to_bits() + { + validate_hatch_pattern(new)?; + } + if old.pattern_angle.to_bits() != new.pattern_angle.to_bits() + && !new.pattern_angle.is_finite() + { + return Err("Hatch.pattern_angle must be finite".into()); + } + if old.pixel_size.to_bits() != new.pixel_size.to_bits() + && (!new.pixel_size.is_finite() || new.pixel_size < 0.0) + { + return Err("Hatch.pixel_size must be finite and non-negative".into()); + } + if old.paths != new.paths { + validate_hatch_paths(&new.paths)?; + } + if old.seed_points != new.seed_points { + for (si, sp) in new.seed_points.iter().enumerate() { + if !sp.x.is_finite() || !sp.y.is_finite() { + return Err(format!("Hatch.seed_points[{si}] has non-finite coordinates")); + } + } + } + if old.gradient_color != new.gradient_color { + return Err("Hatch.gradient_color is unmapped and read-only".into()); + } + 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 + { + return Err("Hatch MPOLYGON fields are unmapped and read-only".into()); + } + } + _ => {} + } + Ok(()) +} + +/// Validate references which require the surrounding document. Keeping this +/// in the host API gives every scripting adapter the same reference rules. +#[cfg(feature = "host")] +pub fn validate_canvas_entity_references( + document: &crate::host::CadDocument, + entity: &crate::host::EntityType, +) -> Result<(), String> { + use crate::host::EntityType; + let owner = entity.common().owner_handle; + if !owner.is_null() { + let block_owner = document + .block_records + .iter() + .any(|record| record.handle == owner); + let insert_owner = matches!(entity, EntityType::AttributeEntity(_)) + && matches!(document.get_entity(owner), Some(EntityType::Insert(_))); + if !block_owner && !insert_owner { + return Err(format!("entity owner {owner:?} does not exist")); + } + } + if let EntityType::Hatch(value) = entity { + let handles = value.paths.iter().flat_map(|path| &path.boundary_handles); + let mut count = 0usize; + for handle in handles { + count += 1; + if handle.is_null() || document.get_entity(*handle).is_none() { + return Err(format!("Hatch boundary handle {handle:?} does not exist")); + } + } + if value.is_associative && count == 0 { + return Err("associative Hatch requires boundary handles".into()); + } + if !value.is_associative && count != 0 { + return Err("non-associative Hatch cannot carry boundary handles".into()); + } + } + if let EntityType::Tolerance(value) = entity { + let found = value + .dimension_style_handle + .filter(|handle| !handle.is_null()) + .map_or_else( + || { + document.dim_styles.iter().any(|style| { + style + .name + .eq_ignore_ascii_case(value.dimension_style_name.trim()) + }) + }, + |handle| { + document + .dim_styles + .iter() + .any(|style| style.handle == handle) + }, + ); + if !found { + return Err(format!( + "Tolerance dimension style {:?} does not exist", + value.dimension_style_name + )); + } + } + if let EntityType::Shape(value) = entity { + let style = value + .style_handle + .filter(|handle| !handle.is_null()) + .and_then(|handle| { + document + .text_styles + .iter() + .find(|style| style.handle == handle) + }) + .or_else(|| { + document + .text_styles + .iter() + .find(|style| style.name.eq_ignore_ascii_case(value.style_name.trim())) + }) + .ok_or_else(|| format!("Shape text style {:?} does not exist", value.style_name))?; + if !style.is_shape_file { + return Err(format!( + "Shape text style {:?} is not a shape-file style", + style.name + )); + } + if style.font_file.trim().is_empty() { + return Err(format!("Shape text style {:?} has no SHX file", style.name)); + } + } + // 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| { + matches!(document.get_entity(value.common.handle), + Some(EntityType::Insert(existing)) if existing.block_name == value.block_name) + }; + if let Some(value) = match entity { + EntityType::Insert(value) if !insert_is_unchanged_edit(value) => Some(value), + _ => None, + } { + let name = value.block_name.trim(); + let record = document.block_records.get(name).ok_or_else(|| { + format!("Insert block {:?} does not exist", value.block_name) + })?; + if record.is_model_space() || record.is_paper_space() { + return Err("Insert cannot reference a model-space or paper-space block".into()); + } + // A block cannot (transitively) contain an insert of itself. + let owner_name = document + .block_records + .iter() + .find(|candidate| candidate.handle == value.common.owner_handle) + .map(|candidate| candidate.name.clone()); + if let Some(owner_name) = owner_name { + let mut pending = vec![record.name.clone()]; + let mut seen = std::collections::HashSet::new(); + while let Some(current) = pending.pop() { + if current.eq_ignore_ascii_case(&owner_name) { + return Err(format!( + "inserting block {:?} into {owner_name:?} would make the block contain itself", + value.block_name + )); + } + if !seen.insert(current.to_ascii_lowercase()) { + continue; + } + if let Some(block) = document.block_records.get(¤t) { + for handle in &block.entity_handles { + if let Some(EntityType::Insert(nested)) = document.get_entity(*handle) { + pending.push(nested.block_name.clone()); + } + } + } + } + } + } + if let EntityType::SectionSymbol(value) = entity { + use crate::host::acadrust::objects::{ClassObjectData, ObjectType}; + for (name, handle, wanted) in [ + ("style", value.style_handle, "SectionViewStyle"), + ("view representation", value.view_rep_handle, "ViewRep"), + ] { + if handle.is_null() { + continue; + } + let matches = match document.objects.get(&handle) { + Some(ObjectType::ClassObject(object)) => match (&object.data, wanted) { + (ClassObjectData::SectionViewStyle(_), "SectionViewStyle") => true, + (ClassObjectData::ViewRep(_), "ViewRep") => true, + _ => false, + }, + _ => false, + }; + if !matches { + return Err(format!("SectionSymbol {name} {handle:?} is not an existing {wanted} object")); + } + } + } + if let EntityType::ViewBorder(value) = entity { + if !matches!(document.get_entity(value.active_viewport), Some(EntityType::Viewport(_))) { + return Err(format!("ViewBorder viewport {:?} does not exist", value.active_viewport)); + } + if !matches!( + document.objects.get(&value.scale_handle), + Some(crate::host::acadrust::objects::ObjectType::Scale(_)) + ) { + return Err(format!("ViewBorder scale {:?} does not exist", value.scale_handle)); + } + } + if let EntityType::Viewport(value) = entity { + let owner = value.common.owner_handle; + let in_paper_space = !owner.is_null() + && document + .block_records + .iter() + .any(|record| record.handle == owner && record.is_paper_space()); + if !in_paper_space { + return Err("Viewport must be owned by a paper-space layout block".into()); + } + for handle in &value.frozen_layers { + if !document.layers.iter().any(|layer| layer.handle == *handle) { + return Err(format!("Viewport frozen layer {handle:?} does not exist")); + } + } + } + if let EntityType::Underlay(value) = entity { + match document.objects.get(&value.definition_handle) { + Some(crate::host::acadrust::objects::ObjectType::UnderlayDefinition(definition)) => { + if definition.underlay_type != value.underlay_type { + return Err(format!( + "Underlay type {:?} does not match its definition ({:?})", + value.underlay_type, definition.underlay_type + )); + } + } + _ => { + return Err(format!( + "Underlay definition {:?} does not exist", + value.definition_handle + )); + } + } + } + if let EntityType::RasterImage(value) = entity { + 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(_)) + ) { + return Err(format!("RasterImage image definition {handle:?} does not exist")); + } + } + } + if let EntityType::Table(value) = entity { + 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(_)) + ) { + return Err(format!("Table style handle {handle:?} does not exist")); + } + } + for (ri, row) in value.rows.iter().enumerate() { + for (ci, cell) in row.cells.iter().enumerate() { + for content in &cell.contents { + if let Some(handle) = content.text_style_handle.filter(|handle| !handle.is_null()) { + if !document.text_styles.iter().any(|style| style.handle == handle) { + return Err(format!("Table.rows[{ri}].cells[{ci}] text style handle {handle:?} does not exist")); + } + } + if let Some(handle) = content.block_handle.filter(|handle| !handle.is_null()) { + if !document.block_records.iter().any(|record| record.handle == handle) { + return Err(format!("Table.rows[{ri}].cells[{ci}] block handle {handle:?} does not exist")); + } + } + } + } + } + } + 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| { + match handle.filter(|handle| !handle.is_null()) { + Some(handle) if !ok(handle) => Err(format!("MultiLeader {what} handle {handle:?} does not exist")), + _ => Ok(()), + } + }; + let is_block = |h| document.block_records.iter().any(|record| record.handle == h); + let is_text_style = |h| document.text_styles.iter().any(|style| style.handle == h); + let is_linetype = |h| document.line_types.iter().any(|ltype| ltype.handle == h); + let is_mleader_style = |h| matches!(document.objects.get(&h), Some(ObjectType::MultiLeaderStyle(_))); + handle_exists(value.style_handle, "style", &is_mleader_style)?; + handle_exists(value.text_style_handle, "text style", &is_text_style)?; + handle_exists(value.context.text_style_handle, "context text style", &is_text_style)?; + handle_exists(value.line_type_handle, "line type", &is_linetype)?; + handle_exists(value.arrowhead_handle, "arrowhead block", &is_block)?; + handle_exists(value.block_content_handle, "block content", &is_block)?; + handle_exists(value.context.block_content_handle, "context block content", &is_block)?; + } + if let EntityType::Dimension(value) = entity { + let name = value.base().style_name.trim(); + if !document.dim_styles.iter().any(|style| style.name.eq_ignore_ascii_case(name)) { + return Err(format!("Dimension style {:?} does not exist", value.base().style_name)); + } + } + if let EntityType::MLine(value) = entity { + let style = resolve_mline_style(document, value).ok_or_else(|| { + format!("MLine style {:?} does not exist", value.style_name) + })?; + if style.elements.is_empty() { + return Err(format!("MLine style {:?} has no elements", style.name)); + } + for (index, vertex) in value.vertices.iter().enumerate() { + if !vertex.segments.is_empty() && vertex.segments.len() != style.elements.len() { + return Err(format!( + "MLine.vertices[{index}] has {} segments but style {:?} has {} elements", + vertex.segments.len(), + style.name, + style.elements.len() + )); + } + } + } + if let EntityType::Leader(value) = entity { + use crate::host::acadrust::entities::LeaderCreationType; + if !document + .dim_styles + .iter() + .any(|style| style.name.eq_ignore_ascii_case(value.dimension_style.trim())) + { + return Err(format!( + "Leader dimension style {:?} does not exist", + value.dimension_style + )); + } + if !value.annotation_handle.is_null() { + if value.creation_type == LeaderCreationType::NoAnnotation { + return Err("Leader with no annotation cannot carry an annotation handle".into()); + } + let annotation = document + .get_entity(value.annotation_handle) + .filter(|_| value.annotation_handle != value.common.handle) + .ok_or_else(|| { + format!( + "Leader annotation handle {:?} does not exist", + value.annotation_handle + ) + })?; + let expected = match value.creation_type { + LeaderCreationType::WithText => matches!( + annotation, + EntityType::Text(_) | EntityType::MText(_) + ), + LeaderCreationType::WithTolerance => matches!(annotation, EntityType::Tolerance(_)), + LeaderCreationType::WithBlock => matches!(annotation, EntityType::Insert(_)), + LeaderCreationType::NoAnnotation => unreachable!(), + }; + if !expected { + return Err(format!( + "Leader annotation {:?} does not match creation_type {:?}", + value.annotation_handle, value.creation_type + )); + } + } + } + if let EntityType::AttributeDefinition(value) = entity { + let owner = entity.common().owner_handle; + let block = document + .block_records + .iter() + .find(|record| record.handle == owner) + .ok_or_else(|| { + "AttributeDefinition requires an existing block-record owner".to_owned() + })?; + if block.is_model_space() || block.is_paper_space() { + return Err("AttributeDefinition owner must be a block definition".into()); + } + let style = document + .text_styles + .get(value.text_style.trim()) + .ok_or_else(|| { + format!( + "AttributeDefinition text style {:?} does not exist", + value.text_style + ) + })?; + if style.is_shape_file { + return Err(format!( + "AttributeDefinition text style {:?} is a shape-file style", + style.name + )); + } + } + if let EntityType::AttributeEntity(value) = entity { + let owner = entity.common().owner_handle; + let insert = match document.get_entity(owner) { + Some(EntityType::Insert(insert)) => insert, + _ => return Err("AttributeEntity requires an existing Insert owner".into()), + }; + let block = document + .block_records + .get(insert.block_name.trim()) + .ok_or_else(|| { + format!( + "AttributeEntity owner references missing block {:?}", + insert.block_name + ) + })?; + let definition = block + .entity_handles + .iter() + .find_map(|handle| match document.get_entity(*handle) { + Some(EntityType::AttributeDefinition(definition)) + if definition.common.handle == value.attdef_handle => + { + Some(definition) + } + _ => None, + }) + .ok_or_else(|| { + "AttributeEntity attdef_handle does not belong to the owner's block".to_owned() + })?; + if !definition.tag.eq_ignore_ascii_case(value.tag.trim()) { + return Err("AttributeEntity tag does not match its attribute definition".into()); + } + let style = document + .text_styles + .get(value.text_style.trim()) + .ok_or_else(|| { + format!( + "AttributeEntity text style {:?} does not exist", + value.text_style + ) + })?; + if style.is_shape_file { + return Err(format!( + "AttributeEntity text style {:?} is a shape-file style", + style.name + )); + } + } + Ok(()) +} + +/// Stable handle wins; the name is the fallback for legacy entities. +#[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 + .style_handle + .filter(|handle| !handle.is_null()) + .and_then(|handle| match document.objects.get(&handle) { + Some(ObjectType::MLineStyle(style)) => Some(style), + _ => None, + }) + .or_else(|| { + document.objects.values().find_map(|object| match object { + ObjectType::MLineStyle(style) + if style.name.eq_ignore_ascii_case(mline.style_name.trim()) => + { + Some(style) + } + _ => None, + }) + }) +} + +/// Fill name-based table references with stable handles before a new entity or +/// replacement is committed. Existing non-null handles remain authoritative +/// for legacy DWG entities whose fallback name can be stale. +#[cfg(feature = "host")] +pub fn bind_canvas_entity_references( + document: &crate::host::CadDocument, + entity: &mut crate::host::EntityType, +) -> Result<(), String> { + use crate::host::EntityType; + match entity { + EntityType::Tolerance(value) + if value + .dimension_style_handle + .filter(|h| !h.is_null()) + .is_none() => + { + value.dimension_style_handle = document + .dim_styles + .iter() + .find(|style| { + style + .name + .eq_ignore_ascii_case(value.dimension_style_name.trim()) + }) + .map(|style| style.handle); + } + EntityType::MLine(value) if value.style_handle.filter(|h| !h.is_null()).is_none() => { + value.style_handle = resolve_mline_style(document, value).map(|style| style.handle); + } + EntityType::Shape(value) if value.style_handle.filter(|h| !h.is_null()).is_none() => { + value.style_handle = document + .text_styles + .iter() + .find(|style| style.name.eq_ignore_ascii_case(value.style_name.trim())) + .map(|style| style.handle); + } + EntityType::AttributeEntity(value) => { + let insert = match document.get_entity(value.common.owner_handle) { + Some(EntityType::Insert(insert)) => insert, + _ => return Err("AttributeEntity requires an existing Insert owner".into()), + }; + let block = document + .block_records + .get(insert.block_name.trim()) + .ok_or_else(|| { + format!( + "AttributeEntity owner references missing block {:?}", + insert.block_name + ) + })?; + let current_matches = block.entity_handles.iter().any(|handle| { + *handle == value.attdef_handle + && matches!(document.get_entity(*handle), + Some(EntityType::AttributeDefinition(definition)) + if definition.tag.eq_ignore_ascii_case(value.tag.trim())) + }); + if !current_matches { + value.attdef_handle = block + .entity_handles + .iter() + .find_map(|handle| match document.get_entity(*handle) { + Some(EntityType::AttributeDefinition(definition)) + if definition.tag.eq_ignore_ascii_case(value.tag.trim()) => + { + Some(*handle) + } + _ => None, + }) + .ok_or_else(|| { + format!( + "AttributeEntity tag {:?} has no definition in block {:?}", + value.tag, insert.block_name + ) + })?; + } + } + _ => {} + } + validate_canvas_entity_references(document, entity) +} + +/// Clone a canvas entity with a new layer, preserving its other snapshot +/// fields. Internal records and opaque fallbacks are excluded. +#[cfg(feature = "host")] +pub fn patch_canvas_layer( + entity: &crate::host::EntityType, + layer: &str, +) -> Result { + use crate::host::EntityType; + if layer.trim().is_empty() { + return Err("layer name is empty".into()); + } + if matches!( + entity, + EntityType::Block(_) + | EntityType::BlockEnd(_) + | EntityType::Seqend(_) + | EntityType::Extended(_) + | EntityType::Unknown(_) + ) { + return Err("entity kind does not support canvas layer edits".into()); + } + let mut changed = entity.clone(); + changed.common_mut().layer = layer.to_owned(); + Ok(changed) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{get_embedded_type_registry_json, TypeId, TypeRegistry}; + + #[test] + fn every_entity_variant_has_exactly_one_classification() { + let catalog: EntityCoverageCatalog = + serde_json::from_str(get_embedded_entity_coverage_json()).unwrap(); + let registry: TypeRegistry = + serde_json::from_str(get_embedded_type_registry_json()).unwrap(); + let variants = ®istry.types[&TypeId::new("EntityType")].variants; + assert_eq!(catalog.entity_kinds.len(), variants.len()); + let mut names = std::collections::HashSet::new(); + for entry in &catalog.entity_kinds { + assert!(names.insert(&entry.kind), "duplicate kind {}", entry.kind); + assert!(variants.iter().any(|v| v.name == entry.kind)); + assert!(entry + .properties + .iter() + .any(|p| p.name == "handle" && p.model_access == ModelAccess::ReadOnly)); + assert!(entry + .properties + .iter() + .any(|p| p.name == "kind" && p.model_access == ModelAccess::ReadOnly)); + assert!(entry + .properties + .iter() + .any(|p| p.name == "owner_handle" && p.model_access == ModelAccess::ReadOnly)); + let mut source_paths = std::collections::HashSet::new(); + for property in &entry.properties { + assert!( + source_paths.insert(&property.source_path), + "duplicate source path in {}: {}", + entry.kind, + property.source_path + ); + assert!( + property.snapshot_readable, + "unreadable traced property in {}: {}", + entry.kind, property.name + ); + if property.model_access == ModelAccess::ReadWrite { + assert!(matches!( + property.validation.as_str(), + "type_conversion_only" | "transaction_geometry" | "transaction_nonempty" + )); + } + } + } + } + + #[test] + fn exposed_property_names_are_unique_per_kind() { + // A field named like a base key (`kind`, `handle`, `layer`, ...) would + // overwrite it in every Python dict; such fields must be renamed. + let catalog: EntityCoverageCatalog = + serde_json::from_str(get_embedded_entity_coverage_json()).unwrap(); + for entry in &catalog.entity_kinds { + let mut seen = std::collections::HashSet::new(); + for property in entry.properties.iter().filter(|p| p.model_access != ModelAccess::Unmapped) { + assert!(seen.insert(property.name.clone()), "{}: duplicate exposed name {}", entry.kind, property.name); + } + } + } + + #[test] + fn unsupported_kinds_have_no_editable_properties() { + let catalog: EntityCoverageCatalog = + serde_json::from_str(get_embedded_entity_coverage_json()).unwrap(); + for entry in &catalog.entity_kinds { + if entry.scope != EntityScope::Canvas + || !matches!( + entry.kind.as_str(), + "Point" + | "Line" + | "Circle" + | "Arc" + | "Ellipse" + | "Polyline" + | "Polyline2D" + | "Polyline3D" + | "LwPolyline" + | "Spline" + | "Text" + | "MText" + | "Ray" + | "XLine" + | "Solid" + | "Face3D" + | "Insert" + | "Tolerance" + | "Shape" + | "AttributeDefinition" + | "AttributeEntity" + | "Hatch" + | "Leader" + | "MLine" + | "Dimension" + | "MultiLeader" + | "Table" + | "PolygonMesh" + | "PolyfaceMesh" + | "Mesh" + | "Helix" + | "RasterImage" + | "Wipeout" + | "Underlay" + | "Viewport" + | "ViewBorder" + | "Light" + | "Ole2Frame" + | "Solid3D" + | "Body" + | "Region" + | "Surface" + | "SectionSymbol" + ) + { + assert!( + entry + .properties + .iter() + .all(|p| p.model_access != ModelAccess::ReadWrite + || (entry.scope == EntityScope::Canvas && p.name == "layer")), + "{}", + entry.kind + ); + } + } + } + + #[test] + fn catalog_names_the_fields_with_transaction_geometry_checks() { + let catalog: EntityCoverageCatalog = + serde_json::from_str(get_embedded_entity_coverage_json()).unwrap(); + let checked: std::collections::HashSet<_> = catalog + .entity_kinds + .iter() + .flat_map(|entry| { + entry + .properties + .iter() + .filter(|property| property.validation == "transaction_geometry") + .map(|property| format!("{}.{}", entry.kind, property.name)) + }) + .collect(); + assert_eq!( + checked, + std::collections::HashSet::from([ + "Point.location".to_owned(), + "Line.start".to_owned(), + "Line.end".to_owned(), + "Circle.center".to_owned(), + "Circle.radius".to_owned(), + "Arc.center".to_owned(), + "Arc.radius".to_owned(), + "Ray.base_point".to_owned(), + "Ray.direction".to_owned(), + "XLine.base_point".to_owned(), + "XLine.direction".to_owned(), + "Solid.first_corner".to_owned(), + "Solid.second_corner".to_owned(), + "Solid.third_corner".to_owned(), + "Solid.fourth_corner".to_owned(), + "Solid.normal".to_owned(), + "Solid.thickness".to_owned(), + "Face3D.first_corner".to_owned(), + "Face3D.second_corner".to_owned(), + "Face3D.third_corner".to_owned(), + "Face3D.fourth_corner".to_owned(), + "Face3D.invisible_edges".to_owned(), + "Insert.insert_point".to_owned(), + "Insert.x_scale".to_owned(), + "Insert.y_scale".to_owned(), + "Insert.z_scale".to_owned(), + "Insert.rotation".to_owned(), + "Insert.normal".to_owned(), + "Insert.column_count".to_owned(), + "Insert.row_count".to_owned(), + "Insert.column_spacing".to_owned(), + "Insert.row_spacing".to_owned(), + "Tolerance.insertion_point".to_owned(), + "Tolerance.direction".to_owned(), + "Tolerance.normal".to_owned(), + "Tolerance.text".to_owned(), + "Tolerance.dimension_style_name".to_owned(), + "Tolerance.text_height".to_owned(), + "Tolerance.dimension_gap".to_owned(), + "Shape.insertion_point".to_owned(), + "Shape.size".to_owned(), + "Shape.shape_name".to_owned(), + "Shape.shape_number".to_owned(), + "Shape.rotation".to_owned(), + "Shape.relative_x_scale".to_owned(), + "Shape.oblique_angle".to_owned(), + "Shape.normal".to_owned(), + "Shape.thickness".to_owned(), + "Shape.style_name".to_owned(), + "AttributeDefinition.tag".to_owned(), + "AttributeDefinition.prompt".to_owned(), + "AttributeDefinition.default_value".to_owned(), + "AttributeDefinition.insertion_point".to_owned(), + "AttributeDefinition.alignment_point".to_owned(), + "AttributeDefinition.height".to_owned(), + "AttributeDefinition.rotation".to_owned(), + "AttributeDefinition.width_factor".to_owned(), + "AttributeDefinition.oblique_angle".to_owned(), + "AttributeDefinition.text_style".to_owned(), + "AttributeDefinition.text_generation_flags".to_owned(), + "AttributeDefinition.horizontal_alignment".to_owned(), + "AttributeDefinition.vertical_alignment".to_owned(), + "AttributeDefinition.flags".to_owned(), + "AttributeDefinition.field_length".to_owned(), + "AttributeDefinition.normal".to_owned(), + "AttributeDefinition.mtext_flag".to_owned(), + "AttributeDefinition.is_multiline".to_owned(), + "AttributeDefinition.line_count".to_owned(), + "AttributeDefinition.lock_position".to_owned(), + "AttributeEntity.tag".to_owned(), + "AttributeEntity.value".to_owned(), + "AttributeEntity.insertion_point".to_owned(), + "AttributeEntity.alignment_point".to_owned(), + "AttributeEntity.height".to_owned(), + "AttributeEntity.rotation".to_owned(), + "AttributeEntity.width_factor".to_owned(), + "AttributeEntity.oblique_angle".to_owned(), + "AttributeEntity.text_style".to_owned(), + "AttributeEntity.text_generation_flags".to_owned(), + "AttributeEntity.horizontal_alignment".to_owned(), + "AttributeEntity.vertical_alignment".to_owned(), + "AttributeEntity.flags".to_owned(), + "AttributeEntity.field_length".to_owned(), + "AttributeEntity.normal".to_owned(), + "AttributeEntity.mtext_flag".to_owned(), + "AttributeEntity.is_multiline".to_owned(), + "AttributeEntity.line_count".to_owned(), + "AttributeEntity.lock_position".to_owned(), + "Hatch.elevation".to_owned(), + "Hatch.normal".to_owned(), + "Hatch.is_solid".to_owned(), + "Hatch.pattern".to_owned(), + "Hatch.pattern_angle".to_owned(), + "Hatch.pattern_scale".to_owned(), + "Hatch.pattern_type".to_owned(), + "Hatch.is_double".to_owned(), + "Hatch.style".to_owned(), + "Hatch.is_associative".to_owned(), + "Hatch.pixel_size".to_owned(), + "Hatch.paths".to_owned(), + "Hatch.seed_points".to_owned(), + "Leader.dimension_style".to_owned(), + "Leader.arrow_enabled".to_owned(), + "Leader.path_type".to_owned(), + "Leader.creation_type".to_owned(), + "Leader.hookline_direction".to_owned(), + "Leader.hookline_enabled".to_owned(), + "Leader.text_height".to_owned(), + "Leader.text_width".to_owned(), + "Leader.vertices".to_owned(), + "Leader.annotation_handle".to_owned(), + "Leader.override_color".to_owned(), + "Leader.normal".to_owned(), + "Leader.horizontal_direction".to_owned(), + "Leader.block_offset".to_owned(), + "Leader.annotation_offset".to_owned(), + "MLine.flags".to_owned(), + "MLine.justification".to_owned(), + "MLine.normal".to_owned(), + "MLine.scale_factor".to_owned(), + "MLine.style_name".to_owned(), + "MLine.vertices".to_owned(), + "Dimension.text".to_owned(), + "Dimension.style_name".to_owned(), + "Dimension.normal".to_owned(), + "Dimension.text_middle_point".to_owned(), + "Dimension.attachment_point".to_owned(), + "Dimension.text_rotation".to_owned(), + "Dimension.horizontal_direction".to_owned(), + "Dimension.flip_arrow1".to_owned(), + "Dimension.flip_arrow2".to_owned(), + "Dimension.text_user_positioned".to_owned(), + "Dimension.definition_point".to_owned(), + "Dimension.first_point".to_owned(), + "Dimension.second_point".to_owned(), + "Dimension.angle_vertex".to_owned(), + "Dimension.dimension_arc".to_owned(), + "Dimension.feature_location".to_owned(), + "Dimension.leader_endpoint".to_owned(), + "Dimension.first_extension_point".to_owned(), + "Dimension.second_extension_point".to_owned(), + "Dimension.center_point".to_owned(), + "Dimension.first_leader_point".to_owned(), + "Dimension.second_leader_point".to_owned(), + "Dimension.chord_point".to_owned(), + "Dimension.override_center".to_owned(), + "Dimension.jog_point".to_owned(), + "Dimension.rotation".to_owned(), + "Dimension.ext_line_rotation".to_owned(), + "Dimension.leader_length".to_owned(), + "Dimension.arc_start_parameter".to_owned(), + "Dimension.arc_end_parameter".to_owned(), + "Dimension.jog_angle".to_owned(), + "Dimension.is_ordinate_type_x".to_owned(), + "Dimension.is_partial".to_owned(), + "Dimension.has_leader".to_owned(), + "MultiLeader.content_type".to_owned(), + "MultiLeader.path_type".to_owned(), + "MultiLeader.line_color".to_owned(), + "MultiLeader.line_weight".to_owned(), + "MultiLeader.enable_landing".to_owned(), + "MultiLeader.enable_dogleg".to_owned(), + "MultiLeader.dogleg_length".to_owned(), + "MultiLeader.arrowhead_size".to_owned(), + "MultiLeader.text_color".to_owned(), + "MultiLeader.text_frame".to_owned(), + "MultiLeader.text_left_attachment".to_owned(), + "MultiLeader.text_right_attachment".to_owned(), + "MultiLeader.text_top_attachment".to_owned(), + "MultiLeader.text_bottom_attachment".to_owned(), + "MultiLeader.text_attachment_direction".to_owned(), + "MultiLeader.text_attachment_point".to_owned(), + "MultiLeader.text_alignment".to_owned(), + "MultiLeader.text_angle_type".to_owned(), + "MultiLeader.text_direction_negative".to_owned(), + "MultiLeader.scale_factor".to_owned(), + "MultiLeader.enable_annotation_scale".to_owned(), + "MultiLeader.extend_leader_to_text".to_owned(), + "MultiLeader.block_content_color".to_owned(), + "MultiLeader.block_connection_type".to_owned(), + "MultiLeader.block_rotation".to_owned(), + "MultiLeader.block_scale".to_owned(), + "MultiLeader.context".to_owned(), + "MultiLeader.style_handle".to_owned(), + "MultiLeader.text_style_handle".to_owned(), + "MultiLeader.arrowhead_handle".to_owned(), + "MultiLeader.line_type_handle".to_owned(), + "MultiLeader.block_content_handle".to_owned(), + "Table.insertion_point".to_owned(), + "Table.horizontal_direction".to_owned(), + "Table.normal".to_owned(), + "Table.table_style_handle".to_owned(), + "Table.rows".to_owned(), + "Table.columns".to_owned(), + "Table.merged_ranges".to_owned(), + "Table.break_spacing".to_owned(), + "Table.break_flow_direction".to_owned(), + "Table.break_options".to_owned(), + "PolygonMesh.flags".to_owned(), + "PolygonMesh.m_vertex_count".to_owned(), + "PolygonMesh.n_vertex_count".to_owned(), + "PolygonMesh.m_smooth_density".to_owned(), + "PolygonMesh.n_smooth_density".to_owned(), + "PolygonMesh.smooth_type".to_owned(), + "PolygonMesh.elevation".to_owned(), + "PolygonMesh.normal".to_owned(), + "PolygonMesh.vertices".to_owned(), + "PolyfaceMesh.elevation".to_owned(), + "PolyfaceMesh.flags".to_owned(), + "PolyfaceMesh.normal".to_owned(), + "PolyfaceMesh.start_width".to_owned(), + "PolyfaceMesh.end_width".to_owned(), + "PolyfaceMesh.smooth_surface".to_owned(), + "PolyfaceMesh.thickness".to_owned(), + "PolyfaceMesh.vertices".to_owned(), + "PolyfaceMesh.faces".to_owned(), + "Mesh.blend_crease".to_owned(), + "Mesh.subdivision_level".to_owned(), + "Mesh.vertices".to_owned(), + "Mesh.faces".to_owned(), + "Mesh.edges".to_owned(), + "Mesh.override_option".to_owned(), + "Helix.axis_base_point".to_owned(), + "Helix.start_point".to_owned(), + "Helix.axis_vector".to_owned(), + "Helix.radius".to_owned(), + "Helix.turns".to_owned(), + "Helix.turn_height".to_owned(), + "Helix.handedness".to_owned(), + "Helix.constraint".to_owned(), + "RasterImage.insertion_point".to_owned(), + "RasterImage.u_vector".to_owned(), + "RasterImage.v_vector".to_owned(), + "RasterImage.flags".to_owned(), + "RasterImage.clipping_enabled".to_owned(), + "RasterImage.brightness".to_owned(), + "RasterImage.contrast".to_owned(), + "RasterImage.fade".to_owned(), + "RasterImage.clip_boundary".to_owned(), + "RasterImage.file_path".to_owned(), + "Wipeout.insertion_point".to_owned(), + "Wipeout.u_vector".to_owned(), + "Wipeout.v_vector".to_owned(), + "Wipeout.size".to_owned(), + "Wipeout.flags".to_owned(), + "Wipeout.clipping_enabled".to_owned(), + "Wipeout.brightness".to_owned(), + "Wipeout.contrast".to_owned(), + "Wipeout.fade".to_owned(), + "Wipeout.clip_mode".to_owned(), + "Wipeout.clip_type".to_owned(), + "Wipeout.clip_boundary_vertices".to_owned(), + "Ole2Frame.upper_left_corner".to_owned(), + "Ole2Frame.lower_right_corner".to_owned(), + "Ole2Frame.lock_aspect".to_owned(), + "Ole2Frame.is_paper_space".to_owned(), + "Surface.u_isolines".to_owned(), + "Surface.v_isolines".to_owned(), + "SectionSymbol.symbol_scale".to_owned(), + "SectionSymbol.points".to_owned(), + "SectionSymbol.style_handle".to_owned(), + "SectionSymbol.view_rep_handle".to_owned(), + "Underlay.underlay_type".to_owned(), + "Underlay.definition_handle".to_owned(), + "Underlay.insertion_point".to_owned(), + "Underlay.x_scale".to_owned(), + "Underlay.y_scale".to_owned(), + "Underlay.z_scale".to_owned(), + "Underlay.rotation".to_owned(), + "Underlay.normal".to_owned(), + "Underlay.flags".to_owned(), + "Underlay.contrast".to_owned(), + "Underlay.fade".to_owned(), + "Underlay.clip_boundary_vertices".to_owned(), + "Underlay.clip_inverted".to_owned(), + "Viewport.center".to_owned(), + "Viewport.width".to_owned(), + "Viewport.height".to_owned(), + "Viewport.status".to_owned(), + "Viewport.view_center".to_owned(), + "Viewport.view_direction".to_owned(), + "Viewport.view_target".to_owned(), + "Viewport.lens_length".to_owned(), + "Viewport.front_clip_z".to_owned(), + "Viewport.back_clip_z".to_owned(), + "Viewport.view_height".to_owned(), + "Viewport.twist_angle".to_owned(), + "Viewport.frozen_layers".to_owned(), + "Viewport.render_mode".to_owned(), + "Viewport.circle_sides".to_owned(), + "Viewport.ucs_icon_visible".to_owned(), + "ViewBorder.min".to_owned(), + "ViewBorder.max".to_owned(), + "ViewBorder.center".to_owned(), + "ViewBorder.scale".to_owned(), + "ViewBorder.rotation_angle".to_owned(), + "ViewBorder.active_viewport".to_owned(), + "ViewBorder.scale_handle".to_owned(), + "Light.name".to_owned(), + "Light.light_type".to_owned(), + "Light.position".to_owned(), + "Light.target".to_owned(), + "Light.status".to_owned(), + "Light.light_color".to_owned(), + "Light.plot_glyph".to_owned(), + "Light.intensity".to_owned(), + "Light.attenuation_type".to_owned(), + "Light.use_attenuation_limits".to_owned(), + "Light.attenuation_start_limit".to_owned(), + "Light.attenuation_end_limit".to_owned(), + "Light.hotspot_angle".to_owned(), + "Light.falloff_angle".to_owned(), + "Light.cast_shadows".to_owned(), + "Light.shadow_type".to_owned(), + "Light.shadow_map_size".to_owned(), + "Light.shadow_map_softness".to_owned(), + "Light.photometric_mode".to_owned(), + "Light.photometric_data".to_owned(), + ]) + ); + } + + #[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( + Vector3::new(1.0, 2.0, 0.0), + "{\\Fgdt;p}%%v0.1", + ); + let entity = acadrust::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())) + .unwrap_err() + .contains("unit vector") + ); + tolerance.direction = Vector3::UNIT_X; + tolerance.dimension_style_name = "Missing".into(); + assert!(validate_canvas_entity_references( + &document, + &acadrust::EntityType::Tolerance(tolerance) + ) + .unwrap_err() + .contains("does not exist")); + } + + #[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(); + 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( + 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()); + validate_new_canvas_entity(&entity).unwrap(); + validate_canvas_entity_references(&document, &entity).unwrap(); + + let mut wrong_kind = leader.clone(); + wrong_kind.creation_type = LeaderCreationType::WithTolerance; + assert!(validate_canvas_entity_references( + &document, + &acadrust::EntityType::Leader(wrong_kind) + ) + .unwrap_err() + .contains("does not match")); + let mut missing = leader.clone(); + missing.annotation_handle = acadrust::Handle::new(0xdead); + assert!(validate_canvas_entity_references( + &document, + &acadrust::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)) + .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)) + .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) + ) + .unwrap_err() + .contains("dimension style")); + + 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) + ) + .unwrap_err() + .contains("finite")); + } + + #[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(); + 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()); + 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())); + + let mut missing = mline.clone(); + missing.style_name = "Missing".into(); + assert!(bind_canvas_entity_references(&document, &mut acadrust::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)) + .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")); + 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")); + let mut scale = mline.clone(); + scale.scale_factor = 0.0; + assert!(validate_new_canvas_entity(&acadrust::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"); + 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( + Vector3::new(1.0, 2.0, 0.0), + "ARROW", + "Symbols", + 2.0, + 0.5, + ); + shape.shape_number = 1; + let mut entity = acadrust::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) + if value.style_handle == Some(style_handle))); + let acadrust::EntityType::Shape(mut bad) = entity else { + unreachable!() + }; + bad.size = 0.0; + assert!( + validate_new_canvas_entity(&acadrust::EntityType::Shape(bad)) + .unwrap_err() + .contains("greater than zero") + ); + } + + #[test] + fn layer_write_coverage_matches_canvas_scope() { + let catalog: EntityCoverageCatalog = + serde_json::from_str(get_embedded_entity_coverage_json()).unwrap(); + assert_eq!( + catalog + .entity_kinds + .iter() + .filter(|entry| entry.scope == EntityScope::Canvas) + .count(), + 43 + ); + for entry in &catalog.entity_kinds { + let layer = entry + .properties + .iter() + .find(|property| property.name == "layer") + .unwrap(); + assert_eq!( + layer.model_access == ModelAccess::ReadWrite, + entry.scope == EntityScope::Canvas + ); + if entry.scope == EntityScope::Canvas { + assert_eq!(layer.validation, "transaction_nonempty"); + } + } + } + + #[test] + fn insert_catalog_separates_transform_writes_from_block_identity() { + let catalog: EntityCoverageCatalog = + serde_json::from_str(get_embedded_entity_coverage_json()).unwrap(); + let insert = catalog + .entity_kinds + .iter() + .find(|entry| entry.kind == "Insert") + .unwrap(); + let block_name = insert + .properties + .iter() + .find(|property| property.name == "block_name") + .unwrap(); + // Named at creation; a geometry transaction can never change it (the + // mutation check rejects a different block). + assert_eq!(block_name.model_access, ModelAccess::ReadWrite); + assert!( + insert + .properties + .iter() + .find(|property| property.name == "attributes") + .unwrap() + .model_access + == ModelAccess::Unmapped + ); + assert!( + insert + .properties + .iter() + .find(|property| property.name == "insert_point") + .unwrap() + .model_access + == ModelAccess::ReadWrite + ); + } + + #[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()); + let snapshot = entity_snapshot(&hatch).unwrap(); + let fields = snapshot + .get("Hatch") + .and_then(|value| value.as_object()) + .unwrap(); + assert!(fields.contains_key("common")); + assert!(fields.len() > 1); + } + + #[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(); + 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), + ) + .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), + ) + .unwrap_err() + .contains("Circle.radius")); + } + + #[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(); + ray.direction = Vector3::new(2.0, 0.0, 0.0); + assert!( + validate_new_canvas_entity(&acadrust::EntityType::Ray(ray.clone())) + .unwrap_err() + .contains("Ray.direction") + ); + let before = acadrust::EntityType::Ray(ray.clone()); + ray.common.layer = "CONSTRUCTION".into(); + assert!(validate_entity_mutation(&before, &acadrust::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()); + + let mut xline = acadrust::entities::XLine::default(); + xline.base_point.x = f64::NAN; + assert!( + validate_new_canvas_entity(&acadrust::EntityType::XLine(xline)) + .unwrap_err() + .contains("XLine.base_point") + ); + + let solid = acadrust::entities::Solid::new( + Vector3::ZERO, + Vector3::UNIT_X, + Vector3::UNIT_Y, + Vector3::ZERO, + ); + let mut changed = solid.clone(); + changed.thickness = f64::INFINITY; + assert!(validate_entity_mutation( + &acadrust::EntityType::Solid(solid), + &acadrust::EntityType::Solid(changed) + ) + .unwrap_err() + .contains("Solid.thickness")); + + let face = acadrust::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); + assert!(validate_entity_mutation( + &acadrust::EntityType::Face3D(face), + &acadrust::EntityType::Face3D(changed) + ) + .unwrap_err() + .contains("Face3D.invisible_edges")); + } + + #[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)); + 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) + ) + .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) + ) + .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) + ) + .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) + ) + .unwrap_err() + .contains("block identity")); + } + + #[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()); + let patched = patch_canvas_layer(&hatch, "HATCHES").unwrap(); + assert_eq!(patched.common().layer, "HATCHES"); + assert_eq!(hatch.common().layer, "0"); + let mut expected = hatch.clone(); + expected.common_mut().layer = "HATCHES".into(); + assert_eq!(patched, expected); + assert!(patch_canvas_layer(&hatch, " ").is_err()); + } + + #[cfg(feature = "host")] + #[test] + fn attribute_definition_requires_a_block_owner_and_text_style() { + use crate::host::acadrust::{ + self, + entities::{Block, BlockEnd}, + types::{Handle, Vector3}, + }; + let mut document = acadrust::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"); + record.handle = record_handle; + record.block_entity_handle = block_handle; + record.block_end_handle = end_handle; + document.block_records.add(record).unwrap(); + let mut block = Block::new("TAGBLOCK", Vector3::ZERO); + block.common.handle = block_handle; + block.common.owner_handle = record_handle; + document + .add_entity(acadrust::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)) + .unwrap(); + + let mut definition = acadrust::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()); + 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()) + ) + .unwrap_err() + .contains("block-record owner")); + definition.common.owner_handle = record_handle; + definition.text_style = "Missing".into(); + assert!(validate_canvas_entity_references( + &document, + &acadrust::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)) + .unwrap_err() + .contains("no whitespace") + ); + } + #[test] + fn hatch_geometry_and_boundaries_are_validated() { + use acadrust::entities::hatch::{BoundaryEdge, BoundaryPath, LineEdge}; + let mut hatch = acadrust::entities::Hatch::new(); + let mut path = BoundaryPath::new(); + for (start, end) in [ + ((0.0, 0.0), (10.0, 0.0)), + ((10.0, 0.0), (10.0, 10.0)), + ((10.0, 10.0), (0.0, 10.0)), + ((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), + })); + } + hatch.paths.push(path); + let entity = acadrust::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)) + .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)) + .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, + ), + )).unwrap(); + let mut associative = hatch.clone(); + associative.is_associative = true; + associative.paths[0].boundary_handles.push(boundary); + validate_canvas_entity_references( + &document, + &acadrust::EntityType::Hatch(associative.clone()), + ).unwrap(); + associative.paths[0].boundary_handles[0] = acadrust::Handle::new(u64::MAX); + assert!(validate_canvas_entity_references( + &document, + &acadrust::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 new file mode 100644 index 000000000..9c056c2e2 --- /dev/null +++ b/crates/ocs_plugin_api/src/entity_coverage_types.rs @@ -0,0 +1,52 @@ +/// Stable, language-neutral entity capability schema. + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum EntityScope { + Canvas, + Internal, + Opaque, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ModelAccess { + ReadWrite, + ReadOnly, + Unmapped, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +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. + pub source_path: String, + pub type_id: String, + pub optional: bool, + pub is_sequence: bool, + /// Whether the serialized host snapshot carries this property. + pub snapshot_readable: bool, + /// `unmapped` means no normalized Python property or setter; the raw + /// snapshot can still expose its serialized value. + pub model_access: ModelAccess, + /// Write validation applied by the v7 transaction path. Currently + /// `transaction_geometry` for selected primitive geometry, otherwise + /// `type_conversion_only` for writable properties or `none`. + pub validation: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EntityKindCoverage { + pub kind: String, + pub scope: EntityScope, + pub properties: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EntityCoverageCatalog { + pub schema_version: u32, + pub entity_kinds: Vec, +} diff --git a/crates/ocs_plugin_api/src/host.rs b/crates/ocs_plugin_api/src/host.rs index 951900621..445393aaa 100644 --- a/crates/ocs_plugin_api/src/host.rs +++ b/crates/ocs_plugin_api/src/host.rs @@ -60,6 +60,243 @@ pub enum LogLevel { Error, } +/// A solid primitive the host can build with its geometry kernel. Lengths are +/// in drawing units; `center`/`origin` are world coordinates. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum SolidPrimitive { + /// Axis-aligned box from its center and full extents. + Box { center: [f64; 3], size: [f64; 3] }, + /// Right triangular prism from its minimum corner and extents. + Wedge { origin: [f64; 3], size: [f64; 3] }, + /// Cylinder standing on the plane through `center`. + Cylinder { center: [f64; 3], radius: f64, height: f64 }, + Sphere { center: [f64; 3], radius: f64 }, + Torus { center: [f64; 3], major: f64, minor: f64 }, + /// Regular pyramid with `sides` base edges. + Pyramid { center: [f64; 3], radius: f64, height: f64, sides: u32 }, +} + +/// How two solids combine. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum SolidBoolean { + Union, + /// Remove the second solid from the first. + Subtract, + Intersect, +} + +/// One step of driving an OCS command from a script (API v7, additive). The +/// host runs the real command, so every tool behaves exactly as at the command +/// line, and answers with where the command stands. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum CommandRequest { + /// Run a whole command line (`OFFSET 5 ...`): the tokens after the name + /// answer the prompts in order and a final Enter finishes the command. + Run { line: String }, + /// Start a command and leave it waiting for its first input. + Start { name: String }, + /// Answer the current prompt with a world-coordinate point. + Point { point: [f64; 3] }, + /// Answer with typed text (a distance, an angle, a name). + Text { text: String }, + /// Answer with a keyword option. + Token { text: String }, + /// Pick an entity at a point. + Entity { handle: Handle, point: [f64; 3] }, + /// Complete an object-selection prompt with the current selection. + Selection, + /// Press Enter. + Enter, + /// Cancel the running command and close anything it left open. + Cancel, +} + +/// Where a command stands after a [`CommandRequest`]. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +pub struct CommandOutcome { + /// `completed` when nothing is waiting, `waiting_input` when the command + /// (or an editor or dialog it opened) needs more. + pub status: String, + /// What is still open: `command`, `text_editor`, `mtext_editor` or `modal:`. + pub blocked_by: Option, + /// The waiting command's name and prompt (empty when none). + pub command: String, + pub prompt: String, + /// The kinds of input the prompt accepts (`point`, `entity`, `selection`, + /// `text`, `token`, `enter`) and its keyword options. + pub accepts: Vec, + pub options: Vec, + /// Entities in the drawing after the step, and the change. + pub entities: u64, + pub added: i64, + /// Typed tokens no prompt asked for. + pub unconsumed: Vec, + /// The command line's error message when the step failed. + pub error: Option, +} + +/// A change to a drawing table record (API v7, additive). The host validates +/// the request, records one undo step and refuses without changing anything +/// when it cannot honour it; the result is the record's handle. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum TableOperation { + /// Create a layer. `config.name` must be unused and valid. + LayerCreate { config: LayerConfig }, + /// Change only the properties `config` sets on an existing layer. + LayerModify { config: LayerConfig }, + /// Rename a layer; entities on it follow. Layer `0` and `Defpoints` stay. + LayerRename { from: String, to: String }, + /// Delete a layer. A layer that still holds objects is refused unless + /// `erase_objects` is set, which erases them with the layer. Layer `0`, + /// `Defpoints` and the current layer are never deleted. + LayerDelete { name: String, erase_objects: bool }, + /// Make an existing layer current. + LayerSetCurrent { name: String }, + /// Create a text style; unset properties take the AutoCAD defaults. + TextStyleCreate { config: TextStyleConfig }, + /// Change only the properties `config` sets on an existing text style. + TextStyleModify { config: TextStyleConfig }, + /// Create a dimension style, optionally copying an existing one first. + /// `properties` is a JSON object of DimStyle fields (see the Python docs); + /// handles and xref fields are managed by the host and refused. + DimStyleCreate { name: String, copy_from: Option, properties: String }, + /// Change only the DimStyle fields the JSON object `properties` names. + DimStyleModify { name: String, properties: String }, + /// Rename a text or dimension style; references to it follow. `Standard` + /// is never renamed and a case-only change is refused. + StyleRename { kind: TableStyleKind, from: String, to: String }, + /// Delete a text or dimension style that is neither current nor in use. + /// `Standard` is never deleted. + StyleDelete { kind: TableStyleKind, name: String }, + /// Make a text or dimension style current. + StyleSetCurrent { kind: TableStyleKind, name: String }, + /// Define a block from existing drawing entities. The entities are copied + /// into the definition shifted by `-base_point` (the block's origin); + /// `erase_originals` then removes them from the drawing. No insert is + /// placed. Returns the block record's handle. + BlockCreate { + name: String, + entities: Vec, + base_point: [f64; 3], + erase_originals: bool, + description: Option, + }, + /// Change a block's description, explodable or uniform-scale settings. + BlockModify { + name: String, + description: Option, + explodable: Option, + scale_uniformly: Option, + }, + /// Rename a block; every insert of it follows. + BlockRename { from: String, to: String }, + /// Delete an unreferenced block definition and its contents. + BlockDelete { name: String }, + /// Add a new entity to a block definition, validated exactly as a new + /// model-space entity is. The entity's owner is set by the host. Returns the + /// new entity's handle. Edit and delete members with the ordinary entity + /// operations. + BlockEntityAdd { block: String, entity: EntityType }, + /// Create a simple linetype. `pattern` is signed lengths in drawing units: + /// positive is a dash, negative a gap, zero a dot (2-12 elements with at + /// least one dash or dot and one gap). Returns the linetype's handle. + LinetypeCreate { name: String, description: String, pattern: Vec }, + /// Change a simple linetype's description or pattern in place. + LinetypeModify { name: String, description: Option, pattern: Option> }, + /// Rename a linetype; layers and entities that use it follow. + LinetypeRename { from: String, to: String }, + /// Delete a linetype that no layer, entity, dimension style or the current + /// setting uses. `Continuous`, `ByLayer` and `ByBlock` are never deleted. + LinetypeDelete { name: String }, + /// Create a paper-space layout with the default page setup and sheet viewport. + LayoutCreate { name: String }, + /// Rename a paper-space layout (never `Model`). + LayoutRename { from: String, to: String }, + /// Delete a paper-space layout and everything on it (never `Model`). + LayoutDelete { name: String }, + /// Switch the active layout (`Model` or a paper-space layout). + LayoutSetCurrent { name: String }, + /// Change a paper-space layout's sheet: size in millimetres, rotation in + /// degrees (0, 90, 180 or 270) and a custom plot scale (numerator, + /// denominator). + LayoutSetPage { + name: String, + paper_size: Option<[f64; 2]>, + rotation: Option, + scale: Option<[f64; 2]>, + }, +} + +/// The style tables `TableOperation` can rename, delete or make current. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum TableStyleKind { + Text, + Dim, +} + +/// Properties for creating or changing a text style; `None` leaves a property +/// as it was (or the default for a new style). Angles are in radians. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +pub struct TextStyleConfig { + pub name: String, + /// Fixed height; `0` lets each text choose its own. + pub height: Option, + pub width_factor: Option, + pub oblique_angle: Option, + pub font_file: Option, + pub big_font_file: Option, + pub true_type_font: Option, + pub backward: Option, + pub upside_down: Option, + pub vertical: Option, + pub annotative: Option, +} + +/// A kernel operation on ACIS-backed solids (API v7, additive). The host owns +/// the geometry: a script never sees or rewrites the payload. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum SolidOperation { + /// Build a new solid on `layer` (layer `0` when `None`). + Create { primitive: SolidPrimitive, layer: Option }, + /// Apply a rigid transform, given as a column-major 4x4 matrix, to an + /// existing solid. Scaling and shear are refused. + Transform { handle: Handle, matrix: [f64; 16] }, + /// Build a planar region from one closed planar profile entity (a circle, + /// ellipse, closed polyline or spline, ...). The new region goes on + /// `layer`, or on the source's layer when `None`; `delete_source` erases + /// the profile afterwards, as the REGION command does. + RegionFromProfile { source: Handle, layer: Option, delete_source: bool }, + /// Build a plane surface from one closed planar profile entity. + SurfaceFromProfile { source: Handle, layer: Option, delete_source: bool }, + /// Extrude a planar profile along `direction`: a solid when the profile is + /// closed, a surface when it is open. + Extrude { source: Handle, direction: [f64; 3], layer: Option, delete_source: bool }, + /// Combine two solids into a new one. Both operands are consumed unless + /// `keep_operands`; the result goes on `layer` or the first operand's + /// layer. The kernel refuses some cases (coincident faces, cuts it has no + /// closed form for); a refusal changes nothing. Curved operands can take a + /// second or two, and the host thread is busy meanwhile. + /// Embed a picture file as an OLE frame whose bottom-left corner is at + /// `origin`, `width` drawing units wide, keeping the picture's aspect. + /// PNG, JPEG and BMP are stored as read; other formats are re-encoded as + /// PNG. The frame goes on `layer` (layer `0` when `None`). + EmbedPicture { path: String, origin: [f64; 3], width: f64, layer: Option }, + Boolean { + first: Handle, + second: Handle, + operation: SolidBoolean, + layer: Option, + keep_operands: bool, + }, +} + +/// Value of a host-managed drafting or document setting exposed to plugins. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum HostSettingValue { + Text(String), + Number(f64), +} + /// A notification the host sends to a plugin. These are best-effort, /// full-duplex messages correlated with an optional `command_id`. #[derive(Debug, Clone, PartialEq)] @@ -76,6 +313,10 @@ pub enum HostNotification { DocumentTabClosed { tab_id: u64 }, /// V4 selection changed for a specific tab. Discriminant 7. SelectionChangedV4 { tab_id: u64, handles: Vec }, + /// V7 active interactive-command state for a tab. Discriminant 8. + CommandStateChanged { tab_id: u64, command: Option }, + /// V7 drawing epoch, broadcast even without an open shared view. Discriminant 9. + DrawingChanged { tab_id: u64, epoch: u64 }, /// Fallback for notification variants added in future minor revisions. /// Carries the raw bincode payload so an older peer can ignore it without /// failing deserialization. @@ -126,6 +367,20 @@ impl Serialize for HostNotification { bincode::serialize_into(&mut bytes, handles) .map_err(serde::ser::Error::custom)?; } + HostNotification::CommandStateChanged { tab_id, command } => { + bytes.push(8); + bincode::serialize_into(&mut bytes, tab_id) + .map_err(serde::ser::Error::custom)?; + bincode::serialize_into(&mut bytes, command) + .map_err(serde::ser::Error::custom)?; + } + HostNotification::DrawingChanged { tab_id, epoch } => { + bytes.push(9); + bincode::serialize_into(&mut bytes, tab_id) + .map_err(serde::ser::Error::custom)?; + bincode::serialize_into(&mut bytes, epoch) + .map_err(serde::ser::Error::custom)?; + } HostNotification::Unknown(raw) => bytes.extend_from_slice(raw), } bytes.serialize(serializer) @@ -163,6 +418,12 @@ impl<'de> Deserialize<'de> for HostNotification { 7 => bincode::deserialize(rest) .map(|(tab_id, handles)| HostNotification::SelectionChangedV4 { tab_id, handles }) .map_err(serde::de::Error::custom), + 8 => bincode::deserialize(rest) + .map(|(tab_id, command)| HostNotification::CommandStateChanged { tab_id, command }) + .map_err(serde::de::Error::custom), + 9 => bincode::deserialize(rest) + .map(|(tab_id, epoch)| HostNotification::DrawingChanged { tab_id, epoch }) + .map_err(serde::de::Error::custom), _ => Ok(HostNotification::Unknown(bytes)), } } @@ -553,6 +814,62 @@ pub trait HostApi { None } + /// Read a named host setting without invoking the command dispatcher. + /// Names are case-insensitive. Hosts may support only a subset. + fn system_variable(&self, _name: &str) -> Option { + None + } + + /// Set a named host setting without re-entering command dispatch. + /// Returns the effective value after validation and normalization. + fn set_system_variable( + &mut self, + _name: &str, + _value: HostSettingValue, + ) -> Result { + Err("system variable is not supported by this host".to_owned()) + } + + /// Validate and replace existing entities as one undo step (API v7). + /// Callers clone entities from `document()`, change only supported fields, + /// and submit all replacements together. Identity and kind must be kept. + /// An error leaves the drawing and undo history unchanged. + fn update_entities_transaction( + &mut self, + _label: &str, + _entities: Vec, + ) -> Result<(), String> { + Err("entity transactions are not supported by this host".to_owned()) + } + + /// Current ordered selection in this session's tab (API v7). + fn selection(&self) -> Vec { Vec::new() } + + /// Replace this tab's selection with exactly these handles, in order. + /// Missing or duplicate handles reject the request (API v7). + fn set_selection(&mut self, _handles: &[Handle]) -> Result<(), String> { + Err("selection writes are not supported by this host".to_owned()) + } + + /// Run a kernel-backed solid operation and return the created or updated + /// entity's handle. Refused, without any change, when the kernel cannot + /// perform it losslessly (API v7, additive). + fn solid_operation(&mut self, _operation: SolidOperation) -> Result { + Err("solid operations are not supported by this host".to_owned()) + } + + /// Drive an OCS command from a script (API v7, additive). Refused, with + /// nothing run, when another command is already active or the request names + /// a command that could end the session or re-enter the plugin. + fn run_command(&mut self, _request: CommandRequest) -> Result { + Err("running commands is not supported by this host".to_owned()) + } + + /// Create, change, rename, delete or select a drawing table record (API v7, + /// additive). Returns the record's handle; a refusal changes nothing. + fn table_operation(&mut self, _operation: TableOperation) -> Result { + Err("table operations are not supported by this host".to_owned()) + } /// Add a layer to the active document with full initial properties. /// If an optional property in `config` is `None`, standard CAD defaults are applied. /// Returns `None` if the layer already exists or `config.name` is invalid. diff --git a/crates/ocs_plugin_api/src/ipc/client.rs b/crates/ocs_plugin_api/src/ipc/client.rs index cf86f02a5..b347d44fb 100644 --- a/crates/ocs_plugin_api/src/ipc/client.rs +++ b/crates/ocs_plugin_api/src/ipc/client.rs @@ -11,7 +11,7 @@ use acadrust::{CadDocument, EntityType, Handle}; use interprocess::local_socket::traits::Stream as StreamTrait; use interprocess::local_socket::{GenericNamespaced, Stream, ToNsName}; -use crate::host::{DocumentReader, HostApi, InteractiveCommand, ReaderEntity}; +use crate::host::{DocumentReader, HostApi, HostSettingValue, InteractiveCommand, ReaderEntity}; use crate::ipc::protocol::{ HostResponse, HostToPlugin, PluginRequest, PluginResponse, PluginToHost, RunnerHandshake, }; @@ -417,6 +417,104 @@ impl HostApi for PluginHostApi { } } + fn system_variable(&self, name: &str) -> Option { + match self.client.request(PluginRequest::GetSystemVariable { name: name.to_owned() }) { + Ok(PluginResponse::SystemVariable(value)) => value, + _ => None, + } + } + + fn set_system_variable( + &mut self, + name: &str, + value: HostSettingValue, + ) -> Result { + match self.client.request(PluginRequest::SetSystemVariable { + name: name.to_owned(), + value, + }) { + Ok(PluginResponse::SystemVariableResult(result)) => { + if result.is_ok() { + self.document_cache = OnceCell::new(); + } + result + } + Ok(PluginResponse::Error(error)) => Err(error), + Ok(other) => Err(format!("unexpected system variable response: {other:?}")), + Err(error) => Err(error.to_string()), + } + } + + fn update_entities_transaction( + &mut self, + label: &str, + entities: Vec, + ) -> Result<(), String> { + match self.client.request(PluginRequest::UpdateEntitiesTransaction { + label: label.to_owned(), entities, + }) { + Ok(PluginResponse::EntityTransactionResult(result)) => { + if result.is_ok() { self.document_cache = OnceCell::new(); } + result + } + Ok(PluginResponse::Error(error)) => Err(error), + Ok(other) => Err(format!("unexpected entity transaction response: {other:?}")), + Err(error) => Err(error.to_string()), + } + } + + fn selection(&self) -> Vec { + match self.client.request(PluginRequest::GetSelection) { + Ok(PluginResponse::Selection(handles)) => handles, + _ => Vec::new(), + } + } + + fn solid_operation(&mut self, operation: crate::host::SolidOperation) -> Result { + match self.client.request(PluginRequest::SolidOperation { operation }) { + Ok(PluginResponse::SolidResult(result)) => { + if result.is_ok() { self.document_cache = OnceCell::new(); } + result + } + Ok(PluginResponse::Error(error)) => Err(error), + Ok(other) => Err(format!("unexpected solid operation response: {other:?}")), + Err(error) => Err(error.to_string()), + } + } + + fn run_command(&mut self, request: crate::host::CommandRequest) -> Result { + match self.client.request(PluginRequest::RunCommand { request }) { + Ok(PluginResponse::CommandResult(result)) => { + self.document_cache = OnceCell::new(); + result + } + Ok(PluginResponse::Error(error)) => Err(error), + Ok(other) => Err(format!("unexpected command response: {other:?}")), + Err(error) => Err(error.to_string()), + } + } + + fn table_operation(&mut self, operation: crate::host::TableOperation) -> Result { + match self.client.request(PluginRequest::TableOperation { operation }) { + Ok(PluginResponse::TableResult(result)) => { + if result.is_ok() { self.document_cache = OnceCell::new(); } + result + } + Ok(PluginResponse::Error(error)) => Err(error), + Ok(other) => Err(format!("unexpected table operation response: {other:?}")), + Err(error) => Err(error.to_string()), + } + } + + fn set_selection(&mut self, handles: &[Handle]) -> Result<(), String> { + match self.client.request(PluginRequest::SetSelection { handles: handles.to_vec() }) { + Ok(PluginResponse::SelectionResult(result)) => result, + Ok(PluginResponse::Error(error)) => Err(error), + Ok(other) => Err(format!("unexpected selection response: {other:?}")), + Err(error) => Err(error.to_string()), + } + } + fn add_layer(&mut self, config: crate::host::LayerConfig) -> Option { match self.client.request(PluginRequest::AddLayer(config)) { Ok(PluginResponse::OptHandle(h)) => { diff --git a/crates/ocs_plugin_api/src/ipc/protocol.rs b/crates/ocs_plugin_api/src/ipc/protocol.rs index 710731cfc..5d86fe6b2 100644 --- a/crates/ocs_plugin_api/src/ipc/protocol.rs +++ b/crates/ocs_plugin_api/src/ipc/protocol.rs @@ -17,7 +17,7 @@ use serde::{Deserialize, Serialize}; -use crate::host::{CommandSource, CommandStep}; +use crate::host::{CommandSource, CommandStep, HostSettingValue}; use crate::manifest::ApiVersion; use crate::ribbon::owned::{OwnedPluginManifest, OwnedRibbonGroup}; @@ -81,6 +81,8 @@ pub enum HostRequest { code: String, tab_index: usize, }, + /// V7: release an interactive command after completion or cancellation. + DropInteractive { command_id: u64 }, } /// Responses the plugin runner sends back for `HostRequest`. @@ -149,6 +151,21 @@ pub enum PluginRequest { ModifyLayer(crate::host::LayerConfig), /// Run a command on the active document tab's command line (AutoLISP style). ExecuteCommand(String), + /// Read a host-managed setting without nested command dispatch. + GetSystemVariable { name: String }, + /// Change a host-managed setting without nested command dispatch. + SetSystemVariable { name: String, value: HostSettingValue }, + /// V7: validate and replace existing entities in a single undo step. + UpdateEntitiesTransaction { label: String, entities: Vec }, + /// V7: synchronous selection read/write for the dispatch tab. + GetSelection, + SetSelection { handles: Vec }, + /// V7 (additive): kernel-backed solid create or transform. + SolidOperation { operation: crate::host::SolidOperation }, + /// V7 (additive): drawing table record create/modify/rename/delete. + TableOperation { operation: crate::host::TableOperation }, + /// V7 (additive): drive an OCS command. + RunCommand { request: crate::host::CommandRequest }, } /// Responses the host sends back for `PluginRequest`. @@ -177,6 +194,14 @@ pub enum PluginResponse { DocumentPath(Option), /// Optional entity handle (e.g. from AddLayer). OptHandle(Option), + SystemVariable(Option), + SystemVariableResult(Result), + EntityTransactionResult(Result<(), String>), + Selection(Vec), + SelectionResult(Result<(), String>), + SolidResult(Result), + TableResult(Result), + CommandResult(Result), } /// Messages sent from the host to the plugin runner. diff --git a/crates/ocs_plugin_api/src/ipc/server.rs b/crates/ocs_plugin_api/src/ipc/server.rs index 8bea17852..33d1e8df6 100644 --- a/crates/ocs_plugin_api/src/ipc/server.rs +++ b/crates/ocs_plugin_api/src/ipc/server.rs @@ -3,6 +3,27 @@ use crate::host::HostApi; use crate::ipc::protocol::{PluginRequest, PluginResponse}; +fn addressable_document_snapshot(host: &dyn HostApi) -> acadrust::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()), + _ => None, + }) + .flatten() + .map(acadrust::EntityType::AttributeEntity) + .collect::>(); + for attribute in attributes { + // Attribute entities remain canonical children of INSERT in the host + // document. The IPC snapshot also indexes them by handle so plugin + // document models can address a child exactly like any other entity. + let _ = snapshot.add_entity(attribute); + } + snapshot +} + /// Apply one plugin request to the host's `HostApi` implementation. /// /// `on_start_interactive` is called when the plugin starts an interactive @@ -54,7 +75,7 @@ pub fn handle_plugin_request( on_start_interactive(command_id); PluginResponse::Ok } - DocumentSnapshot => PluginResponse::Document(Box::new(host.document().clone())), + DocumentSnapshot => PluginResponse::Document(Box::new(addressable_document_snapshot(host))), OpenDocumentView => match host.document_view() { Some(info) => PluginResponse::DocumentView { path: info.path, @@ -80,5 +101,17 @@ pub fn handle_plugin_request( AddLayer(config) => PluginResponse::OptHandle(host.add_layer(config)), ModifyLayer(config) => PluginResponse::Bool(host.modify_layer(config)), ExecuteCommand(cmd) => PluginResponse::Bool(host.execute_command(&cmd)), + GetSystemVariable { name } => PluginResponse::SystemVariable(host.system_variable(&name)), + SetSystemVariable { name, value } => { + PluginResponse::SystemVariableResult(host.set_system_variable(&name, value)) + } + UpdateEntitiesTransaction { label, entities } => PluginResponse::EntityTransactionResult( + host.update_entities_transaction(&label, entities), + ), + GetSelection => PluginResponse::Selection(host.selection()), + SetSelection { handles } => PluginResponse::SelectionResult(host.set_selection(&handles)), + SolidOperation { operation } => PluginResponse::SolidResult(host.solid_operation(operation)), + TableOperation { operation } => PluginResponse::TableResult(host.table_operation(operation)), + RunCommand { request } => PluginResponse::CommandResult(host.run_command(request)), } } diff --git a/crates/ocs_plugin_api/src/ipc/v4/client.rs b/crates/ocs_plugin_api/src/ipc/v4/client.rs index 20e3d235b..d22983e66 100644 --- a/crates/ocs_plugin_api/src/ipc/v4/client.rs +++ b/crates/ocs_plugin_api/src/ipc/v4/client.rs @@ -17,7 +17,7 @@ use interprocess::TryClone; use crate::host::{ DocumentReader, ExecutionResult, HostApi, HostNotification, InteractiveCommand, - PluginNotification, PluginRequestSender, PluginRequestError, ReaderEntity, + PluginNotification, PluginRequestSender, PluginRequestError, ReaderEntity, HostSettingValue, }; use crate::ipc::protocol::{ HostRequest, HostResponse, PluginRequest, PluginResponse, RunnerHandshake, @@ -731,6 +731,104 @@ impl HostApi for V4PluginHostApi { } } + fn system_variable(&self, name: &str) -> Option { + match self.request(PluginRequest::GetSystemVariable { name: name.to_owned() }) { + Ok(PluginResponse::SystemVariable(value)) => value, + _ => None, + } + } + + fn set_system_variable( + &mut self, + name: &str, + value: HostSettingValue, + ) -> Result { + match self.request(PluginRequest::SetSystemVariable { + name: name.to_owned(), + value, + }) { + Ok(PluginResponse::SystemVariableResult(result)) => { + if result.is_ok() { + self.document_cache = OnceCell::new(); + } + result + } + Ok(PluginResponse::Error(error)) => Err(error), + Ok(other) => Err(format!("unexpected system variable response: {other:?}")), + Err(error) => Err(error.to_string()), + } + } + + fn update_entities_transaction( + &mut self, + label: &str, + entities: Vec, + ) -> Result<(), String> { + match self.request(PluginRequest::UpdateEntitiesTransaction { + label: label.to_owned(), entities, + }) { + Ok(PluginResponse::EntityTransactionResult(result)) => { + if result.is_ok() { self.document_cache = OnceCell::new(); } + result + } + Ok(PluginResponse::Error(error)) => Err(error), + Ok(other) => Err(format!("unexpected entity transaction response: {other:?}")), + Err(error) => Err(error.to_string()), + } + } + + fn selection(&self) -> Vec { + match self.request(PluginRequest::GetSelection) { + Ok(PluginResponse::Selection(handles)) => handles, + _ => Vec::new(), + } + } + + fn solid_operation(&mut self, operation: crate::host::SolidOperation) -> Result { + match self.request(PluginRequest::SolidOperation { operation }) { + Ok(PluginResponse::SolidResult(result)) => { + if result.is_ok() { self.document_cache = OnceCell::new(); } + result + } + Ok(PluginResponse::Error(error)) => Err(error), + Ok(other) => Err(format!("unexpected solid operation response: {other:?}")), + Err(error) => Err(error.to_string()), + } + } + + fn run_command(&mut self, request: crate::host::CommandRequest) -> Result { + match self.request(PluginRequest::RunCommand { request }) { + Ok(PluginResponse::CommandResult(result)) => { + self.document_cache = OnceCell::new(); + result + } + Ok(PluginResponse::Error(error)) => Err(error), + Ok(other) => Err(format!("unexpected command response: {other:?}")), + Err(error) => Err(error.to_string()), + } + } + + fn table_operation(&mut self, operation: crate::host::TableOperation) -> Result { + match self.request(PluginRequest::TableOperation { operation }) { + Ok(PluginResponse::TableResult(result)) => { + if result.is_ok() { self.document_cache = OnceCell::new(); } + result + } + Ok(PluginResponse::Error(error)) => Err(error), + Ok(other) => Err(format!("unexpected table operation response: {other:?}")), + Err(error) => Err(error.to_string()), + } + } + + fn set_selection(&mut self, handles: &[Handle]) -> Result<(), String> { + match self.request(PluginRequest::SetSelection { handles: handles.to_vec() }) { + Ok(PluginResponse::SelectionResult(result)) => result, + Ok(PluginResponse::Error(error)) => Err(error), + Ok(other) => Err(format!("unexpected selection response: {other:?}")), + Err(error) => Err(error.to_string()), + } + } + fn add_layer(&mut self, config: crate::host::LayerConfig) -> Option { match self.request(PluginRequest::AddLayer(config)) { Ok(PluginResponse::OptHandle(h)) => { diff --git a/crates/ocs_plugin_api/src/ipc/v4/protocol.rs b/crates/ocs_plugin_api/src/ipc/v4/protocol.rs index 34e97c2e9..bbf969f41 100644 --- a/crates/ocs_plugin_api/src/ipc/v4/protocol.rs +++ b/crates/ocs_plugin_api/src/ipc/v4/protocol.rs @@ -86,6 +86,167 @@ mod tests { } } + #[test] + fn command_state_notification_roundtrips() { + let notification = HostNotification::CommandStateChanged { + tab_id: 42, + command: Some("LINE".to_owned()), + }; + let bytes = bincode::serialize(¬ification).unwrap(); + let decoded: HostNotification = bincode::deserialize(&bytes).unwrap(); + assert_eq!(decoded, notification); + let stopped = HostNotification::CommandStateChanged { tab_id: 42, command: None }; + let bytes = bincode::serialize(&stopped).unwrap(); + let decoded: HostNotification = bincode::deserialize(&bytes).unwrap(); + assert_eq!(decoded, stopped); + let changed = HostNotification::DrawingChanged { tab_id: 42, epoch: 7 }; + let bytes = bincode::serialize(&changed).unwrap(); + let decoded: HostNotification = bincode::deserialize(&bytes).unwrap(); + assert_eq!(decoded, changed); + } + + #[test] + fn command_request_and_outcome_roundtrip() { + use crate::host::{CommandOutcome, CommandRequest}; + for request in [ + CommandRequest::Run { line: "LINE 0,0 1,1".into() }, + CommandRequest::Start { name: "OFFSET".into() }, + 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::Selection, + CommandRequest::Enter, + CommandRequest::Cancel, + ] { + let bytes = bincode::serialize(&PluginRequest::RunCommand { request: request.clone() }).unwrap(); + assert!(matches!(bincode::deserialize::(&bytes).unwrap(), + PluginRequest::RunCommand { request: decoded } if decoded == request)); + } + let outcome = CommandOutcome { + status: "waiting_input".into(), + blocked_by: Some("command".into()), + command: "OFFSET".into(), + prompt: "Select object".into(), + accepts: vec!["entity".into()], + options: vec!["M".into()], + entities: 4, + added: -1, + unconsumed: vec!["x".into()], + error: Some("e".into()), + }; + for result in [Ok(outcome), Err("refused".to_owned())] { + let bytes = bincode::serialize(&PluginResponse::CommandResult(result.clone())).unwrap(); + assert!(matches!(bincode::deserialize::(&bytes).unwrap(), + PluginResponse::CommandResult(decoded) if decoded == result)); + } + } + + #[test] + fn table_operation_request_and_response_roundtrip() { + use crate::host::{LayerConfig, TableOperation}; + for operation in [ + TableOperation::LayerCreate { + config: LayerConfig { + name: "Walls".into(), + color: Some(acadrust::types::Color::Index(5)), + frozen: Some(true), + description: Some("d".into()), + ..Default::default() + }, + }, + TableOperation::LayerModify { config: LayerConfig { name: "Walls".into(), off: Some(true), ..Default::default() } }, + TableOperation::LayerRename { from: "A".into(), to: "B".into() }, + TableOperation::LayerDelete { name: "A".into(), erase_objects: true }, + TableOperation::LayerSetCurrent { name: "A".into() }, + TableOperation::TextStyleCreate { + config: crate::host::TextStyleConfig { name: "T".into(), height: Some(2.5), oblique_angle: Some(0.2), backward: Some(true), ..Default::default() }, + }, + TableOperation::TextStyleModify { config: crate::host::TextStyleConfig { name: "T".into(), font_file: Some("romans".into()), ..Default::default() } }, + TableOperation::DimStyleCreate { name: "D".into(), copy_from: Some("Standard".into()), properties: "{\"dimscale\":2}".into() }, + TableOperation::DimStyleModify { name: "D".into(), properties: "{}".into() }, + TableOperation::StyleRename { kind: crate::host::TableStyleKind::Text, from: "A".into(), to: "B".into() }, + TableOperation::StyleDelete { kind: crate::host::TableStyleKind::Dim, name: "A".into() }, + 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)], + base_point: [1.0, 2.0, 3.0], + erase_originals: true, + description: Some("d".into()), + }, + TableOperation::BlockModify { name: "B".into(), description: None, explodable: Some(false), scale_uniformly: Some(true) }, + TableOperation::BlockRename { from: "B".into(), to: "C".into() }, + TableOperation::BlockDelete { name: "C".into() }, + TableOperation::BlockEntityAdd { + block: "C".into(), + entity: acadrust::EntityType::Line(acadrust::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]) }, + TableOperation::LinetypeRename { from: "L".into(), to: "M".into() }, + TableOperation::LinetypeDelete { name: "M".into() }, + TableOperation::LayoutCreate { name: "S".into() }, + TableOperation::LayoutRename { from: "S".into(), to: "T".into() }, + TableOperation::LayoutDelete { name: "T".into() }, + TableOperation::LayoutSetCurrent { name: "Model".into() }, + TableOperation::LayoutSetPage { name: "S".into(), paper_size: Some([420.0, 297.0]), rotation: Some(90), scale: Some([1.0, 50.0]) }, + ] { + let bytes = bincode::serialize(&PluginRequest::TableOperation { operation: operation.clone() }).unwrap(); + 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())] { + let bytes = bincode::serialize(&PluginResponse::TableResult(result.clone())).unwrap(); + assert!(matches!(bincode::deserialize::(&bytes).unwrap(), + PluginResponse::TableResult(decoded) if decoded == result)); + } + } + + #[test] + fn solid_operation_request_and_response_roundtrip() { + use crate::host::{SolidOperation, SolidPrimitive}; + for operation in [ + SolidOperation::Create { + 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::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), + 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 }, + ] { + 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())] { + let bytes = bincode::serialize(&PluginResponse::SolidResult(result.clone())).unwrap(); + assert!(matches!(bincode::deserialize::(&bytes).unwrap(), + PluginResponse::SolidResult(decoded) if decoded == result)); + } + } + + #[test] + fn selection_request_and_response_roundtrip() { + let request = PluginRequest::SetSelection { handles: vec![acadrust::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)]); + let bytes = bincode::serialize(&response).unwrap(); + assert!(matches!(bincode::deserialize::(&bytes).unwrap(), + PluginResponse::Selection(handles) if handles == vec![acadrust::Handle::new(9)])); + } + #[test] fn plugin_notification_roundtrip() { let frame = PluginToHostV4::Notification(NotificationEnvelope { diff --git a/crates/ocs_plugin_api/src/lib.rs b/crates/ocs_plugin_api/src/lib.rs index 9b4309220..c55cc0342 100644 --- a/crates/ocs_plugin_api/src/lib.rs +++ b/crates/ocs_plugin_api/src/lib.rs @@ -36,6 +36,8 @@ pub mod manifest; pub mod ribbon; pub mod type_registry; pub mod type_registry_types; +pub mod entity_coverage; +pub mod entity_coverage_types; pub mod version_info; /// Runtime host surface — only built with the `host` feature (pulls `acadrust`). @@ -73,6 +75,8 @@ pub use type_registry::{ get_embedded_type_registry_json, EnumVariantInfo, FieldInfo, MethodInfo, ParameterInfo, TypeId, TypeInfo, TypeKind, TypeRegistry, }; +pub use entity_coverage::{get_embedded_entity_coverage_json, EntityCoverageCatalog, + EntityKindCoverage, EntityScope, ModelAccess, PropertyCoverage}; pub use version_info::get_embedded_version_info_json; #[cfg(test)] diff --git a/crates/ocs_plugin_api/src/manifest.rs b/crates/ocs_plugin_api/src/manifest.rs index b0e4b3dcd..8bf2ee892 100644 --- a/crates/ocs_plugin_api/src/manifest.rs +++ b/crates/ocs_plugin_api/src/manifest.rs @@ -19,8 +19,10 @@ /// vtable so API v2 plugins keep working. v4 adds full-duplex notifications on /// a multiplexed socket while leaving the V2/V3 ABI and protocol untouched. v5 /// adds the `BuiltinPlugin::on_load` lifecycle callback and -/// `HostApi::document_path`. -pub const API_VERSION: u32 = 5; +/// `HostApi::document_path`. v6 appends generic host system-variable access +/// (`HostApi::system_variable` / `set_system_variable`). v7 appends atomic, +/// undoable replacement of a set of existing entities. +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 @@ -28,7 +30,7 @@ pub const API_VERSION: u32 = 5; pub const API_VERSION_MIN_SUPPORTED: u32 = 2; /// Environment variable that caps the API major accepted by the host at -/// runtime. Set to `4` to disable V5 plugins, `3` to disable V4, or `2` for +/// runtime. Set to `5` to disable V6 plugins, `4` to disable V5, or `2` for /// V2-only mode. pub const MAX_API_VERSION_ENV: &str = "OCS_PLUGIN_MAX_API_VERSION"; diff --git a/crates/ocs_plugin_api/src/process.rs b/crates/ocs_plugin_api/src/process.rs index 7ac7ae608..5f8086a08 100644 --- a/crates/ocs_plugin_api/src/process.rs +++ b/crates/ocs_plugin_api/src/process.rs @@ -237,6 +237,7 @@ pub(crate) fn request_kind(req: &HostRequest) -> &'static str { HostRequest::GetPrompt { .. } => "GetPrompt", HostRequest::NeedsEntityPick { .. } => "NeedsEntityPick", HostRequest::ExecuteCode { .. } => "ExecuteCode", + HostRequest::DropInteractive { .. } => "DropInteractive", HostRequest::Shutdown => "Shutdown", } } @@ -621,6 +622,15 @@ impl PluginProcess { } } + /// Release a completed or abandoned V4 interactive command in the runner. + pub fn drop_interactive(&self, command_id: u64) -> Result<(), PluginError> { + let Some(v4) = &self.v4 else { return Ok(()); }; + match v4.call(&mut NullHost, HostRequest::DropInteractive { command_id }, &mut |_| {})? { + HostResponse::Bool(true) => Ok(()), + other => Err(PluginError::UnexpectedResponse(Box::new(other))), + } + } + /// Ask the plugin process for the current prompt of an interactive command. pub fn get_prompt(&self, command_id: u64) -> Result { if let Some(v4) = &self.v4 { diff --git a/crates/ocs_plugin_api/src/process/v4.rs b/crates/ocs_plugin_api/src/process/v4.rs index 102255ca4..dfcd7b4a1 100644 --- a/crates/ocs_plugin_api/src/process/v4.rs +++ b/crates/ocs_plugin_api/src/process/v4.rs @@ -16,8 +16,7 @@ use crate::ipc::v4::protocol::{HostToPluginV4, NotificationEnvelope}; use crate::ipc::v4::server::{ default_notify_rate_limit, run_host_reader_thread, HostIncoming, RateLimiter, V4HostShared, }; -use crate::process::PluginError; -use crate::process::{request_kind, request_timeout}; +use crate::process::{request_kind, request_timeout, PluginError}; type DeferredRequest = (u64, Option, Box); @@ -387,16 +386,21 @@ mod tests { struct DummyHost { push_info_messages: StdMutex>, + transactions: StdMutex)>>, } impl DummyHost { fn new() -> Self { Self { push_info_messages: StdMutex::new(Vec::new()), + transactions: StdMutex::new(Vec::new()), } } fn take_push_info(&self) -> Vec { std::mem::take(&mut *self.push_info_messages.lock().unwrap()) } + fn take_transactions(&self) -> Vec<(String, Vec)> { + std::mem::take(&mut *self.transactions.lock().unwrap()) + } } impl HostApi for DummyHost { fn tab_index(&self) -> usize { @@ -433,6 +437,14 @@ mod tests { false } fn push_undo(&mut self, _label: &str) {} + fn update_entities_transaction( + &mut self, + label: &str, + entities: Vec, + ) -> Result<(), String> { + self.transactions.lock().unwrap().push((label.to_owned(), entities)); + Ok(()) + } fn set_dirty(&mut self) {} fn push_info(&mut self, msg: &str) { self.push_info_messages.lock().unwrap().push(msg.to_string()); @@ -604,6 +616,49 @@ mod tests { restore_test_env(); } + #[test] + fn v4_connection_routes_entity_transaction_over_nested_ipc() { + let _guard = ENV_LOCK.lock().unwrap(); + set_test_env(); + let (host_stream, mut runner_stream) = connect_pair(); + let handler: Arc, PluginNotification) + Send + Sync> = + Arc::new(|_, _| {}); + let conn = V4Connection::new(host_stream, handler).unwrap(); + let mut point = acadrust::entities::Point::new(); + point.common.handle = Handle::new(42); + point.location.x = 7.0; + let expected = acadrust::EntityType::Point(point); + let sent = expected.clone(); + + let runner = thread::spawn(move || { + let req = recv::(&mut runner_stream).unwrap(); + let HostToPluginV4::Request { id, payload: HostRequest::Dispatch { .. } } = req else { + panic!("unexpected host request: {req:?}"); + }; + send(&mut runner_stream, &PluginToHostV4::Request { + id: 99, + tab_id: Some(0), + payload: PluginRequest::UpdateEntitiesTransaction { + label: "Move point".into(), + entities: vec![sent], + }, + }).unwrap(); + assert!(matches!(recv::(&mut runner_stream).unwrap(), + HostToPluginV4::Response { id: 99, payload: PluginResponse::EntityTransactionResult(Ok(())) })); + send(&mut runner_stream, &PluginToHostV4::Response { + id, + payload: HostResponse::Bool(true), + }).unwrap(); + }); + + let mut host = DummyHost::new(); + assert!(matches!(conn.call(&mut host, HostRequest::Dispatch { cmd: "MOVE".into() }, &mut |_| {}).unwrap(), + HostResponse::Bool(true))); + assert_eq!(host.take_transactions(), vec![("Move point".into(), vec![expected])]); + runner.join().unwrap(); + restore_test_env(); + } + #[test] fn v4_connection_notification_invokes_handler() { let _guard = ENV_LOCK.lock().unwrap(); diff --git a/crates/ocs_plugin_api/src/runner.rs b/crates/ocs_plugin_api/src/runner.rs index 296f70e7d..1c7627f9d 100644 --- a/crates/ocs_plugin_api/src/runner.rs +++ b/crates/ocs_plugin_api/src/runner.rs @@ -211,6 +211,10 @@ fn handle_host_request( Err(_) => HostResponse::Error("interactive command panicked".to_string()), } } + HostRequest::DropInteractive { command_id } => { + interactive.borrow_mut().remove(&command_id); + HostResponse::Bool(true) + } HostRequest::GetPrompt { command_id } => { let result = { let registry = interactive.borrow(); @@ -330,6 +334,10 @@ fn handle_host_request_v4( Err(_) => Some(HostResponse::Error("interactive command panicked".to_string())), } } + HostRequest::DropInteractive { command_id } => { + interactive.borrow_mut().remove(&command_id); + Some(HostResponse::Bool(true)) + } HostRequest::GetPrompt { command_id } => { let result = { let registry = interactive.borrow(); diff --git a/crates/ocs_plugin_api/src/shm.rs b/crates/ocs_plugin_api/src/shm.rs index 4203ce406..5344737da 100644 --- a/crates/ocs_plugin_api/src/shm.rs +++ b/crates/ocs_plugin_api/src/shm.rs @@ -83,13 +83,19 @@ impl DocumentSnapshotStore { /// page. pub fn new(tab_id: u64, segment_size: usize) -> io::Result { let segment_size = segment_size.checked_next_multiple_of(4096).ok_or_else(|| { - io::Error::new(io::ErrorKind::InvalidInput, "snapshot segment size overflow") + io::Error::new( + io::ErrorKind::InvalidInput, + "snapshot segment size overflow", + ) })?; let total = segment_size .checked_mul(2) .and_then(|segments| CONTROL_SIZE.checked_add(segments)) .ok_or_else(|| { - io::Error::new(io::ErrorKind::InvalidInput, "snapshot mapping size overflow") + io::Error::new( + io::ErrorKind::InvalidInput, + "snapshot mapping size overflow", + ) })?; if total > MAX_SNAPSHOT_SIZE { return Err(io::Error::new( @@ -245,7 +251,10 @@ impl SharedDocumentReader { )); } let file_len = usize::try_from(file_len).map_err(|_| { - io::Error::new(io::ErrorKind::InvalidData, "snapshot file size is unsupported") + io::Error::new( + io::ErrorKind::InvalidData, + "snapshot file size is unsupported", + ) })?; let mmap = unsafe { MmapOptions::new().len(file_len).map(&file)? }; let segment_size = (file_len - CONTROL_SIZE) / 2; @@ -543,10 +552,22 @@ pub struct DocumentViewDataV4 { impl From<&CadDocument> for DocumentViewDataV4 { fn from(doc: &CadDocument) -> Self { + let mut entities: Vec<_> = doc.entities().map(EntityViewV4::from).collect(); + // ATTRIB records live inside their owning INSERT in CadDocument rather + // than in the flat entity index. Flatten them into the plugin snapshot + // as addressable entities while keeping the INSERT's canonical nested + // copy intact for rendering and file serialization. + for entity in doc.entities() { + if let EntityType::Insert(insert) = entity { + entities.extend(insert.attributes.iter().map(|attribute| { + EntityViewV4::from(&EntityType::AttributeEntity(attribute.clone())) + })); + } + } Self { layers: doc.layers.iter().map(LayerView::from).collect(), app_ids: doc.app_ids.iter().map(AppIdView::from).collect(), - entities: doc.entities().map(EntityViewV4::from).collect(), + entities, } } } @@ -721,7 +742,10 @@ mod tests { let archived = reader.archived().unwrap(); let entity = &archived.entities[0]; - assert_eq!(entity.handle, doc.entities().next().unwrap().common().handle.value()); + assert_eq!( + entity.handle, + doc.entities().next().unwrap().common().handle.value() + ); let decoded: EntityType = bincode::deserialize(&entity.data).expect("bincode decode"); assert!(matches!(decoded, EntityType::Point(_))); } diff --git a/docs/cadcodec-reader-gaps.md b/docs/cadcodec-reader-gaps.md new file mode 100644 index 000000000..7e2f45406 --- /dev/null +++ b/docs/cadcodec-reader-gaps.md @@ -0,0 +1,220 @@ +# cadcodec DXF reader/writer gaps found by the OCS Python host audit + +Reports on `HakanSeven12/cadcodec` (the `acadrust` crate). **Status (21 September +2026):** fixes for issues 1-6 below, with regression tests, are submitted as +[HakanSeven12/cadcodec#48](https://github.com/HakanSeven12/cadcodec/pull/48) (branch +`fix/dxf-reader-writer-roundtrip` on `felixriestra/cadcodec`); the five findings under +"Observed but not filed" and the style and block ones added later are not filed. Every finding was made against **cadcodec revision +`5b682ed`** (OCS pins `acadrust` to it) by saving a document with +`DxfWriter` or `DwgWriter`, reloading it, and comparing entity fields. + +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 +fixed; flip the marked expectation when that happens. To reproduce, from the +OCS repository: + +```sh +STAGE="$PWD/work/bundled-plugins/opencad.python" +bash plugins/opencad-python/tools/stage-bundled.sh "$STAGE" --debug +cargo build --bin OpenCADStudio +OCS_TEST_PYTHON_PLUGIN="$STAGE/libopencad_python.dylib" \ +OCS_PLUGIN_RUNNER_EXE="$PWD/target/debug/OpenCADStudio" \ +cargo test --lib app::plugin_host::tests:: -- --test-threads=1 +``` + +| # | Area | Severity | Effect | +|---|---|---|---| +| 1 | `ATTDEF` reader | high | Most attribute-definition properties silently reset to defaults | +| 2 | Boolean group codes (290-299) | high | Booleans read through `as_i16()` are never applied | +| 3 | `UNDERLAY` rotation | high | Rotation is not converted back to radians; repeated saves compound it | +| 4 | `LEADER` | medium | Annotation link and three vectors are lost | +| 5 | `DRAWINGVIEW` / `SECTIONLINE` | medium | Typed entity not restored from the ENTITIES section | +| 6 | `TABLE` merged ranges | low | `merged_ranges` is empty after DXF reopen | + +--- + +## 1. ATTDEF reader handles eight group codes; the writer emits all of them + +**Where:** `src/io/dxf/reader/section_reader.rs`, `read_attdef` (around line +15647). The writer, `write_attdef` in `writer/section_writer.rs` (around line +4837), emits `7, 11, 41, 51, 70, 71, 72, 73, 74, 210` and `280` in addition to +the groups the reader consumes. + +**What happens:** the reader has arms for groups `1, 2, 3, 10/20/30, 40, 50, +280` and `101` only; everything else falls through to `_ =>`. After a DXF round +trip these fields reset to defaults: + +- text style name (7) +- alignment point (11/21/31) and both alignments (72, 74) +- width factor (41) and oblique angle (51) +- attribute flags (70): invisible, constant, verify, preset +- text generation flags (71) and field length (73) +- normal (210/220/230) and the position-lock state + +Tag, prompt, default value, insertion point, height and rotation survive. +Losing the attribute flags is the worst part: an invisible or constant +attribute becomes visible and editable after a DXF save. + +**Evidence:** `audit_python_attribute_definition_lifecycle_over_real_ipc` +compares every writable field through both formats. DWG round-trips the full +state; the DXF digest is pinned to the defaults (see the `BLOCKER` comment on +`expect_edited_dxf`). + +**Suggested fix:** add reader arms for the missing groups, using the same +conversions the writer applies (rotation and oblique angle are written in +degrees). `TEXT` and `ATTRIB` already read most of these groups and can share +the implementation. + +## 2. Boolean group codes are read with `as_i16()` and never applied + +**Where:** `src/io/dxf/reader/stream_reader.rs` defines +`DxfCodePair::as_i16()` as `Some` only for `CodePairValue::Int`. Groups 290-299 +are typed `Bool`, so `as_i16()` returns `None` for them and +`unwrap_or(0) != 0` yields `false`. The pattern appears in many arms of +`section_reader.rs`. + +**Confirmed impact (round-trip tested):** + +- `HELIX` group 290 (`handedness`): a clockwise helix reopens counter-clockwise. + The reader arm is `290 => { if let Some(v) = pair.as_i16() { ... } }` + (around line 12796). +- `LIGHT` groups 291, 292, 293 (`plot_glyph`, `use_attenuation_limits`, + `cast_shadows`; around lines 18154-18182), and 290 for the photometric web + file flag: `cast_shadows` reopens `false`. + +**Same pattern, not individually tested** (line numbers in `section_reader.rs`): +13833, 13955-13969 (point-cloud style flags); 18194 (`has_web_file`); +18431-18438 and 18493-18494 (sweep, loft and revolve option flags); and several +`if let Some(v) = pair.as_i16()` arms on 290/292 near lines 9815, 10096, 10478 +and 12861. Some other arms already use `as_bool()` correctly (for example lines +18376 and 18550), so the fix is mechanical. + +**Evidence:** `audit_python_helix_lifecycle_over_real_ipc` (canary +`expect_edited_dxf`) and `audit_python_light_lifecycle_over_real_ipc`. + +**Suggested fix:** read these groups with a helper that accepts either +representation, for example +`pair.as_bool().or_else(|| pair.as_i16().map(|v| v != 0))`, and audit every arm +whose code is in 290-299. Alternatively make `as_i16()` accept `Bool` values. + +## 3. `UNDERLAY` rotation is written in degrees and read back unconverted + +**Where:** `write_underlay` writes group 50 with `underlay.rotation.to_degrees()` +(around line 11038 of `writer/section_writer.rs`). `read_underlay` stores the +raw value: `50 => { if let Some(v) = pair.as_double() { underlay.rotation = v; } }` +(`reader/section_reader.rs`, around line 19470), with no `to_radians()`. + +**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. + +**Evidence:** `audit_python_underlay_lifecycle_over_real_ipc`; the canary +`expect_edited_dxf` / `expect_reedited_dxf` records 28.6 then 1641.4. + +**Suggested fix:** `underlay.rotation = v.to_radians()` in the reader. Check +that `DGN`/`DWF`/`PDF` underlays share the code path. + +## 4. `LEADER`: annotation link and vectors are lost in DXF + +**a. The writer never emits group 340.** `write_leader` writes 3, 71-76, 10, 210, +211, 212 and 213 but no associated-annotation handle, while the reader has a +`340 =>` arm that fills `annotation_handle` (around line 16619). A DXF save +therefore unlinks every leader from its text, tolerance or block annotation. + +**b. The coordinate mapping does not reassemble 211, 212 and 213.** +`GroupCodeValueType::coordinate_axis` in `src/io/dxf/group_code_value.rs` maps +X to `10..=18 | 110..=112 | 210 | 1010..=1013` (and the Y/Z equivalents), so +`add_coordinate` ignores the 211/221/231, 212/222/232 and 213/223/233 series. +`read_leader` uses a `PointReader` for `horizontal_direction`, `block_offset` +and `annotation_offset`, so all three reopen as their defaults even though the +writer emits them. (`normal`, group 210, works.) + +**c. `override_color` is not persisted** in DXF or DWG: a leader coloured by +`override_color` reopens `ByLayer`. Observed, cause not traced. + +**Evidence:** the canaries in `staged_python_leader_lifecycle_over_real_ipc` +(search for `BLOCKER`). DWG keeps the annotation link, offset and arrow flag. + +**Suggested fix:** write group 340 for a non-null `annotation_handle`; extend +`coordinate_axis`/`coordinate_group` to cover the 211-213 series (or read those +groups explicitly); decide where the override colour belongs (group 62/420 on +the leader). + +## 5. `DRAWINGVIEW` and `SECTIONLINE` are dispatched only inside blocks + +**Where:** `section_reader.rs` has two entity dispatch tables: one for block +contents (around lines 3241-3390) and one for the ENTITIES section +(`read_entities`, from line 3482). `"DRAWINGVIEW"` (line 3386) and +`"SECTIONLINE"` (line 3382) appear only in the first; the ENTITIES table has no +arm for them. `write_view_border_dxf` writes `DRAWINGVIEW` correctly. + +**What happens:** a `ViewBorder` saved to DXF in the drawing's entity list +reopens as a different, opaque entity kind instead of `ViewBorder`. DWG restores +the typed record. The same applies to `SectionSymbol`. + +**Evidence:** `audit_python_view_border_lifecycle_over_real_ipc` (canary +`expect_edited_dxf: "wrong kind"`) and `audit_python_section_symbol_lifecycle_over_real_ipc` +(canary `wrong kind Unknown`). + +**Suggested fix:** add the two arms to the ENTITIES dispatch, calling +`read_view_border_dxf` and `read_section_symbol_dxf`. + +## 6. `TABLE` `merged_ranges` is empty after a DXF reopen + +**What happens:** the writer emits the merged-range list (`AcDbFormattedTableData`, +group 90 then 91-94 per range; near line 10216 of `writer/section_writer.rs`), +and `reader/section_reader/table_content.rs` (around lines 627-690) appears to +parse it, yet `Table::merged_ranges` is empty after a DXF reopen. The merge +itself survives through the origin cell's `merge_width` and `merge_height` +(groups 175 and 176), and DWG restores `merged_ranges` but not the per-cell +dimensions. + +**Impact:** low, because the merge is preserved in the cell fields; consumers +that read `merged_ranges` directly see nothing. Cause not traced; it may be that +the entity path does not call the table-content parser. + +**Evidence:** `staged_python_table_lifecycle_over_real_ipc` (the DXF branch +asserts an empty `merged_ranges` and marks it as a blocker). + +**Suggested fix:** rebuild `merged_ranges` from the cell merge dimensions after +reading, or make sure the `AcDbFormattedTableData` block is parsed for entities. + +--- + +## Observed but not filed + +These are format constraints or OCS-side issues, not reader bugs: + +- **DWG R2010+ does not store** a leader's `text_height`, `text_width` or + `hookline_enabled`, or a 3-D polyline's `elevation`; the writer stores them + only for the older versions that define them. +- **Entity-level `MultiLeader.text_height`** is not stored by either writer + (`context.text_height` is), so OCS exposes it read-only. +- **Legacy `Polyline` reopens as `Polyline3D`** from both formats and a 2-D + polyline reopens as `LwPolyline` from DXF. These look like deliberate + normalization; OCS made the legacy kind update-only and records the DXF + conversion. +- **Spline weights in DXF** were briefly suspected but are correct: the writer + emits group 41 only when `flags.rational` is set, so the host must set that + flag when weights are present (fixed in OCS). + +### 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) +(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. + 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). +- **`TextStyle::true_type_font`** is never written or read by either codec, so it lives only in memory. +- **`BLOCK_RECORD` description:** the DXF codec does not carry `BlockRecord::description` + (found by `audit_python_blocks_over_real_ipc`, pinned by a canary); DWG keeps it. Not filed, for + the same reason as `true_type_font`. + diff --git a/docs/cadkernel-body-path.md b/docs/cadkernel-body-path.md new file mode 100644 index 000000000..312e36960 --- /dev/null +++ b/docs/cadkernel-body-path.md @@ -0,0 +1,200 @@ +# Scripting ACIS bodies through cadkernel: 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 +neither produce a valid ACIS payload nor edit one, and that the handover +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. + +**Conclusion:** the blocker was overstated. OCS already owns a kernel-backed +create/transform/boolean pipeline whose output is verified lossless before it is +accepted. The missing piece is not geometry but an *operation channel* from a +script to that pipeline. Solid3D and Body are then straightforward; Region and +Surface need a little more; the one real cost is boolean speed. + +## What exists + +| Capability | Where | Notes | +|---|---|---| +| Primitives: box, wedge, cylinder, cone/frustum, elliptical cylinder, sphere, torus, pyramid, pyramid frustum | `scene/model/solid_model.rs` (`box_solid`, ...) | return a kernel `Body` | +| Transforms: place, turn about an axis, mirror, column-major 4x4 | `placed`, `turned`, `mirrored`, `by_matrix` | analytic surfaces move with their frames, no point rewriting | +| Booleans: union, subtract, intersect | `boolean_result` | refuses with a reason instead of returning a broken solid | +| Body to entity payload | `acis_export::solid_to_sat` | **self-verifying**: it lifts its own SAT text and SAB binary back and returns `None` unless both are lossless (`loss.is_empty()`) and validate | +| Entity payload to body | `solid3d_tess::kernel_body` / `kernel_region_body` / `kernel_surface_body` / `kernel_acis_body` | returns `None` when the lift is lossy or has more than one body | +| Regenerating derived data | `app/model_ops.rs::entity_with_boolean_body` | rewrites `acis_data`, `wires`, clears `silhouettes` and `history_handle` for Solid3D, Region and Surface | +| Volume, extent, edge wires | `volume`, `extent`, `edge_wires` | used as oracles below | + +`kernel_acis_body` is exactly the "proven lossless path" the handover asked for: +a payload is edited only if it lifts with no loss, otherwise it is left alone. + +## Spike results + +Everything below passed, with the kernel's own volume as the oracle. + +| Test | Result | +|---|---| +| Box 10x6x4 to Solid3D | volume 240, 12 edge wires, lifts back | +| DWG save/reopen | lifts back, volume 240, 12 wires kept. `acis_data` changes from SAT text to **SAB binary** (same solid, different bytes) | +| DXF save/reopen | lifts back, volume 240. Cached `wires` are dropped (0) and are regenerated on demand; silhouettes were already empty | +| Translate by (5,5,5) | volume 240, extent shifted exactly, lifts back | +| Rotate 45 degrees about Z | volume 240, extent -5.657..5.657, lifts back | +| Mirror in X | volume 240, lifts back | +| Box union cylinder | volume 315.277, 16 wires, lifts back | +| Box subtract cylinder | volume 189.815, 14 wires, lifts back | +| Box intersect cylinder | volume 50.185, 2 wires, lifts back | +| Sphere, torus, wedge, pyramid | volumes 112.696, 98.415, 12.000, 28.532; all lift back | +| Same payload in a `Body` entity | lifts back; still lifts back after DWG and after DXF | + +Timings (debug build): primitive, transform, `solid_to_sat` and `edge_wires` are +effectively instant (under 0.05 s). **Each boolean took about 23 s**, most likely +a debug-build artifact for a kernel doing heavy numeric work, but it has not been +measured in release and must be before booleans are exposed. + +Two things to understand about "lossless": + +- DWG rewrites SAT text as SAB binary. The solid is identical (same volume, + topology, faces) but the bytes are not, so the honest oracle is "lifts back with + no loss and the same volume", not byte equality. An untouched body that a + script never edits is written exactly as OCS writes any other body. +- DXF does not carry the cached wires; they are derived data. + +## Design + +The obstacle is the channel. The generic entity path moves an acadrust +`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: + +**A. Host-side normalization with a request carrier (no ABI change).** A +hand-written converter (as for Dimension) turns `create_entity('Solid3D', +primitive='box', ...)` into a Solid3D that carries the request in an otherwise +unused field, and the host's `normalize_scripted_entity` replaces it with the real +payload, exactly as it already does for MLine geometry, Helix splines and image +definitions. It works inside today's v7 protocol, but the carrier field is a hack. + +**B. New host operation (recommended).** Add one additive operation to `HostApi` +and the v4 IPC protocol, for example `solid_operation(op) -> Result`, +with `op` one of: create primitive, transform, boolean, and (later) region from +curves. The host runs it through the pipeline above inside an undo step and +returns the new or updated handle. Because it is a new method with a default +"unsupported" implementation it is backward compatible; bump the minor/negotiate +by capability rather than the major version. Python side: + +```python +box = doc.solids.box(center=(0, 0, 0), size=(10, 6, 4)) +cyl = doc.solids.cylinder(base=(0, 0, -3), radius=2, height=10) +hole = box.subtract(cyl) # new Solid3D; operands untouched +box.transform(translate=(5, 5, 5)) # in-place, transactional, undoable +``` + +**C. Do nothing but layer edits** (the current state). + +Recommended: **B**, with `solid_operation` covering create, transform and boolean. +Every operation goes through `solid_to_sat` (self-verifying) and the kernel's own +refusal path, so a failure is an error before any mutation, and an untouched +payload is never rewritten. In-place edits of an existing entity are gated by +`kernel_acis_body` returning `Some`: a payload the kernel cannot lift losslessly +is refused with a clear message instead of being rewritten. + +## Per-kind feasibility + +| Kind | Create | Edit | Notes | +|---|---|---|---| +| **Solid3D** | 9 primitives, boolean of two solids | transform, boolean | Straightforward. Regenerate `wires`; clear `silhouettes`; `history_handle` is cleared by `entity_with_boolean_body`, so a history-carrying solid needs a decision (refuse, or accept losing parametric history). | +| **Body** | same payload container | transform | Same pipeline, `kernel_acis_body` works on a `Body` payload (spike). A true non-solid (sheet or wire) body may not lift; those stay layer-only. | +| **Region** | from closed curve entities (the REGION command's `add_region_model`) | transform | Needs a "region from these curve handles" operation and planar-face construction. | +| **Surface** | plane, extruded, revolved from a profile | transform | The most involved: surface kinds carry `surface_data` besides `acis_data`, and only `Plane` keeps its kind on regeneration (`SurfaceKind::Generic` otherwise). | + +## Risks and open questions + +1. **Boolean speed.** Measured in release, see "Boolean timings" below: about + 1 to 2.2 s for a box and a cylinder, 0.2 s for two spheres and 1 to 2 ms for + planar-only operands, against 23 s in a debug build. The kernel also refuses + many curved cases, which matters more than speed. +2. **Interoperability cannot be verified here.** `solid_to_sat` is verified by + OCS's own lifter. Whether AutoCAD accepts the SAT/SAB that OCS writes is not + testable without AutoCAD, so completion should be scoped as "OCS round trip", + as for RasterImage. +3. **Parametric history.** Regenerating a payload clears `history_handle`. +4. **Frames.** Some solids carry a body-to-world transform in the SAT + (`body_transform` in `kernel_acis_body`); the lift already applies it, and the + spike's translated/rotated bodies confirm it, but a foreign DWG with a + scaled or sheared body transform should be added as a fixture. +5. **API surface.** `solid_operation` is a protocol addition; decide whether it is + one generic operation enum or separate methods before starting. + +## Suggested phases + +1. `solid_operation` protocol and host executor for **create primitive** and + **transform** on Solid3D, with the full lifecycle test through the real runner + (volume oracle, DWG and DXF reopen, undo, refusal of a lossy payload). +2. **Booleans** (after release-build timing) and the **Body** container. +3. **Region** from curve handles. +4. **Surface** (plane first). + +Phase 1 alone would move Solid3D from blocked to complete-in-OCS. + +**Status (2026-09-20): phase 1 is done.** `HostApi::solid_operation` (create primitive and rigid transform), the `doc.solids` Python API and `audit_python_solid3d_lifecycle_over_real_ipc` are in; Solid3D is complete, Body moves through the same channel. Design B was implemented as recommended. Region creation from a closed profile followed (`RegionFromProfile`). Plane surfaces and extrusions (`SurfaceFromProfile`, `Extrude`) followed. Only booleans remain, plus lofted, revolved, swept and NURBS surfaces. + +## 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 +`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 +that revision and time `brep::combine` on `brep::make::{cuboid, cylinder, +sphere, torus}` operands. + +| Operation | Time | Result | +|---|---|---| +| box union / subtract / intersect cylinder (10x6x4 box, r=2 cylinder) | 1.05 to 1.08 s each, identical on repeat runs | ok | +| same, operands scaled 10x and 100x | 2.21 s and 2.21 s | ok | +| box union / subtract box (planar only) | 0.001 to 0.002 s | ok | +| sphere union sphere (overlapping) | 0.22 s | ok | +| sphere subtract sphere (overlapping) | 0.23 s | refused `CutRefused` | +| box subtract sphere (sphere cutting the box faces) | 1.28 s | refused `Coincident` | +| sphere union torus (disjoint) | under 1 ms | refused `NoClosedForm` | +| torus subtract cylinder through the ring | under 1 ms | refused `NoClosedForm` | +| drill a first cylinder hole in a box | 0.03 s | ok | +| drill a second hole in the drilled box | under 1 ms | refused `CutRefused` | + +What this means: + +- **Speed is acceptable.** A curved boolean costs 0.2 to 2.2 s, the same cost as + the GUI BOOLEAN command, and planar work is instant. The 23 s seen earlier was + a debug-build artifact. +- **It blocks the host thread.** The plugin's `PY_RUN` is a synchronous dispatch + and nested host requests run inline, so a script freezes the UI while a + boolean runs. One to two seconds per operation is tolerable; a script with + dozens is not. +- **The whole script has a budget.** A `Dispatch` call uses the 30 s default + timeout (`OCS_PLUGIN_CALL_TIMEOUT_SECS`, with a 10 s floor), and nested host + work counts against one deadline. Roughly fifteen curved booleans in a single + script would exceed it. +- **Refusals are the real limit.** The kernel says no often: coincident faces, a + cut it has no closed form for, torus operands, and, surprisingly, a second + cut in a body that was already drilled. Refusals are fast and leave the inputs + intact, which is the behavior the API already relies on, but it means booleans + will be a partial feature that fails with a reason, not a general one. +- **Timing is not proportional to size.** Scaling the operands 10x doubled the + time, 100x changed nothing more, so the cost is dominated by the surface + intersection setup rather than the geometry size. + +### Recommendation + +Add `SolidOperation::Boolean { first, second, operation, layer }` and run it +synchronously on the host thread, surfacing the kernel's refusal (`Coincident`, +`CutRefused`, `NoClosedForm`, ...) verbatim in the error and leaving both +operands untouched on failure. Build the result with the existing +`entity_with_boolean_body` path so it inherits the lossless verification. +Test it with **planar** operands (millisecond cost, safe in debug) plus one +`#[ignore]` curved case that documents the release-only runtime. Document the +30 s script budget and that chained cuts on a drilled body are often refused. +Do not move booleans to a background thread yet: it would need cancellation and +undo coordination for a cost that is one to two seconds. + +**Status (2026-09-20): implemented.** `SolidOperation::Boolean` and `doc.solids.union`, `subtract` and `intersect` follow the recommendation above, with planar-operand lifecycle testing and an ignored curved refusal test. Every operation this note proposed is now built. diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md index f046c3f3b..439c41d37 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -9,12 +9,12 @@ with Open CAD Studio. The model follows [QGIS](https://plugins.qgis.org/)-style extensibility: a small metadata file, a single entry point, an optional separate engine crate, and user-installable packages from a curated index. -> **Open CAD Studio ships no built-in plugins.** Every add-on is an **external -> dynamic library** (`cdylib`) the host loads at runtime from the user plugins -> folder. The host source only contains the generic plugin *runtime* +> Plugins are **out-of-process dynamic libraries** (`cdylib`). The host loads +> first-party packages from its application resources and third-party packages +> from the user plugins folder. Both use the generic runtime > (`src/plugin/`, `src/app/plugin_host.rs`) and the stable contract crate -> (`crates/ocs_plugin_api`). Add-ons live in their own repositories and -> **consume** that contract. +> (`crates/ocs_plugin_api`). A user package with the same id explicitly +> overrides a bundled package. --- @@ -24,7 +24,7 @@ engine crate, and user-installable packages from a curated index. |------|-----------| | **One package, one entry point** | Manifest, ribbon tab and commands ship together in the plugin crate; no edits to the host. | | **Stable contract** | Authors target the semver-versioned `ocs_plugin_api` crate, not `OpenCADStudio` internals. | -| **Out-of-tree by default** | A plugin is its own repo + crate; the host never recompiles to gain one. | +| **Out-of-tree by default** | Third-party plugins remain independent; selected first-party plugins may build and ship with OCS. | | **DWG round-trip** | Domain data lives on entities as XDATA, not in an opaque side database. | | **Engine reuse** | A headless `std`-only engine crate can run in WASM/CLI without the CAD host. | diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md new file mode 100644 index 000000000..e56953166 --- /dev/null +++ b/docs/plugin-host-model-completion-plan.md @@ -0,0 +1,341 @@ +# OCS canvas entity completion plan + +## Goal and current state + +Build a general Python document model over the OCS host API so a script can control OCS: read and change every canvas entity, manage the drawing's tables, and run OCS's own commands. Keep a property-level record of read/write, read-only, snapshot-only, and unsupported fields in the [43-kind coverage ledger](plugin-host-model-coverage-ledger.md). The architecture and guarantees are in [plugin-host-model.md](plugin-host-model.md); the progress log at the end of this file records each increment with its evidence. Do not resume the separate Lisp-to-Python conversion work. + +**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. +- **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. + +Repositories: + +- Bundled Python adapter: `plugins/opencad-python` +- Host coverage policy: `crates/ocs_plugin_api/entity_coverage_policy.json` +- Host coverage/validation: `crates/ocs_plugin_api/src/entity_coverage.rs` and `build.rs` +- Adapter mapping: `plugins/opencad-python/entity_manifest.json`, + `plugins/opencad-python/build/generate.rs`, + `plugins/opencad-python/src/ocs_module.rs`, and + `plugins/opencad-python/src/document_model.py` +- Host design notes: `docs/plugin-host-model.md` + +Host and adapter changes live in the same repository and are committed together. + +## First: complete the originally agreed Phase 8 + +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. +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. + +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 +the specific failure and leave Phase 8 open. + +## Per-kind completion gate + +Use one checklist row per kind in a coverage ledger. Status values: `unstarted`, `mapped`, `integration-tested`, `complete`, or `blocked`; record the blocking dependency. **Do not use “complete” for read-only, update-only, or layer-only support.** For every kind: + +1. **Declare coverage:** Every traced field has an accurate catalog status and validation rule. Distinguish ordinary properties from handles, owners, linked records, external assets, and opaque payloads. Snapshot-only fields remain explicitly unmapped. Assert adapter keys match the host catalog. +2. **Create:** A Python script creates a valid entity through the document model in a real document with required tables, styles, definitions, files, and references. Missing or invalid required inputs fail before mutation. Add document-model creation/deletion methods if needed; current `doc.entities` only indexes and iterates, while low-level `ocs.add`/remove APIs exist. +3. **Read and edit:** After creation, read the entity by handle through `doc.entities`; edit at least one defining geometry or placement field and one other meaningful writable field inside `doc.transaction`. Verify that all unmentioned fields, handles, ownership, attached records, and XDATA remain unchanged. Check invalid values and forbidden reference edits fail atomically. +4. **Delete and history:** Delete the created entity through the Python interface. Prove create, edit, and delete have defined undo/redo behavior in the host, including grouped writes and failure rollback. Record any transaction API extension and bump the host API version if its ABI changes; keep older plugin compatibility. +5. **Real IPC and canvas:** Run the above through the staged plugin and actual OCS IPC. Verify visibility/geometry in the canvas and selection or picking in the GUI. For nonvisual entities, verify their observable document or layout behavior and explain the oracle. +6. **Persistence:** Save/reopen DWG **and** DXF fixtures. Compare kind, geometry, important metadata, handles/references, and preserved fields after reopen; edit again after reopen. Test at least one valid edge case for that kind. Do not count a JSON or in-memory round trip as file persistence. +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. + +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. + +## Existing mapped kinds: audit before claiming completion + +Audit these against the same gate: `Point`, `Line`, `Circle`, `Arc`, `Ellipse`, `Polyline`, `Polyline2D`, `Polyline3D`, `LwPolyline`, `Spline`, `Text`, `MText`, `Ray`, `XLine`, `Solid`, `Face3D`, `Insert`. `Insert` currently has transform edits but **no Python creation**; its block name is read-only and attached attributes are snapshot-only. Earlier tests include some direct DWG round trips, but there is no evidence that every mapped kind passes real IPC, GUI, deletion, undo/redo, and both file formats. Mark each independently after testing. + +## Ordered 26-kind expansion queue + +The order below groups shared implementation work. Complete a kind only through the gate above. Within a group, move past a blocked kind after recording the exact dependency. “Minimum exercise” identifies a real entity-specific create/edit/delete case; it does not make other fields automatically writable. + +| # | Kind | Minimum entity-specific exercise and dependency | +|---:|---|---| +| 1 | Tolerance | Create a feature-control frame with text/style; move and edit its text/direction; check style reference and display. | +| 2 | Shape | Create with an installed SHX shape/style; change insertion, size, rotation; verify the named glyph and style survive reopen. | +| 3 | AttributeDefinition | Create in a block definition; edit tag, default text, and placement; verify the block’s definition and linked inserts. | +| 4 | AttributeEntity | Create as a block reference attribute; edit value and placement; verify parent `Insert`, handles, and sequence linkage. | +| 5 | Hatch | Create a closed-boundary solid hatch and a patterned hatch; edit boundary/pattern; verify fill and boundary validity. | +| 6 | Leader | Create with vertices and an annotation; edit path and annotation placement; preserve annotation handle. | +| 7 | MLine | Create against a valid MLine style; edit vertices and scale; verify style element offsets and joins. | +| 8 | Dimension | Create at least one supported dimension subtype with style and reference points; edit defining geometry and displayed measurement. Track subtype coverage separately. | +| 9 | MultiLeader | Create text and leader paths using a valid style; edit landing/vertices and text; preserve nested context and annotation links. | +| 10 | Table | Create rows, columns, style, and cells; edit size and one cell value; preserve merged ranges and block/style links. | +| 11 | PolygonMesh | Create a grid with defined M/N counts and vertices; edit a vertex and density; reject inconsistent counts. | +| 12 | PolyfaceMesh | Create vertices/faces; edit a vertex and face indices; reject invalid indices and preserve sequence records. | +| 13 | Mesh | Create vertices/faces/edges; edit one vertex and topology; reject out-of-range indices and verify rendered faces. | +| 14 | Helix | Create with axis, radius, turns, and height; edit a defining parameter and verify the derived curve plus embedded spline data. | +| 15 | RasterImage | Create with a resolvable image definition/file; edit placement, vectors, and clipping; verify display and definition handles. | +| 16 | Wipeout | Create with a valid clip polygon; edit boundary and placement; verify masking, clip mode, and image-definition links. | +| 17 | Underlay | Create with a valid PDF/DWF/DGN definition; edit transform and clipping; verify external resource and definition handle. | +| 18 | Viewport | Create in the appropriate layout; edit center/size/view target; verify visible viewport and frozen-layer/clip references. | +| 19 | ViewBorder | Create with its required view/scale references; edit border geometry; verify linked viewport/view records. | +| 20 | SectionSymbol | Create with view style and point records; edit endpoints/label; preserve view-representation links. | +| 21 | Light | Create a light with position/target; edit intensity and direction; verify color/photometric state and visible glyph. | +| 22 | Region | Create/import a valid region payload; transform/edit supported geometry, then verify ACIS payload after both file formats. | +| 23 | Body | Create/import a valid body payload; apply a supported transform/edit; verify ACIS and history/reference preservation. | +| 24 | Solid3D | Create/import a valid solid; edit a supported geometric operation; verify ACIS, topology, and history after reopen. | +| 25 | Surface | Create/import a valid surface; edit a supported parameter/transform; verify surface payload, isolines, and history. | +| 26 | Ole2Frame | Create with a valid embedded object payload; edit frame geometry; verify storage, aspect behavior, and persistence. | + +The last five may require new engine capabilities rather than only Python converters. Do not deserialize and rewrite their opaque payloads unless lossless behavior is proven. If a listed kind has no meaningful safe Python creation/edit path, keep it marked blocked or partial and report why; do not relabel it complete to reach 43/43. + +## 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. +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 + app host tests. Run + `cargo test --locked --features experimental-host-model --manifest-path plugins/opencad-python/Cargo.toml` + plus + `python3 -m unittest discover -s plugins/opencad-python/tests -v`. +5. Check `git diff --check` and repository status, commit host and adapter changes + together, and record the resulting commit and the next unfinished gate in the ledger. + +## Phase 8 progress log + +- 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 + 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. +- The same Line test now also sends a duplicate-handle batch through Python and confirms the host rejects it without changing geometry or creating an undo entry. +- The opt-in `staged_python_point_pick_and_cancel_over_real_ipc` test passes with the same runner. It requests point and entity picks through Python, drives the app's active-command point/object-pick callbacks, polls the returned tokens through a later Python run, and verifies Enter cancellation. The callbacks are invoked by the test harness, not by a physical GUI pointer event; live GUI verification remains open. +- The opt-in `staged_python_tabs_isolate_tokens_and_notifications` test passes with two real OCS document tabs and the staged runner. A token created in tab 0 cannot be observed or consumed in tab 1 before or after completion. Host-to-plugin DrawingChanged notifications for both tab ids are delivered over IPC and drained only by their owning tab. A 257-event burst reports overflow, and `DocumentTabClosed` discards queued events for that tab. +- Added `doc.create_entity(kind, **properties)` and `doc.delete_entity(entity_or_handle)` to the high-level Python document model. Unit tests cover forwarding and managed identity fields. The real staged-plugin Line lifecycle now uses these methods rather than low-level `ocs.add`/`remove_entity`. +- Closed the first new-kind increment for `Tolerance`. The host coverage catalog now maps insertion point, direction, normal, text, dimension style name, text height, and gap; validates geometry and document style references; keeps the style handle read-only; and leaves the undocumented DWG short unmapped. The thin adapter adds generated conversion plus stale style-handle clearing when the style name changes. +- `staged_python_tolerance_lifecycle_over_real_ipc` passes the full C/R/E/D/U/I/W/V/P gate with the actual runner: high-level create/read/transaction edit/delete, live canvas selection, undo/redo, invalid direction and missing style rollback, untouched-field preservation, and DWG/DXF reopen. Tolerance is recorded as **Complete** in the ledger. The queue now continues with `Shape`. +- Closed `Shape` with a generated SHX fixture and a real shape-file `TextStyle`. Host-owned binding resolves the Python style name to the stable table handle required by DWG; validation rejects missing/non-shape styles and invalid geometry. The adapter maps all stable shape fields and keeps the bound style handle read-only. +- `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. +- 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`. +- 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`. +- Completed `PolygonMesh`, `PolyfaceMesh` and `Mesh` (queue items 6-8) with one shared `run_mesh_lifecycle` test driver and per-kind host validation. The generator now omits a sub-record's own `EntityCommon` from the scripted form. All three pass the full gate including DWG and DXF reopen with re-edit. The queue now continues with `Helix`. +- Mapped `Helix` with a host-rebuilt spline. `run_mesh_lifecycle` gained per-format DXF expectations so an engine gap is pinned by a canary instead of hidden. All gates pass except W for `handedness` in DXF (reader ignores boolean group 290). The queue now continues with `RasterImage`. +- Mapped `RasterImage`. The host reads the image file, creates and links the `ImageDefinition`, and derives `size` and the default clip boundary. The shared lifecycle driver passes every gate in OCS with a real PNG; `ACAD_IMAGE_DICT`/reactor creation is not implemented, so the kind stays integration-tested. The queue now continues with `Wipeout`. +- Completed `Wipeout` (queue item 11) with rectangular and polygonal clip masks through the shared lifecycle driver; scripted creation matches OCS's native command. The queue now continues with `Underlay`. +- Mapped `Underlay` against a fixture PDF `UnderlayDefinition`. All gates pass except DXF W: the reader does not convert the degree-valued rotation back to radians, and repeated DXF round trips compound the error (pinned by a canary). The queue now continues with `Viewport`. +- 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`. +- 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. +- 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. +- 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. +- 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. +- 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. + +### 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 +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, +unknown linetype, out-of-range lineweight or transparency, protected layer `0`/`Defpoints`, current +layer, layer holding objects without `erase_objects`) is decided before the undo step is recorded, so +nothing changes. Python: `ocs.active_document.layers` (`create`, `modify`, `rename`, `delete`, +`set_current`, `current`, lookup and iteration). Evidence: `audit_python_layer_table_over_real_ipc` +(19 refusals, live/DWG/DXF persistence of colour, lineweight, transparency, flags, linetype, +description, rename following entities, `erase_objects` and one-step undo), an IPC round-trip test and a +Python unit test. Next: text and dimension styles, linetypes, blocks, layouts; then headless commands. + +### 2026-09-21: text and dimension styles (`doc.text_styles`, `doc.dim_styles`) + +`TableOperation` grew `TextStyleCreate/Modify`, `DimStyleCreate/Modify` (DimStyle fields travel as a JSON +object, so all 88 are settable with host validation; handles, xref fields and the name are refused), +`StyleRename`, `StyleDelete`, `StyleSetCurrent`. The host reuses OCS's own style machinery (in-use scan, +reference-following rename) by widening four `style_ops` methods to `pub(super)`, and additionally makes a +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 +canaries and listed in `cadcodec-reader-gaps.md`. Next: linetypes, blocks, layouts; then headless commands. + +### 2026-09-21: blocks (`doc.blocks`) + +`TableOperation` gained `BlockCreate` (copy existing model/paper entities into a definition shifted by +`-base_point`, optionally erasing the originals), `BlockModify` (description, explodable, uniform scale), +`BlockRename` (inserts follow, via `Scene::rename_block`) and `BlockDelete` (refused while an insert, a +dimension-style arrow or a multileader uses it; removes the members, markers, record and orphaned draw-order +tables). Creation reuses `Scene::define_block_from_owned_entities`. Python: `blocks.create/modify/rename/delete`, +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: +editing a definition's contents in place, and attribute definitions inside a scripted block. Next: linetypes and +layouts, then headless commands. + +### 2026-09-21: block contents (`create_entity(..., block=)`) + +`TableOperation::BlockEntityAdd` adds a validated entity to a definition: the host sets the owner, runs the same +validation and reference binding as a new model-space entity, refuses viewports, raster images, block markers, +nested attributes and self-nesting inserts (direct or transitive), then reuses the scene's block-editor routing so +the entity gets normal preparation. Editing and deleting members already worked through the entity API; deleting a +member now also drops its handle from the block record, because the core document leaves a removed entity +listed (`entity_handles`). An entity-delta undo of an add still leaves the handle listed (documented scene behaviour), +so consumers must treat `entity_handles` as candidates and confirm with `get_entity`; the Python block records +already do. Evidence: `audit_python_block_contents_over_real_ipc` (9 refusals; create, edit, delete in a block; +attribute definition inside a block; nested insert; live/DWG/DXF persistence; undo), IPC and Python unit tests. +Next: linetypes and layouts, then headless commands. + +### 2026-09-21: linetypes and layouts (`doc.linetypes`, `doc.layouts`) + +`TableOperation` gained `LinetypeCreate/Modify/Rename/Delete` (simple patterns only, standard names taken, rename +follows layers and entities, delete refused while a layer, entity, dimension style or the current setting uses it) and +`LayoutCreate/Rename/Delete/SetCurrent/SetPage` (reusing `add_layout`, the default page setup, the sheet viewport, +`rename_layout` plus the layout dictionary key, `delete_layout`, and the application's own `on_layout_switch`; `Model` is +protected; `set_page` sets paper size, rotation and a custom scale and refreshes the limits). The host system variable +`CTAB` now reports the current layout. Evidence: `audit_python_linetypes_and_layouts_over_real_ipc` (34 refusals; patterns, +modify, rename following layers; page setup; an entity created on a sheet and not in model space; live/DWG/DXF +persistence; deleting the current layout; four-step undo restoring the layouts, their entity and the linetype), IPC +round-trip and Python unit tests. Not covered: complex (text/shape) linetypes, reordering layouts, plot devices and +styles, and page-setup names. Next: headless command execution. + +### 2026-09-21: command runner and modify wrappers (`doc.command`, `doc.start_command`, `doc.modify`) + +`HostApi::run_command(CommandRequest) -> CommandOutcome` (additive v7; `Run`, `Start`, `Point`, `Text`, `Token`, `Entity`, +`Selection`, `Enter`, `Cancel`) drives the real command through the same primitives the automation channel uses +(`run_command_line`, `dispatch_command`, `feed_command`, `feed_active_cmd`, `CommandEscape`) and finishes each step with +`drive_headless_task`, which is synchronous on the host thread. The earlier "nested command replay can hang" concern does not +apply to this path: no step waits on anything that must arrive from outside, the exploration harness ran every command +tried without a hang, and an editor or dialog a command opens is reported in `blocked_by` and closed by `Cancel`. It refuses +while another command is active, on a denylist (`QUIT`, `EXIT`, `CLOSE*`, `NEW`, `QNEW`, `OPEN`, `SAVE*`, `RECOVER`, `SCRIPT`, +`RUNSCRIPT`, `PY_*`), and off the active tab. It does not consult the user's automation on/off switch, which governs the +external MCP/serve channel; a script is already something the user chose to run. Python: `doc.command`, `doc.start_command` +(context manager that cancels a waiting command) and `doc.modify` (offset, trim, extend, fillet, move, copy, rotate, scale, +mirror, erase). The prompt sequences were observed with `spike_command_runner_prompts` (ignored harness). Evidence: +`audit_python_modify_wrappers_over_real_ipc` checks exact geometry for every wrapper (offset distance and side, trim and +extend end points, fillet arc centre and radius, move, copy keeping the original, rotate, scale, mirror keeping the source, +erase), `doc.command` and an interactive session, nine refusals (quit, new, `PY_RUN`, unknown, incomplete line, input with no +command, second command, run while active), no command left running, and a one-step undo. IPC round-trip and Python unit tests. +Not covered: chamfer, arrays, hatch, join, explode, break and the many other commands (reachable through `doc.command` and +`start_command`); reading prompts for commands with a text editor; an on/off setting for script-driven commands. + +### 2026-09-21: more modify wrappers + +`doc.modify` gained `chamfer`, `array_rect`, `array_polar`, `explode`, `join`, `break_entity`, `stretch` and `lengthen`, each +written against the prompt sequence observed in `spike_command_runner_prompts` (STRETCH needs an Enter after the crossing +window, LENGTHEN and STRETCH end on Enter, EXPLODE and JOIN use the pre-selection because a pick at their selection prompt +selects nothing). `audit_python_modify_wrappers_over_real_ipc` checks exact geometry for each: the chamfer line, all six +rectangular-array members, the four polar-array members (start and half turn), the exploded segments and the removed polyline, +the joined line without its parts, the two pieces after a break, the stretched and the lengthened end points. HATCH is left to +`create_entity("Hatch")`: its command needs an interactive boundary pick that no scripted step can supply. Not wrapped: PEDIT, +path and 3-D arrays, and the remaining commands, which stay reachable through `doc.command` and `start_command`. + +### 2026-09-21: nested input is validated (vertex validation gap) + +Found while writing the `explode` audit: an `LwPolyline` created from vertices that used the wrong key (`{'x': .., 'y': ..}` instead of +`{'location': ..}`) was accepted and became zero-coordinate vertices. The generated dict-to-struct conversions for nested records +defaulted every absent field and ignored unknown keys, so any typo inside a list of vertices, edges or faces vanished silently. +The generator now (1) refuses an unknown key in every nested record, naming the keys it takes, and (2) enforces a manifest list, +`required_struct_fields`, of the fields that define a nested record's geometry: `LwVertex.location`, `Vertex2D.location`, +`Vertex3D.location`, `Vertex3DPolyline.position`, `PolygonMeshVertex.location`, `PolyfaceVertex.location`, `MLineVertex.position`, +`LineEdge.start/end`, `CircularArcEdge.center/radius`, `EllipticArcEdge.center/major_axis_endpoint`, `PolylineEdge.vertices` and +`SectionSymbolPoint.point`. A `None` counts as missing. Everything else in a nested record still defaults when absent. Evidence: +`audit_python_nested_input_is_validated_over_real_ipc` (eight refusals with the right messages for LwPolyline, Polyline2D and +PolygonMesh; nothing invalid reaches the drawing; the valid forms still create), and every existing per-kind lifecycle audit still passes. + +### 2026-09-21: PEDIT and the remaining arrays + +`doc.modify` gained `array_path`, `array_3d`, `polyline_close`, `polyline_open`, `polyline_width`, `polyline_reverse` and `polyline_join`, +written against the prompts observed in `spike_command_runner_prompts` (PEDIT offers to turn a line or arc into a polyline and the wrapper +accepts; the join option takes a selection, and its Enter completes the whole command). The default pick point for a polyline is its first +vertex. `audit_python_modify_wrappers_over_real_ipc` checks: closed and opened flags, the widened polyline, the reversed vertex order, the +joined polyline's three vertices with its line consumed, a line turned into a widened polyline (and the line gone), all five path-array +positions (2600 to 2650 in 12.5 steps), and all eight members of the 3-D array (x 2700/2720, y 0/10, z 0/30). Finding: PEDIT's line-to-polyline +step needs between 2 and 4 MiB of stack in a debug build, more than a test thread's 2 MiB, so that audit runs on a 64 MiB thread; the +application's main thread has 8 MiB, and a release build uses far less. Not wrapped: PEDIT fit, spline, decurve, linetype generation and +vertex editing. + +### 2026-09-21: guardrails (`SCRIPTCOMMANDS`, stack) + +**On/off setting.** A saved user preference, `UserSettings::script_commands` (on by default, so an existing config keeps +scripts allowed), toggled by the `SCRIPTCOMMANDS [0|1]` command, gates `HostApi::run_command`: while it is off every `Run`, +`Start` and input request is refused (`Cancel` stays allowed so a waiting command is never stranded) with a message that says how to turn it back on. The runner refuses the command +itself (it joins `QUIT`, `NEW`, `PY_*` and the rest on the denylist), so a script cannot switch it. It is a command and a +persisted flag only; there is no Options-page control, which would need locale and layout work in files outside this feature. +Evidence: `script_commands_setting_gates_the_runner` (default on; a script cannot change it; the user turns it off and nothing +runs, `Run` and `Start` both refused, no entity added; back on and it runs) and `script_commands_default_on_and_survive_a_round_trip`. +Checked that the test did not write the setting into the real user config. + +**Stack.** The planned large-stack thread for command steps was not built, on the evidence: the native builds already link with +at least 8 MiB (`.cargo/config.toml` raises Windows to 16 MiB), the deepest step found (PEDIT converting a line to a polyline) +needs 2 to 4 MiB in a debug build, and the nesting depth is fixed because `PY_*` is refused, so it cannot recurse. Moving the step +to a helper thread would be unsound here (the editor state is not `Send` and some of it has thread affinity), and the alternative, +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) + +Second small upstream PR, [HakanSeven12/cadcodec#51](https://github.com/HakanSeven12/cadcodec/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 +an XDATA layout that cannot be verified here. OCS still pins the unfixed revision, so the canaries in +`audit_python_text_and_dim_styles_over_real_ipc` still assert the wrong result until #51 is merged and OCS repins. + +### 2026-09-21: Linux and Windows verification + +The change was built in release on Ubuntu and Windows runners with the Python plugin staged beside the executable +(`plugins/opencad.python`, where the host looks for it on both platforms), and the real-IPC plugin tests ran against +that build: **60 of 60 on each** (unsigned portable packages, built by a fork-only workflow that is not part of this change). +The first Windows run failed two tests because the harness substituted the Windows temp directory (`C:\Users\...`) into a +Python string literal, where `\U` is a unicode escape; that was a test-harness path problem, not a product one, and is fixed by +handing the scripts a forward-slash path. Not covered: the whole workspace suite on Windows, the macOS-only bundle path, and an +installer (AppImage or MSI) that includes the plugin. + +### 2026-09-21: pull-request check (`python-host-check.yml`) + +`.github/workflows/python-host-check.yml` runs on `pull_request` (path-filtered to the plugin API, the plugin, the host's plugin and command +files, settings and the lockfile), on a `pr-check-*` tag and by hand, on `ubuntu-22.04` and `windows-latest`. Steps: the plugin API tests +(`--features host`), staging the bundled plugin and building the runner, the plugin crate tests, the Python model unit tests, and the host's +real-IPC tests. It fails outright if the staged plugin or the runner is missing, because those tests otherwise skip and report success. First run +(`pr-check-1`, commit `1f8cbb81`, all green on both platforms): 115 plugin API tests, 20 plugin crate tests, 16 Python model tests and 60 real-IPC +host tests on each. Debug build, so a cold run is about 30 minutes and a cached one much less. The whole lib suite is deliberately not run by that workflow, because `main`'s own `Tests` workflow (`cargo test --workspace --locked`, added upstream on 21 September) runs it on every pull request. Against the merged tree that command gives 1,805 passed and 1 failed locally on macOS, `fonts_parse_and_resolve`, which also fails on pristine `main` there. + +### 2026-09-21: merged upstream `main` (25 commits) + +Merged `origin/main` at its head (no conflicts). Upstream added `.github/workflows/ci.yml` (`cargo test --workspace --locked` on Linux for every +pull request, `LC_ALL=C`), removed the Nix files, and added parametric-constraint and viewport-plot changes. Re-verified on the merged tree: the +plugin gate (60 real-IPC host tests, 115 plugin API, 20 plugin crate, 16 Python model), the wasm check, and the upstream workspace command with +`--no-fail-fast` (1,805 passed, 1 failed: `fonts_parse_and_resolve`, which fails on pristine `main` on this Mac too; `arc_grips_drive_center_start_and_end_but_not_midpoint` +now passes, fixed upstream). The change is 72 files, +30,705 / -178 against current `main` (fork-only workflow excluded). + diff --git a/docs/plugin-host-model-coverage-ledger.md b/docs/plugin-host-model-coverage-ledger.md new file mode 100644 index 000000000..e2f8fe0f2 --- /dev/null +++ b/docs/plugin-host-model-coverage-ledger.md @@ -0,0 +1,59 @@ +# OCS canvas kind coverage ledger + +Use with [the completion plan](plugin-host-model-completion-plan.md). This ledger is current as of 21 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`), **31 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 blocked at W by cadcodec.** Fixes for six of the DXF bugs behind them are submitted as [HakanSeven12/cadcodec#48](https://github.com/HakanSeven12/cadcodec/pull/48) (ATTDEF fields, boolean group codes for Helix and Light, UNDERLAY rotation, LEADER link and vectors, DRAWINGVIEW/SECTIONLINE dispatch, TABLE merged ranges). OCS still pins the unfixed revision `5b682ed`, so those rows stay blocked and their canaries still assert the wrong result; the expectation is that they flip and the rows move to `Complete` once #48 is merged and OCS repins. That has been checked in cadcodec's own round-trip tests, not yet in OCS. + +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. + +| Kind | Baseline | Gate evidence / blocker | +|---|---|---| +| Point | **Complete** | Exercised: `location`, `thickness`. Rejects non-finite location, thickness and axis angle and a zero normal, which the audit found were previously accepted (thickness and normal) or unchecked at creation. `audit_python_point_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). | +| Line | **Complete** | Exercised: `end`, `thickness`. Existing endpoint, undo, selection and pick coverage plus the second-property edit the earlier row asked for. Rejects non-finite endpoints and thickness and a zero normal (thickness was previously accepted). `audit_python_line_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). | +| Circle | **Complete** | Exercised: `radius`, `center`. Rejects a non-positive or non-finite radius, non-finite center and thickness, and a zero normal. `audit_python_circle_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). | +| Arc | **Complete** | Exercised: `end_angle`, `radius`. Rejects a non-positive radius, non-finite angles (previously accepted), center and thickness, and a zero normal. `audit_python_arc_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). | +| Ellipse | **Complete** | Exercised: `minor_axis_ratio`, `end_parameter`. The audit found Ellipse had no validation at all; it now requires a nonzero major axis, a ratio within (0, 1], finite parameters and a nonzero normal. `audit_python_ellipse_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). | +| Polyline | **Update-only (decision)** | Decision (2026-09-20): the legacy `Polyline` (3-D vertices) is not creatable from Python, because both file formats save it as `Polyline3D` and it would silently change kind on the first save. Creating one now fails with a message that names `Polyline2D` and `Polyline3D`. Existing `Polyline` entities remain editable: `audit_python_polyline_lifecycle_over_real_ipc` (host-made fixture) passes E/D/U/I/V with vertex and closed edits, canvas wires, rejection of short and non-finite vertex lists, an atomic rollback and three-step undo/redo, and pins the reopen behavior (both DWG and DXF return a `Polyline3D` with the vertices and closure intact). Scripts should create `Polyline2D` or `Polyline3D`. | +| 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. | +| 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). | +| XLine | **Complete** | Exercised: `base_point`, `direction`. Same as Ray. `audit_python_xline_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). | +| Solid | **Complete** | Exercised: `fourth_corner`, `thickness`. Finite corners, thickness and normal validation already existed; the audit added the full gate. `audit_python_solid_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). | +| Face3D | **Complete** | Exercised: `third_corner`, `invisible_edges`. Finite corners and known edge bits validation already existed; the audit added the full gate. `audit_python_face3d_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). | +| 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 acadrust (DXF ATTDEF reader)** | 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 acadrust `5b682ed`:** the DXF writer emits every ATTDEF group, but the reader handles only groups 1, 2, 3, 10, 40, 50, 280 and 101, so a DXF reopen keeps tag, prompt, value, insertion point, height and rotation and resets the alignment point, both alignments, width factor, oblique angle, all attribute flags (invisible, constant, verify, preset), field length, generation flags, lock and text style to defaults. This is now pinned by canary expectations for both the edit and the re-edit. Not `Complete` until the reader is extended; the fix belongs in cadcodec's `read_attdef`. `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 / blocked at W by acadrust** | 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 blockers (engine, recorded as canary assertions in the test):** (1) the DXF writer never emits group 340, so a DXF save unlinks the annotation; (2) DXF reads drop `annotation_offset` (group 213 is written but not reassembled); (3) DWG R2010+ does not store `text_height`, `text_width` or `hookline_enabled` (acadrust writes them only for R13–R2007 / R13–R14); (4) neither writer stores `override_color`, so it reopens as `ByLayer` in DWG and DXF. Not `Complete` until the engine round-trips (1) and (2); (3) needs a decision to document or make read-only. | +| 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. | +| 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 | **Integration-tested / blocked at W by acadrust (DXF handedness)** | 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 blocker (engine, pinned by a canary assertion):** the DXF reader parses boolean group 290 as an `i16` and never applies it, so a clockwise helix reopens counter-clockwise from DXF while DWG keeps it. Not `Complete` until the reader is fixed. | +| 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. | +| 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 | **Integration-tested / blocked at W by acadrust (DXF rotation units)** | 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 blocker (engine, pinned by canary assertions):** DXF stores the rotation in degrees but the reader returns it unconverted, so 0.5 rad reopens as 28.6 and each further DXF save-reopen compounds it (1641.4 after a second), silently corrupting the rotation. Not `Complete` until the reader converts degrees back to radians. 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 | **Integration-tested / blocked at W by acadrust (DXF kind)** | 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 blocker (engine, pinned by a canary):** a DXF `DRAWINGVIEW` reopens as a different, opaque entity kind rather than as a ViewBorder, so only DWG restores the typed record. Not `Complete` until DXF loading restores the typed entity. Not covered: the linked view representation and drawing-view definition records. | +| SectionSymbol | **Integration-tested / blocked at W by cadcodec (DXF SECTIONLINE)** | 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 blocker (cadcodec, pinned by a canary; issue 5 in [cadcodec-reader-gaps.md](cadcodec-reader-gaps.md)):** the DXF reader dispatches `SECTIONLINE` only inside blocks, so a symbol in the drawing's entity list reopens as an `Unknown` entity. 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 | **Integration-tested / blocked at W by acadrust (DXF `cast_shadows`)** | 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 blocker (engine, pinned by a canary):** the DXF reader does not restore `cast_shadows`, which reopens false; colour, placement and intensity persist. 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. | +| 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. | +| 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. | + +After each increment, update the row with test names, fixture paths, exact +passing commands, the OCS commit hash, and any manual GUI result. Host and +adapter changes now share the same repository commit. If a gate is untested, +leave it pending. Do not raise the headline completion count from converter +coverage alone. diff --git a/docs/plugin-host-model.md b/docs/plugin-host-model.md new file mode 100644 index 000000000..417a576ea --- /dev/null +++ b/docs/plugin-host-model.md @@ -0,0 +1,137 @@ +# Scripting host model (API v7) + +This note describes how a script controls OCS and what the host guarantees. The +per-kind evidence is in the [coverage ledger](plugin-host-model-coverage-ledger.md), +the increment-by-increment record in the [completion plan](plugin-host-model-completion-plan.md), +and the script-facing reference in `plugins/opencad-python/PLUGIN.md`. A reviewer new to +the change should start with the [review guide](plugin-host-review-guide.md). + +## Shape + +Python runs in RustPython inside a separate plugin process +(`plugins/opencad-python`), so a script cannot crash the editor. It talks to the +host over the versioned IPC in `crates/ocs_plugin_api`. **The host owns the +drawing**: it validates every request, records the undo step, refreshes scene +caches and the shared document view, and marks the document dirty. The Python +adapter is thin: it turns dictionaries into typed values and back, and holds no +CAD rules of its own. Everything below is additive to API v7; a plugin built +without it keeps working, and the default `HostApi` methods refuse. + +## What the host offers + +| Area | `HostApi` entry | What it does | +|---|---|---| +| Read | `document()`, `entity_snapshot` | The full `CadDocument` snapshot; a detached JSON view of any typed entity. | +| Entities | `add_entity`, `remove_entity`, `update_entities_transaction` | Create, delete and replace entities. A transaction validates every replacement first and either commits all of them as one undo step or none. | +| Selection | `selection`, `set_selection` | Ordered, tab-scoped, exact (missing or duplicate handles reject the request). | +| Solids | `solid_operation` | Kernel-backed create, transform, booleans, extrude, region and surface from a profile, picture embedding. Every result is verified to lift back from its ACIS payload without loss; a refusal leaves the operands untouched. | +| Tables | `table_operation` | Layers, text and dimension styles, blocks and their contents, linetypes, layouts (see below). | +| Commands | `run_command` | Drives the real OCS command one step at a time (see below). | +| Settings | `system_variable`, `set_system_variable` | `CLAYER`, `SNAPANG`, and a read-only `CTAB` (the current layout). | + +Entity kinds outside a generated schema still read (handle, kind, layer) and take +`layer` changes; nothing is writable that the host has not validated. + +## Entity validation + +The host embeds a coverage catalog generated from its traced `EntityType` +registry and `crates/ocs_plugin_api/entity_coverage_policy.json`. It classifies all +48 variants: 43 canvas kinds, three internal records (`Block`, `BlockEnd`, +`Seqend`) and two opaque fallbacks (`Extended`, `Unknown`), and records for each +property its source field, type, shape, snapshot readability, Python access +(`read_write`, `read_only`, `unmapped`) and validation status. + +Creation and edits are validated per kind (finite coordinates, positive radii and +scales, unit directions, non-parallel vectors, in-range flags, reference targets +that exist and have the right kind, and so on). Creation must be valid; an edit +is refused only for problems it introduces, so already-invalid legacy values stay +editable. Derived state is computed by the host, never trusted from a script (MLine +geometry, Dimension measurement, Helix spline, Table merge dimensions, Spline +rational flag, SectionSymbol counts, Viewport id and scale). + +Nested records are checked too. A dictionary for a vertex, edge or face may not +carry an unknown key, and the fields that define its geometry are required +(`entity_manifest.json`, `required_struct_fields`); a typo can no longer turn into +a zero coordinate. Everything else in a nested record defaults when absent. + +Every kind is exercised through the real plugin runner by a per-kind audit that +checks create, read, edit, delete, undo, invalid input with an atomic rollback, +and a DWG and DXF round trip. A kind is `Complete` in the ledger only when all of +those pass. + +## Tables + +`TableOperation` is one additive request with typed variants, executed by +`HostSession::table_operation`. Every refusal is decided **before** the undo step is +recorded, so a failure changes nothing and leaves the history alone. + +- **Layers:** create, modify, rename (entities follow), delete (refused while it + holds objects unless erasing them is asked for), set current. Layer `0`, + `Defpoints` and the current layer are protected. +- **Text and dimension styles:** create, modify, rename, delete, set current. Dimension + style fields travel as a validated JSON object; handles, xref fields and the name are + host-managed. Renaming a text style updates the dimension styles that name it. + `Standard` and any style that is current or in use are protected. +- **Blocks:** create from entities (copied in shifted by the base point, optionally + erasing the originals), modify, rename (inserts follow), delete (refused while + referenced), and add an entity to a definition through the normal validation path. + A block may not nest itself, directly or through other blocks. +- **Linetypes:** simple dash, gap and dot patterns; rename follows layers and + entities; delete is refused while anything uses it. +- **Layouts:** create with the default page setup and sheet viewport, rename, delete, + switch (through OCS's own layout switch), and page setup (paper size, rotation, + custom scale). `Model` is protected. + +Style and layout operations act on the active document, because they use the +application's shared style and view machinery. + +## Commands + +`run_command` takes a `CommandRequest` (`Run`, `Start`, `Point`, `Text`, `Token`, +`Entity`, `Selection`, `Enter`, `Cancel`) and answers with a `CommandOutcome`: +whether the command is done or waiting, its prompt, what input it accepts, its +keyword options, how many entities it added, tokens nobody asked for, and any +error. It uses the same primitives as the automation channel and finishes each +step synchronously on the host thread, so a step never waits for anything that has +to arrive from outside; an earlier fire-and-forget replay could hang, this does not. + +Guards: refused while another command is active or off the active tab; refused for +commands that could end the session or re-enter Python (`QUIT`, `EXIT`, `CLOSE*`, +`NEW`, `QNEW`, `OPEN`, `SAVE*`, `RECOVER`, `SCRIPT`, `RUNSCRIPT`, `SCRIPTCOMMANDS`, `PY_*`); an editor or +dialog a command opens is reported and closed by `Cancel`. The user can turn the whole +facility off with the saved `SCRIPTCOMMANDS` preference (on by default; `SCRIPTCOMMANDS 0` +at the command line): while it is off, every request except `Cancel` is refused (so a command a script left +waiting can still be closed). A script cannot change +it, because the runner refuses that command. The runner does not consult the automation +on/off switch, which governs the external MCP and serve channels. The Python layer cancels +any step sequence that leaves a command waiting. + +## Python surface + +`ocs.active_document` exposes `entities`, `create_entity`, `delete_entity`, +`transaction`, `selection`, `solids`, `layers`, `text_styles`, `dim_styles`, `blocks`, +`linetypes`, `layouts`, `command`, `start_command` and `modify` (offset, trim, extend, +fillet, chamfer, move, copy, rotate, scale, mirror, erase, rectangular, polar, path and +3-D arrays, explode, join, break, stretch, lengthen and polyline edits). `doc.coverage()` +lists the catalog, and `doc.entities[handle].coverage` one entry, with the readable and +editable keys and the fields the typed snapshot carries but the model does not map. + +Interactive picks use a token-based request and poll API because `PY_RUN` uses a +fresh interpreter per invocation and a script cannot suspend while the user clicks. +Drawing, selection and command notifications are kept in a bounded 256-event queue +per tab between runs and drained with `doc.poll_events()`; an overflow is reported +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. +- 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`. +- Not available from a script: complex (text and shape) linetypes, plot devices and named + page setups, PEDIT's fit and spline options, and interactive-only commands such as HATCH. +- A command step nests inside the editor's message handling. In a debug build a deep step + (PEDIT converting a line to a polyline) needs between 2 and 4 MiB of stack; the native + builds link with at least 8 MiB (16 MiB on Windows, `.cargo/config.toml`), the nesting depth + is fixed rather than recursive (`PY_*` is refused), and a release build uses far less. diff --git a/docs/plugin-host-review-guide.md b/docs/plugin-host-review-guide.md new file mode 100644 index 000000000..d242aa808 --- /dev/null +++ b/docs/plugin-host-review-guide.md @@ -0,0 +1,140 @@ +# Reviewer guide: scripting host model and the bundled Python plugin + +This is written for a reviewer who has not followed the work and may not have a Mac. +It says what the change is, how to check it on Linux or Windows in a few minutes, where +it touches existing code, what to read first, and what is deliberately left out. + +## What it is + +The bundled `opencad.python` plugin (RustPython, in its own process) can now control OCS +through the versioned plugin API (v7), not only add lines and circles: + +- **Entities.** Create, read, edit, delete and undo for all 43 canvas kinds (41 creatable), + validated by the host, each with a DWG and DXF round-trip audit. +- **Drawing tables.** Layers, text and dimension styles, blocks and their contents, + linetypes and layouts, and kernel-backed solids. +- **Commands.** Scripts can run OCS's own commands step by step (offset, trim, fillet, + arrays, PEDIT and so on), with guards. + +**The host owns the drawing.** It validates every request, records the undo step and +refuses without changing anything when it cannot honour a request. The Python adapter is a +thin translation layer. The design and its guarantees are in +[plugin-host-model.md](plugin-host-model.md). + +**Size.** 72 files, +30,705 / −178, against current `main`. About 15,000 added lines are the +new plugin crate (including a 3,000-line lockfile and a generator), 5,600 are the API crate +(3,500 of them the entity coverage validator), and 8,800 are in `src/`, of which roughly 6,200 +are tests in `plugin_host.rs`. Existing OCS code changes by **178 deleted lines in total** (151 of them outside docs, plugin and API); +everything else is additive. + +## Check it without a Mac + +Nothing here needs macOS. The plugin tests are headless: they build the plugin and the +application, then spawn the application as the plugin runner and talk to it over the real IPC. +`stage-bundled.sh` already handles Linux (`.so`) and Windows (`.dll`), and the host looks for the +plugin in `plugins/opencad.python/` next to the executable. + +```bash +bash plugins/opencad-python/tools/stage-bundled.sh work/plugin --debug +cargo build --bin OpenCADStudio +OCS_TEST_PYTHON_PLUGIN=work/plugin/libopencad_python.so \ +OCS_PLUGIN_RUNNER_EXE=target/debug/OpenCADStudio \ +cargo test --lib -- --test-threads=1 python +``` + +(`opencad_python.dll` and `OpenCADStudio.exe` on Windows.) Expect 60 passed. **Without the two +environment variables these tests are skipped and still report success**; `python-host-check.yml` fails +instead of skipping. + +Already run for you: + +| Check | Where | Result | +|---|---|---| +| `python-host-check.yml` on the pull request | Linux and Windows | 115 plugin API tests, 20 plugin tests, 16 Python model tests, 60 host tests over real IPC, on each | +| Release build with the plugin staged (a fork-only workflow, not in this PR) | Linux and Windows | 60 of 60 host tests on each; unsigned portable packages were produced and can be shared on request | +| Local | macOS | the same, plus the wasm check | + +`main` now has its own `Tests` workflow (`cargo test --workspace --locked` on Linux), which will also run on +this pull request. Run locally on macOS against the merged tree with the same command and `LC_ALL=C` and +`--no-fail-fast`, the whole workspace gives **1,805 passed and 1 failed**. The one failure, +`scene::text::lff::tests::fonts_parse_and_resolve`, also fails on pristine `main` in that environment (it +needs font data) and is not part of this change; I expect it passes on the Linux runner but have not seen that. + +What has **not** been done: launching the GUI on Linux or Windows (the runners have no display), running the +whole workspace suite on Windows, and building an AppImage or MSI that contains the plugin. + +## Reading order + +Read the contract first, then the host, then the adapter. The commits are in dependency order but +several kinds and features are interleaved, so read by area rather than commit by commit. + +1. **The contract: `crates/ocs_plugin_api`.** + - `src/host.rs`: the new types and default-refusing methods: `SolidOperation`, `TableOperation`, + `CommandRequest` and `CommandOutcome`, `update_entities_transaction`, `selection`, `set_selection`, + `solid_operation`, `table_operation`, `run_command`. + - `src/ipc/**`: one additive request and response variant per method. The new variants are appended + after the existing ones so existing wire positions do not move. `v4/protocol.rs` has a bincode round-trip + test for each. + - `src/entity_coverage.rs`, `entity_coverage_policy.json`, `build.rs`: the per-kind validation and the + generated coverage catalog. This is the largest file and the most mechanical; the tests in it are the + rules in executable form. +2. **The host: `src/app/plugin_host.rs`**, lines 1 to about 2,700 (the rest is tests). Sections in order: + scripted-entity normalization (host-derived state), solid operations, `table_operation` (layers, then + styles, blocks, linetypes, layouts), `run_command`, and the `HostApi` delegation at the end. +3. **The adapter: `plugins/opencad-python`.** `entity_manifest.json` and `build/generate.rs` generate the + dict-to-entity conversions from the host's type registry; `src/ocs_module.rs` is the `ocs.*` bindings; + `src/document_model.py` is the script-facing document model; `PLUGIN.md` is the reference. +4. **The small edits to existing OCS files** (this is where behaviour of existing code could change): + + | File | Change | + |---|---| + | `src/plugin/external.rs`, `src/ui/window/plugin_manager.rs` | Bundled-plugin discovery (`bundled_plugins_dir`, `OCS_BUNDLED_PLUGINS_DIR`, a "Bundled with Open CAD Studio" label). A per-user plugin with the same id still overrides the bundled one. | + | `src/plugin/v4_support.rs`, `src/app/update/mod.rs`, `src/app/automation.rs` | New `DrawingChanged` and `CommandStateChanged` notifications; a drawing-changed notification after automation ops. | + | `src/entities/{mline,table,helix,dimension}.rs` | `normalize_scripted_*`: derived state a script must not set (MLine geometry, Table merges, Helix spline, Dimension measurement). Called only from the plugin host. | + | `src/scene/selection.rs`, `src/scene/entity.rs`, `src/app/layers.rs` | `replace_selection_exact`, preserving storage data on entity replacement, `set_current_layer_name`. | + | `src/app/style_ops.rs`, `src/app/control/mod.rs` | Four methods and one function widened to `pub(super)` so the host reuses OCS's own style rename, delete and in-use logic. | + | `src/app/settings.rs`, `src/app/mod.rs`, `src/app/update/file.rs`, `src/app/commands/styleprops.rs` | The `SCRIPTCOMMANDS` preference and command (see the decisions below). | + | `packaging/build_macos_signed.sh`, `.github/workflows/release.yml` | Stage (and, in the signed script, sign) the plugin in the macOS bundle; one added step in `release.yml`. | + +## What each area proves + +| Area | Evidence | +|---|---| +| 43 entity kinds | One real-IPC audit per kind (create, read, edit two properties, delete, undo and redo, invalid input with an atomic rollback, canvas geometry, DWG and DXF reopen and re-edit). [The ledger](plugin-host-model-coverage-ledger.md) records each kind's exact evidence or its blocker. | +| Solids | Kernel primitives, moves, booleans, extrusions and regions, each verified to lift back from its ACIS payload; refusals leave operands untouched. | +| Tables | `audit_python_layer_table`, `_text_and_dim_styles`, `_blocks`, `_block_contents`, `_linetypes_and_layouts`: dozens of refusals each, live and DWG and DXF persistence, one-step undo. | +| Commands | `audit_python_modify_wrappers`: exact geometry for every wrapper (offset, trim, extend, fillet, chamfer, arrays, explode, join, break, stretch, lengthen, PEDIT edits), guards, an interactive session, undo. | +| Nested input | `audit_python_nested_input_is_validated`: a mistyped vertex key or a missing location is refused instead of becoming a zero coordinate. | +| Guardrail | `script_commands_setting_gates_the_runner` and a settings round-trip test. | + +## Decisions that are yours + +1. **`run_command` and the automation switch.** Script-driven commands do not consult the automation on/off + switch, which governs the external MCP and serve channels; a script is something the user chose to run. Say + if you would rather they respected it. +2. **`SCRIPTCOMMANDS`.** A saved preference (on by default) toggled by a command turns script-driven commands off + entirely. It has no Options-page control; adding one needs locale and layout work outside this change. +3. **Denylist.** A script cannot run `QUIT`, `EXIT`, `CLOSE*`, `NEW`, `QNEW`, `OPEN`, `SAVE*`, `RECOVER`, + `SCRIPT`, `RUNSCRIPT`, `SCRIPTCOMMANDS` or any `PY_*`, and only one command may be active. Tell me about any + other command that should be refused. +4. **Nesting depth.** A command step runs inside the editor's message handling. It is not recursive, but a debug + build of one step (PEDIT converting a line to a polyline) needs 2 to 4 MiB of stack; the native builds link + with at least 8 MiB (16 MiB on Windows). +5. **Splitting.** If 79 commits is too much, the natural seams are: the API and host core, the entity kinds, + the tables, and the command runner. Tell me and I will cut it that way. + +## 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**, + 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. +- Not available from a script: complex (text and shape) linetypes, plot devices and named page setups, PEDIT's + fit and spline options, and interactive-only commands such as HATCH. +- Two DXF gaps are not filed upstream because they need the exact XDATA layout AutoCAD expects (the TrueType + family name and the block description); they are listed in [cadcodec-reader-gaps.md](cadcodec-reader-gaps.md). +- The AppImage, snap and MSI still ship without the plugin: `release.yml` stages it only in the macOS bundle + (one added step, part of this change). Bundling it for Linux and Windows means adding the same staging step + (`stage-bundled.sh`) to those jobs, which is small but I could not test the installers here. diff --git a/packaging/build_macos_signed.sh b/packaging/build_macos_signed.sh index f2f937c55..e1566049e 100755 --- a/packaging/build_macos_signed.sh +++ b/packaging/build_macos_signed.sh @@ -89,6 +89,8 @@ cp "target/$TARGET/release/OpenCADStudio" "$APP/Contents/MacOS/OpenCADStudio-App chmod +x "$APP/Contents/MacOS/OpenCADStudio" "$APP/Contents/MacOS/OpenCADStudio-App" cp "$DIST/AppIcon.icns" "$DIST/DWG.icns" "$DIST/DXF.icns" "$APP/Contents/Resources/" cp -R "$EXT" "$APP/Contents/PlugIns/" +bash plugins/opencad-python/tools/stage-bundled.sh \ + "$APP/Contents/Resources/plugins/opencad.python" --target "$TARGET" sed "s/__VERSION__/$VERSION/g" packaging/Info.plist > "$APP/Contents/Info.plist" echo "==> codesign" @@ -97,6 +99,8 @@ if [ "$DEVELOPER_ID" = "-" ]; then # Ad-hoc signing cannot be notarized. codesign --force --sign - --timestamp=none \ "$APP/Contents/MacOS/OpenCADStudio-App" + codesign --force --sign - --timestamp=none \ + "$APP/Contents/Resources/plugins/opencad.python/libopencad_python.dylib" codesign --force --sign - --timestamp=none \ --entitlements crates/dwg-thumbnailer/macos/entitlements.plist \ "$APP/Contents/PlugIns/DWGThumbnail.appex" @@ -107,6 +111,9 @@ else # and a secure timestamp on every layer for notarization. codesign --force --timestamp --options runtime \ -s "$DEVELOPER_ID" "$APP/Contents/MacOS/OpenCADStudio-App" + codesign --force --timestamp --options runtime \ + -s "$DEVELOPER_ID" \ + "$APP/Contents/Resources/plugins/opencad.python/libopencad_python.dylib" codesign --force --timestamp --options runtime \ --entitlements crates/dwg-thumbnailer/macos/entitlements.plist \ -s "$DEVELOPER_ID" "$APP/Contents/PlugIns/DWGThumbnail.appex" diff --git a/plugins/opencad-python/Cargo.lock b/plugins/opencad-python/Cargo.lock new file mode 100644 index 000000000..449bbb423 --- /dev/null +++ b/plugins/opencad-python/Cargo.lock @@ -0,0 +1,3059 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "acadrust" +version = "0.5.5" +source = "git+https://github.com/HakanSeven12/cadcodec.git?rev=5b682ed#5b682ed66ea2c89be8142c8dd83d83774fc3de08" +dependencies = [ + "ahash 0.8.12", + "anyhow", + "bitflags", + "byteorder", + "encoding_rs", + "flate2", + "indexmap", + "itoa", + "memmap2", + "nom", + "once_cell", + "rayon", + "ryu", + "serde", + "thiserror 1.0.69", + "web-time", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "version_check", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "serde", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "ar_archive_writer" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73cd58deff2140a0a8eae87e417bd01db68a33e148aa93d1e8cd837e55e312b6" +dependencies = [ + "object", +] + +[[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" + +[[package]] +name = "attribute-derive" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05832cdddc8f2650cc2cc187cc2e952b8c133a48eb055f35211f61ee81502d77" +dependencies = [ + "attribute-derive-macro", + "derive-where", + "manyhow", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "attribute-derive-macro" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a7cdbbd4bd005c5d3e2e9c885e6fa575db4f4a3572335b974d8db853b6beb61" +dependencies = [ + "collection_literals", + "interpolator", + "manyhow", + "proc-macro-utils", + "proc-macro2", + "quote", + "quote-use", + "syn 2.0.119", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +dependencies = [ + "serde_core", +] + +[[package]] +name = "bitflagset" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64b6ee310aa7af14142c8c9121775774ff601ae055ed98ba7fac96098bcde1b9" +dependencies = [ + "num-integer", + "num-traits", + "radium 1.1.1", + "ref-cast", +] + +[[package]] +name = "bitvec" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" +dependencies = [ + "funty", + "radium 0.7.0", + "tap", + "wyz", +] + +[[package]] +name = "bstr" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" +dependencies = [ + "memchr", + "regex-automata", + "serde_core", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytecheck" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" +dependencies = [ + "bytecheck_derive", + "ptr_meta", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cargo-lock" +version = "11.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50524592e6bfbb1bf6f94e8184a786f637faeaf1cf37bbe65502b9a2c5c48939" +dependencies = [ + "semver", + "serde", + "toml", + "url", +] + +[[package]] +name = "caseless" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6fd507454086c8edfd769ca6ada439193cdb209c7681712ef6275cccbfe5d8" +dependencies = [ + "unicode-normalization", +] + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cc" +version = "1.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "windows-link", +] + +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + +[[package]] +name = "collection_literals" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2550f75b8cfac212855f6b1885455df8eaee8fe8e246b647d69146142e016084" + +[[package]] +name = "compact_str" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "derive-where" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e2b94854e8576378ccda7c8de8a66ed8b4e8acbd2c50ec3418ea6c8aaf4b567" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "doctest-file" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2db04e74f0a9a93103b50e90b96024c9b2bdca8bce6a632ec71b88736d3d359" + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "encoding_rs" +version = "0.8.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5ef0006ac9ab233c38522f5ae99cae3625151de8f706cacee1cba4b8e2832a" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + +[[package]] +name = "endian-type" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-discriminant" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1a6df962265a53221f29081896c412ef325c17fa7d638cd9578febe53d3c82c" +dependencies = [ + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "error-code" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" + +[[package]] +name = "exitcode" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de853764b47027c2e862a995c34978ffa63c1501f2e15f987ba11bd4f9bba193" + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fd-lock" +version = "4.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" +dependencies = [ + "cfg-if", + "rustix", + "windows-sys 0.59.0", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "get-size-derive2" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b6d1e2f75c16bfbcd0f95d84f99858a6e2f885c2287d1f5c3a96e8444a34b4" +dependencies = [ + "attribute-derive", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "get-size2" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49cf31a6d70300cf81461098f7797571362387ef4bf85d32ac47eaa59b3a5a1a" +dependencies = [ + "compact_str", + "get-size-derive2", + "hashbrown 0.16.1", + "ordermap", + "smallvec", +] + +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash 0.7.8", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hexf-parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "interpolator" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71dd52191aae121e8611f1e8dc3e324dd0dd1dee1e6dd91d10ee07a3cfb4d9d8" + +[[package]] +name = "interprocess" +version = "2.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb02ffbf25e8ef0f4a95bff054eef75f6d1bf16919474cf2caa63b21997478a" +dependencies = [ + "doctest-file", + "libc", + "recvmsg", + "widestring", + "windows-sys 0.61.2", +] + +[[package]] +name = "is-macro" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8267aa6001e25494f3015f9663bbd88a18240c74483afa5f0934a1b3e4c388e9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "junction" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfc352a66ba903c23239ef51e809508b6fc2b0f90e3476ac7a9ff47e863ae95" +dependencies = [ + "scopeguard", + "windows-sys 0.61.2", +] + +[[package]] +name = "lexical-parse-float" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52a9f232fbd6f550bc0137dcb5f99ab674071ac2d690ac69704593cb4abbea56" +dependencies = [ + "lexical-parse-integer", + "lexical-util", +] + +[[package]] +name = "lexical-parse-integer" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a7a039f8fb9c19c996cd7b2fcce303c1b2874fe1aca544edc85c4a5f8489b34" +dependencies = [ + "lexical-util", +] + +[[package]] +name = "lexical-util" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2604dd126bb14f13fb5d1bd6a66155079cb9fa655b37f875b3a742c705dbed17" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libffi" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a8d526bc07aa02e6826944ec446ecd8d228bd97b1cbd21e5bc5e3407a09a585" +dependencies = [ + "libc", + "libffi-sys", +] + +[[package]] +name = "libffi-sys" +version = "4.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eab801a4ae9065bfea87f37204a7940523e752056e4a090eaaaab9b47fc826b5" +dependencies = [ + "cc", +] + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libloading" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lz4_flex" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef0d4ed8669f8f8826eb00dc878084aa8f253506c4fd5e8f58f5bce72ddb97e" +dependencies = [ + "twox-hash", +] + +[[package]] +name = "malachite-base" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4f44099731f17094b07825c88ccb5fbd1bfa1f82fafff7daa33e8b8652db16e" +dependencies = [ + "hashbrown 0.16.1", + "itertools", + "libm", + "ryu", +] + +[[package]] +name = "malachite-bigint" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc58206ba15e9c406e20c95c5f86efa07b12f94080945908e910b3a0faa23fef" +dependencies = [ + "malachite-base", + "malachite-nz", + "num-integer", + "num-traits", + "paste", +] + +[[package]] +name = "malachite-nz" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a137660cdba20f136c8a223125f08088adb4e0b72fbb8466f08c43e31cc0427d" +dependencies = [ + "itertools", + "libm", + "malachite-base", + "wide", +] + +[[package]] +name = "malachite-q" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ffcbeed95e34c0fcc3864ccd146e129cbbf7de1513d3afbcfb47c7674c82d94" +dependencies = [ + "itertools", + "libm", + "malachite-base", + "malachite-nz", +] + +[[package]] +name = "manyhow" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b33efb3ca6d3b07393750d4030418d594ab1139cee518f0dc88db70fec873587" +dependencies = [ + "manyhow-macros", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "manyhow-macros" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46fce34d199b78b6e6073abf984c9cf5fd3e9330145a93ee0738a7443e371495" +dependencies = [ + "proc-macro-utils", + "proc-macro2", + "quote", +] + +[[package]] +name = "maplit" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" + +[[package]] +name = "matches" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "multiversion" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ca4bea16ffc3f443cf7d866912118196bfef4c6a1556ca00f9f9b00bb43f7c" +dependencies = [ + "multiversion-macros", +] + +[[package]] +name = "multiversion-macros" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d416831a7317ef4b08bee00b69cbbb9c8763da7959a7026244d6266869f9c83" +dependencies = [ + "proc-macro2", + "quote", + "rustversion", + "syn 3.0.6", +] + +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + +[[package]] +name = "nibble_vec" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43" +dependencies = [ + "smallvec", +] + +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "object" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b" +dependencies = [ + "memchr", +] + +[[package]] +name = "ocs_plugin_api" +version = "0.2.1" +dependencies = [ + "acadrust", + "bincode", + "cargo-lock", + "getrandom 0.2.17", + "interprocess", + "libloading 0.8.9", + "memmap2", + "rkyv", + "serde", + "serde-reflection", + "serde_bytes", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "opencad-python" +version = "0.1.0" +dependencies = [ + "ocs_plugin_api", + "rustpython-vm", + "serde", + "serde_json", +] + +[[package]] +name = "optional" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "978aa494585d3ca4ad74929863093e87cac9790d81fe7aba2b3dc2890643a0fc" + +[[package]] +name = "ordermap" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a756ddcdeb6c0efc8927bdf0ed112885815a35ba350472a467be5104f01ca1" +dependencies = [ + "indexmap", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_shared 0.11.3", +] + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pmutil" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52a40bc70c2c58040d2d8b167ba9a5ff59fc9dab7ad44771cfde3dcfde7a09c6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro-utils" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeaf08a13de400bc215877b5bdc088f241b12eb42f0a548d3390dc1c56bb7071" +dependencies = [ + "proc-macro2", + "quote", + "smallvec", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "psm" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dcd034599e63b970727f70d79e02d62390a4a84f7c6b827c27c46d5ac3fa622" +dependencies = [ + "ar_archive_writer", + "cc", +] + +[[package]] +name = "ptr_meta" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "quote-use" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9619db1197b497a36178cfc736dc96b271fe918875fbf1344c436a7e93d0321e" +dependencies = [ + "quote", + "quote-use-macros", +] + +[[package]] +name = "quote-use-macros" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82ebfb7faafadc06a7ab141a6f67bcfb24cb8beb158c6fe933f2f035afa99f35" +dependencies = [ + "proc-macro-utils", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "radium" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1775bc532a9bfde46e26eba441ca1171b91608d14a3bae71fea371f18a00cffe" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "radix_trie" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c069c179fcdc6a2fe24d8d18305cf085fdbd4f922c041943e203685d6a1c58fd" +dependencies = [ + "endian-type", + "nibble_vec", +] + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "recvmsg" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3edd4d5d42c92f0a659926464d4cce56b562761267ecf0f469d85b7de384175" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" + +[[package]] +name = "rend" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" +dependencies = [ + "bytecheck", +] + +[[package]] +name = "result-like" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bffa194499266bd8a1ac7da6ac7355aa0f81ffa1a5db2baaf20dd13854fd6f4e" +dependencies = [ + "result-like-derive", +] + +[[package]] +name = "result-like-derive" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01d3b03471c9700a3a6bd166550daaa6124cb4a146ea139fb028e4edaa8f4277" +dependencies = [ + "pmutil", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "rkyv" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1" +dependencies = [ + "bitvec", + "bytecheck", + "bytes", + "hashbrown 0.12.3", + "ptr_meta", + "rend", + "rkyv_derive", + "seahash", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustpython-codegen" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12e4fc1331357a7afc6d52d637796a946b1efaaca07c914132f8fd4fd69d595f" +dependencies = [ + "ahash 0.8.12", + "bitflags", + "indexmap", + "itertools", + "log", + "malachite-bigint", + "memchr", + "num-complex", + "num-traits", + "rustpython-compiler-core", + "rustpython-literal", + "rustpython-ruff_python_ast", + "rustpython-ruff_text_size", + "rustpython-wtf8", + "thiserror 2.0.20", + "unicode_names2 2.0.0", +] + +[[package]] +name = "rustpython-common" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f3c0808a170ea900e0489a06d6aa04ce5d27d62b347836c2f98df958f47f243" +dependencies = [ + "ascii", + "bitflags", + "cfg-if", + "getrandom 0.3.4", + "itertools", + "libc", + "lock_api", + "malachite-base", + "malachite-bigint", + "malachite-q", + "nix", + "num-complex", + "num-traits", + "radium 1.1.1", + "rustpython-literal", + "rustpython-wtf8", + "siphasher", + "unicode_names2 2.0.0", + "widestring", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustpython-compiler" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7fa83b852bbc5461c5214060099d7597785d201aa0bf1a87d77967518f60144" +dependencies = [ + "rustpython-codegen", + "rustpython-compiler-core", + "rustpython-ruff_python_ast", + "rustpython-ruff_python_parser", + "rustpython-ruff_source_file", + "rustpython-ruff_text_size", + "thiserror 2.0.20", +] + +[[package]] +name = "rustpython-compiler-core" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f18ba67b55aec49c3e2d223bbdcacff0d7e0f5d304fb0e64b044b8dca13c7c" +dependencies = [ + "bitflags", + "bitflagset", + "itertools", + "lz4_flex", + "malachite-bigint", + "num-complex", + "rustpython-ruff_source_file", + "rustpython-wtf8", +] + +[[package]] +name = "rustpython-derive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6c04350bee9e54b241b93675a0558c2b33fe982213e47a6da0924898af0f2" +dependencies = [ + "rustpython-compiler", + "rustpython-derive-impl", + "syn 2.0.119", +] + +[[package]] +name = "rustpython-derive-impl" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "322d64ea8a21d52cd769db0f6190b6e7c17963b13c8c17f39d7364e68af96731" +dependencies = [ + "itertools", + "maplit", + "proc-macro2", + "quote", + "rustpython-compiler-core", + "rustpython-doc", + "syn 2.0.119", + "syn-ext", + "textwrap", +] + +[[package]] +name = "rustpython-doc" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f286d8b5872aa53dceb7a8d8c6a29fe85150f0a137fecf1c9a3e0a8345cbc19b" +dependencies = [ + "phf 0.13.1", +] + +[[package]] +name = "rustpython-literal" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcb46f7afce00e4797f82062e09881ec5e7226d858104bf4a8a9c3b996de2b4a" +dependencies = [ + "hexf-parse", + "is-macro", + "lexical-parse-float", + "num-traits", + "rustpython-wtf8", + "unic-ucd-category", +] + +[[package]] +name = "rustpython-ruff_python_ast" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f021ff72cabf5e2cd6d8ec8813d376a8445a228dc610ab56c27bd9054cda70d4" +dependencies = [ + "aho-corasick", + "bitflags", + "compact_str", + "get-size2", + "is-macro", + "memchr", + "rustc-hash", + "rustpython-ruff_python_trivia", + "rustpython-ruff_source_file", + "rustpython-ruff_text_size", + "thiserror 2.0.20", +] + +[[package]] +name = "rustpython-ruff_python_parser" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01e6ee78bd9671fb5766664b2695fe1f2a92a961f4d9101646c570d8acdb1e0b" +dependencies = [ + "bitflags", + "bstr", + "compact_str", + "get-size2", + "memchr", + "rustc-hash", + "rustpython-ruff_python_ast", + "rustpython-ruff_python_trivia", + "rustpython-ruff_text_size", + "static_assertions", + "unicode-ident", + "unicode-normalization", + "unicode_names2 1.3.0", +] + +[[package]] +name = "rustpython-ruff_python_trivia" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79e7cfd1056f3a02ff0d2d0e4474286ca963260782f878b7b81c1dd87432e682" +dependencies = [ + "itertools", + "rustpython-ruff_source_file", + "rustpython-ruff_text_size", + "unicode-ident", +] + +[[package]] +name = "rustpython-ruff_source_file" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "948107aad62ddb12a11fc7bf68a49e52a0b0a3737d415a2505e54f5a9edac737" +dependencies = [ + "memchr", + "rustpython-ruff_text_size", +] + +[[package]] +name = "rustpython-ruff_text_size" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8291ee0f5a779e54ccd4e0151a0c426f8b49a123f99b5b6545db17ccdd4277aa" +dependencies = [ + "get-size2", +] + +[[package]] +name = "rustpython-sre_engine" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad4b7bd82b2a531f7c2ad96864ce11285959fc7d8524f1ba54db8ce3a23a3d2c" +dependencies = [ + "bitflags", + "num_enum", + "optional", + "rustpython-wtf8", +] + +[[package]] +name = "rustpython-vm" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1880770161cef896bf9c7e065dae574e3b4c3cf6172e485f1ce86f0483816ab2" +dependencies = [ + "ahash 0.8.12", + "ascii", + "bitflags", + "bstr", + "caseless", + "cfg-if", + "chrono", + "constant_time_eq", + "crossbeam-utils", + "errno", + "exitcode", + "getrandom 0.3.4", + "glob", + "half", + "hex", + "indexmap", + "is-macro", + "itertools", + "junction", + "libc", + "libffi", + "libloading 0.9.0", + "log", + "malachite-bigint", + "memchr", + "nix", + "num-complex", + "num-integer", + "num-traits", + "num_cpus", + "num_enum", + "optional", + "parking_lot", + "paste", + "psm", + "result-like", + "rustix", + "rustpython-codegen", + "rustpython-common", + "rustpython-compiler", + "rustpython-compiler-core", + "rustpython-derive", + "rustpython-literal", + "rustpython-ruff_python_ast", + "rustpython-ruff_python_parser", + "rustpython-ruff_text_size", + "rustpython-sre_engine", + "rustyline", + "scoped-tls", + "scopeguard", + "static_assertions", + "strum", + "strum_macros", + "thiserror 2.0.20", + "timsort", + "uname", + "unic-ucd-bidi", + "unic-ucd-category", + "unic-ucd-ident", + "unicode-casing", + "which", + "widestring", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustpython-wtf8" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada88d2f69ff5516d69e0f3294e9db2ff8ee71a15291b8f3f8584f07ad1ca28d" +dependencies = [ + "ascii", + "bstr", + "itertools", + "memchr", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "rustyline" +version = "17.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e902948a25149d50edc1a8e0141aad50f54e22ba83ff988cf8f7c9ef07f50564" +dependencies = [ + "bitflags", + "cfg-if", + "clipboard-win", + "fd-lock", + "home", + "libc", + "log", + "memchr", + "nix", + "radix_trie", + "unicode-segmentation", + "unicode-width", + "utf8parse", + "windows-sys 0.60.2", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "safe_arch" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42c6efa15875e6ecb39ca61fb0b0c1a40b84fac5a5ffe71eef7d1000c8eb3f5f" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "seahash" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-reflection" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af0fdf9c543af37502581e3d6cf4e6cd1628d6616d6a1f8f4ca88aae3a405f5c" +dependencies = [ + "erased-discriminant", + "once_cell", + "serde", + "serde_json", + "thiserror 1.0.69", + "typeid", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn-ext" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b126de4ef6c2a628a68609dd00733766c3b015894698a438ebdf374933fc31d1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "textwrap" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ecfad6c3abc80a577f2b91c1e412ee57e7a060d430b553c1b0c940974ebcd49" + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "timsort" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "639ce8ef6d2ba56be0383a94dd13b92138d58de44c62618303bb798fa92bdc00" + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "toml" +version = "1.1.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "twox-hash" +version = "2.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5283634e518fe9e82c7b20520bb4bc209009fd16c82077c802f8111ecbb0117a" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "uname" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b72f89f0ca32e4db1c04e2a72f5345d59796d4866a1ee0609084569f73683dc8" +dependencies = [ + "libc", +] + +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-ucd-bidi" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1d568b51222484e1f8209ce48caa6b430bf352962b877d592c29ab31fb53d8c" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-category" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b8d4591f5fcfe1bd4453baaf803c40e1b1e69ff8455c47620440b46efef91c0" +dependencies = [ + "matches", + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-ident" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + +[[package]] +name = "unicode-casing" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "061dbb8cc7f108532b6087a0065eff575e892a4bcb503dc57323a197457cc202" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode_names2" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1673eca9782c84de5f81b82e4109dcfb3611c8ba0d52930ec4a9478f547b2dd" +dependencies = [ + "phf 0.11.3", + "unicode_names2_generator 1.3.0", +] + +[[package]] +name = "unicode_names2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d189085656ca1203291e965444e7f6a2723fbdd1dd9f34f8482e79bafd8338a0" +dependencies = [ + "phf 0.11.3", + "unicode_names2_generator 2.0.0", +] + +[[package]] +name = "unicode_names2_generator" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b91e5b84611016120197efd7dc93ef76774f4e084cd73c9fb3ea4a86c570c56e" +dependencies = [ + "getopts", + "log", + "phf_codegen", + "rand", +] + +[[package]] +name = "unicode_names2_generator" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1262662dc96937c71115228ce2e1d30f41db71a7a45d3459e98783ef94052214" +dependencies = [ + "phf_codegen", + "rand", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "which" +version = "8.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bae2f2b2b816647a1cab1acc91f5bd20812d53cb344382635ec2181940c8034f" +dependencies = [ + "libc", +] + +[[package]] +name = "wide" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d920ac99c3c8edce110cb8d07dbb324d6d026011dce85b1e9355b70f0adacc4f" +dependencies = [ + "bytemuck", + "safe_arch", +] + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/plugins/opencad-python/Cargo.toml b/plugins/opencad-python/Cargo.toml new file mode 100644 index 000000000..600cd2d5b --- /dev/null +++ b/plugins/opencad-python/Cargo.toml @@ -0,0 +1,47 @@ +[package] +name = "opencad-python" +version = "0.1.0" +edition = "2021" +description = "Python scripting for Open CAD Studio (embeds RustPython)." +license = "GPL-3.0-only" + +[workspace] +resolver = "2" + +# A plugin is a C-ABI dynamic library the host loads at runtime. +[lib] +crate-type = ["cdylib"] + +[features] +default = [] +# Requires an external HostApi patch providing run_command/set_selection. +# Never enable against the released OCS host: nested command replay can hang. +experimental-command-replay = [] +# Requires the general HostApi system-variable methods in this OCS fork. +experimental-host-settings = [] +# Requires the in-tree OCS host API v7. +experimental-host-model = ["dep:serde_json"] + +[dependencies] +# The stable contract, with the runtime `host` surface (HostApi / BuiltinPlugin). +ocs_plugin_api = { path = "../../crates/ocs_plugin_api", features = ["host"] } +# Embedded interpreter for Phase 1 (see this crate's PLUGIN.md and ROADMAP.md). +# default-features = false, no "host_env": that default feature bakes `posix` +# (full filesystem access, confirmed: open('/etc/passwd').read() works with +# it on) and `_ctypes` (raw native-code-calling primitives) into every +# interpreter unconditionally, regardless of never adding rustpython-stdlib — +# see this crate's PLUGIN.md's sandboxing section for the audit that found +# this. "compiler" is required (Vm::compile); "gc" matches the default set +# for normal memory behavior. "wasmbind"/"stdio" are irrelevant off-web. +rustpython-vm = { version = "0.5", default-features = false, features = ["compiler", "gc"] } +serde_json = { version = "1", optional = true } + +[build-dependencies] +# No "host" feature: `type_registry`/`type_registry_types` (the only modules +# build.rs touches, via `get_embedded_type_registry_json`) are unconditionally +# compiled and depend on nothing but `serde` — the "host" feature only adds +# the acadrust-typed runtime surface build.rs has no use for. See this crate's +# own `src/lib.rs` doc comment on the module split. +ocs_plugin_api = { path = "../../crates/ocs_plugin_api" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/plugins/opencad-python/PLUGIN.md b/plugins/opencad-python/PLUGIN.md new file mode 100644 index 000000000..ef2c33ad1 --- /dev/null +++ b/plugins/opencad-python/PLUGIN.md @@ -0,0 +1,563 @@ +# Python Scripting (`opencad.python`) + +## Sandboxing (Phase 1.5) + +**Audited, not assumed — and the assumption was wrong.** Earlier phases +claimed "no stdlib is loaded, so `import os`/`socket`/`subprocess` is +already impossible." True for those three names, but incomplete: RustPython +0.5.0's `host_env` Cargo feature — **on by default**, and `rustpython-vm = +"0.5"` in this crate's `Cargo.toml` used to pull in the full default set — +bakes `posix`/`_ctypes` (and `os`/`nt` on other platforms) into every +interpreter's *core* module registry, entirely independent of ever adding +the separate `rustpython-stdlib` crate. Confirmed by actually running it, +not by reading feature flags: with `host_env` on, `PY_EVAL +__import__('posix').getcwd()` and `PY_EVAL open('/etc/passwd').read()` both +worked — full filesystem read (and write) access from any script. + +**Fixed**: `Cargo.toml` now depends on `rustpython-vm` with +`default-features = false, features = ["compiler", "gc"]` — no `host_env`. +Re-verified the full matrix after the fix, all blocked +(`ModuleNotFoundError` or, for `open()`, `UnsupportedOperation`): +`os`, `posix`, `nt`, `subprocess`, `socket`, `ctypes`, `_ctypes`, `_socket`, +`_subprocess`, `_signal`, `pwd`, and both read and write through the +built-in `open()` — its `FileIO` backing turned out to depend on +`host_env`-gated code internally too, even though the `_io` module itself +is always registered. Confirmed no functional regression: `ocs.add_line` +and the full `examples/example.py` (including both real constraints) still +run correctly with `host_env` off. + +What's left in `sys.modules` after that fix, for a fresh interpreter — +audited exhaustively, not just "fewer than before": `_ast`, `_codecs`, +`_frozen_importlib`, `_imp`, `_io`, `_thread`, `_typing`, `_warnings`, +`_weakref`, `builtins`, `codecs`, `encodings_ascii`, `encodings_utf_8`, +`ocs`, `sys` — core language/import/codec plumbing needed to run Python at +all, plus `ocs`. None of it reaches the filesystem, network, or another +process. + +**One known, accepted residual gap, not silently ignored: `_thread` is +real and functional** (`_thread.start_new_thread` exists and is callable, +verified — not a stub). It's registered unconditionally in `rustpython-vm` +0.5.0's `stdlib/mod.rs` with no feature guard at all — not `host_env`, not +even its own `threading` Cargo feature — so there is no dependency-level +way to remove it short of patching RustPython itself, which is out of +scope here. It grants no filesystem/network/process access (everything +that would matter for *that* is confirmed blocked above); the realistic +risk is a script spawning a thread that busy-loops or otherwise misbehaves +inside the plugin's own process — a resource/stability concern local to +the already crash-isolated plugin process (per `plugin-architecture.md`'s +out-of-process design), not a host compromise or data exfiltration path. +Deleting `_thread` from `sys.modules` after interpreter setup was +considered and rejected as a fix: the module stays *registered* at the VM +level regardless, so a script could just re-import it — the deletion would +be security theater, not a real control. + +## Commands + +| Command | Description | +|---------|-------------| +| `PY_EVAL ` | Evaluate one Python expression and print the result (or error) to the command line. Example: `PY_EVAL 1 + 1`. | +| `PY_RUN ` | Run a `.py` file as a script (statements, not just one expression). Reports an error the same way as `PY_EVAL` (missing file, syntax error, exception) — never a silent no-op. | + +## The `ocs` module + +Pre-bound as `ocs` in both commands — no `import` needed (and for `PY_EVAL`, +none is possible: it compiles in `Mode::Eval`, which only accepts a single +expression, not statements). Phases 1.2 (read), 1.3 (2D write) and part of +1.4 (`Run Script…`) of this plugin's `ROADMAP.md`. + +| Function | Returns | +|----------|---------| +| `ocs.selection()` | List of the selected entities' handles (`int`), as of the last selection change — see note below. | +| `ocs.get(handle)` | Dict of `{handle, kind, layer, point}` for that entity, or `None` if it doesn't exist. `point` is `{x, y, z}` or `None` for non-point entities. | +| `ocs.get_text(handle)` | For single-line TEXT, returns its active alignment point, OCS normal, rotation, direction, and height; otherwise `None`. Reads the host's full document snapshot. | +| `ocs.text_entities()` | Lists top-level TEXT/MTEXT entities as `{handle, kind, value}` dictionaries from the active document. | +| `ocs.block_references()` | Lists top-level INSERT entities as `{handle, name, effective_name, count, is_xref, is_dynamic, visibility}` dictionaries; `count` includes MINSERT row × column multiplicity and unresolved visibility is `None`. | +| `ocs.layers()` | Lists active-document layer names, off/frozen/locked flags, and top-level entity counts. | +| `ocs.set_entity_layer(handle, layer_name)` | Moves an existing top-level entity to an existing layer, preserving its handle and layout ownership. | +| `ocs.export_layer_dwg(layer_name, output_path)` | Writes a new DWG containing one layer's top-level entities plus needed block definitions; requires an existing directory and refuses overwrite. | +| `ocs.current_layer()` | Returns the active document's current layer name (`CLAYER`). | +| `ocs.layout_for_entity(handle)` | Returns an entity's owning layout name, tab order, and paperspace sheet count; `None` when unresolved. | +| `ocs.viewport_model_outline(handle)` | Returns four XY modelspace corners and elevation for a rectangular, unclipped +Z paperspace viewport; otherwise `None`. | +| `ocs.add_closed_polyline(vertices, elevation)` | Adds a closed lightweight polyline from at least three finite `[x, y]` vertices. | +| `ocs.text_box_vertices(handle, padding)` | Returns an approximate rotated XY box for left/baseline +Z TEXT; otherwise `None`. | +| `ocs.update_closed_polyline(handle, vertices, elevation)` | Replaces a closed lightweight polyline's vertices/elevation, preserving its handle and common properties. | +| `ocs.copy_model_to_paper(handles, viewport_handle)` | Copies planar modelspace LINE/ARC/CIRCLE/LWPolyline geometry through a rectangular +Z viewport to its paperspace layout. | +| `ocs.mirror_xy_entities(handles, axis_handle, erase_source)` | Mirrors planar LINE/ARC/CIRCLE/LWPolyline entities across an existing XY LINE; optionally removes sources in the same undo group. | +| `ocs.mirror_xy_about_line(handles, point, tangent, erase_source)` | Mirrors planar curves across the XY line through a point in a tangent direction; supports curved-axis midpoint workflows. | +| `ocs.break_xy_line(handle, first, second)` | Removes the span between two interior XY points on a LINE, preserving its first fragment's handle and properties. | +| `ocs.break_xy_arc(handle, first, second)` | Removes the angular span between two interior XY ARC points, retaining the first fragment's handle. | +| `ocs.break_xy_circle(handle, first, second)` | Replaces an XY CIRCLE with the complementary ARC after removing the counterclockwise span from first to second. | +| `ocs.break_xy_polyline(handle, first, second)` | Removes a path interval from an open, straight, zero-width XY LWPolyline, keeping the first fragment's handle. | +| `ocs.align_xy_entities(handles, source, destination, source_direction, target_direction, copy_mode)` | Rigidly moves and rotates planar LINE/ARC/CIRCLE/LWPolyline entities in XY; `copy_mode=True` adds transformed copies instead of changing originals. | +| `ocs.block_insert_point(handle)` | Returns `[x, y, z]` for an INSERT, or `None`. | +| `ocs.block_bounds_xy(handle)` | Returns `[xmin, ymin, xmax, ymax]` for a simple unrotated single INSERT whose definition contains only LINE/ARC/CIRCLE/LWPolyline geometry; otherwise `None`. | +| `ocs.block_attributes(handle)` | Lists `{tag, value}` pairs for one INSERT, or `None` for another entity. | +| `ocs.set_block_attribute(handle, tag, value)` | Updates one INSERT attribute by case-insensitive tag, preserving the block's other data. | +| `ocs.set_text_value(handle, value)` | Replaces one TEXT/MTEXT entity's content, preserving other properties. Raises if the entity cannot be updated. | +| `ocs.set_mtext_mask(handle, mode, scale)` | Sets one MTEXT background mode (`off`, `mask`, or `fill`) and border scale; raises if invalid or not MTEXT. | +| `ocs.move_text(handle, x, y, z)` | Moves only a TEXT entity's active alignment point, preserving its other properties. Raises if it cannot update the entity. | +| `ocs.set_text_pose(handle, x, y, z, rotation)` | Moves and rotates one XY TEXT entity while preserving its other properties. | +| `ocs.get_area(handle)` | Returns `{area, point}` for a circle or straight, closed lightweight polyline in the world XY plane; otherwise `None`. `point` is suitable for an area-number label. | +| `ocs.get_curve_measure(handle)` | Returns `{length, point, rotation, tangent}` at half length for an XY line, circle, arc, or straight lightweight polyline; `tangent` is an XY unit vector. Otherwise `None`. | +| `ocs.line_endpoints(handle)` | Returns two `[x, y, z]` points for a nonzero XY LINE; otherwise `None`. | +| `ocs.polyline_segments(handle)` | Returns segment endpoint, width, length, and optional bulge-arc centre/radius dictionaries for an XY lightweight polyline; otherwise `None`. | +| `ocs.write_new_text_file(path, content)` | Writes a new UTF-8 report file at an explicit path during a `PY_` command. Refuses to overwrite or create parent directories. | +| `ocs.curve_samples(handle, segments)` | Returns equally spaced XY sample vertices, elevation, and closure for a line, arc, or circle; otherwise `None`. | +| `ocs.replace_with_polyline(handle, vertices, elevation, closed)` | Replaces a line, arc, or circle in place with a lightweight polyline, preserving the handle and common drawing properties. | +| `ocs.add_line(x1, y1, x2, y2)` | Adds a 2D line (z=0, layer `"0"`); returns its handle. | +| `ocs.add_circle(x, y, radius)` | Adds a 2D circle (z=0, layer `"0"`); returns its handle. | +| `ocs.add_arc(x, y, radius, start_deg, end_deg)` | Adds a 2D arc (z=0, layer `"0"`), angles in degrees; returns its handle. | +| `ocs.add_polyline(vertices, elevation)` | Adds an open 2D lightweight polyline. Each vertex is `[x, y, start_width, end_width]`; returns its handle. | +| `ocs.add_text_centered(value, x, y, z, height, rotation_radians)` | Adds middle-center aligned single-line text; returns its handle. | +| `ocs.add_text_left(value, x, y, z, height, rotation_radians)` | Adds left/baseline single-line text at its insertion point; returns its handle. | +| `ocs.add_mtext(value, point, height, width, rotation_radians)` | Adds top-left anchored MTEXT at `[x, y, z]` using Standard style; returns its handle. | +| `ocs.add_points(points)` | Adds many 3D points (each `[x, y, z]`) in one `HostApi::add_entities` batch call; returns the new handles in order. | +| `ocs.remove_entity(handle)` | Deletes one entity in the script's undo group; raises if the handle cannot be removed. | +| `ocs.add_table(rows, x, y, z, row_height, column_width)` | Adds a native table from a rectangular list of string rows; returns its handle. | +| `ocs.read_record(handle, app_name)` | Returns `{app_name, values}` for the XDATA record `app_name` on `handle`, or `None` if the entity or the record doesn't exist. See "XDATA" below for the `values` shape. | +| `ocs.write_record(handle, app_name, values)` | Attaches an XDATA record to `handle` for `app_name`, replacing any existing record for that application. Raises if `handle` doesn't exist. | +| `ocs.remove_record(handle, app_name)` | Removes the XDATA record for `app_name` from `handle`, if any. Returns `True` if a record was actually removed. | +| `ocs.command(cmd)` | Superseded. Only the `experimental-command-replay` build has it (the old fire-and-forget replay). Use `doc.command`, `doc.start_command` and `doc.modify` from the document model (see "Running OCS commands" below). | +| `ocs.select(handles)` | Replaces the selection with exactly these handles, in order (document-model build); `doc.selection = [...]` is the same. | +| `ocs.system_variable(name)` / `ocs.set_system_variable(name, value)` | Development-only `experimental-host-settings` feature. Reads or sets host-managed CLAYER (text) and SNAPANG (degrees) without nested command dispatch. Needs the host API v7 build that provides `system_variable`; absent from the portable default build. | + +`add_line`/`add_circle`/`add_arc` take plain Python numbers — +`ocs.add_line(0, 0, 10, 10)` works with ints, not just floats. (Needed an +explicit fix: a bare `f64` `#[pyfunction]` parameter in RustPython, unlike +CPython's C-function argument parsing, does *not* implicitly coerce an +`int` — it raises `TypeError`. Using `rustpython_vm::function::ArgIntoFloat` +instead accepts anything with `__float__`/`__index__`, matching how a script +author actually writes coordinates.) + +### Layers: `ocs.active_document.layers` + +The layer table, with every change validated by the host, recorded as one undo +step, and refused (a `RuntimeError`, nothing changed) when it cannot be honoured. + +```python +L = ocs.active_document.layers +L.create("Walls", color=1, lineweight=50, description="load bearing") +L.create("Grid", color=(10, 200, 30), linetype="Continuous", off=True) +L.modify("Walls", color=5, locked=True) # only the properties given +L.rename("Walls", "Structure") # entities on it follow +L.set_current("Grid") +L.delete("Structure", erase_objects=True) # refused if it holds objects otherwise +"Grid" in L, L["Grid"]["color"], L.current, L.names() +``` + +Properties: `color` (ACI 1-255, an `(r, g, b)` tuple or a Color dict), `linetype` +(must exist in the drawing), `lineweight` (1/100 mm, 0-211; -1 ByLayer, -2 ByBlock, +-3 Default), `off`, `frozen`, `locked`, `plottable`, `transparency` (percent 0-90), +`description`. Records also carry `handle`, `entity_count` and `current`. +Refused: an existing or invalid name (empty, over 255 characters, or containing +`<>/\":;?*|=` or a backquote), a color of ByLayer/ByBlock/0, an unknown property, +renaming or deleting layer `0` or `Defpoints`, deleting or freezing the current +layer, deleting an externally referenced layer, and modifying with no properties. +Making a layer current is a setting, not an undo step. + +### Text and dimension styles: `doc.text_styles`, `doc.dim_styles` + +```python +S, D = ocs.active_document.text_styles, ocs.active_document.dim_styles +S.create("Title", height=5, width_factor=0.8, oblique=15, font="romans", annotative=True) +S.modify("Title", height=6) +D.create("Metric", dimscale=2, dimtxt=3.5, dimtxsty="Title") # DimStyle field names +D.create("Metric2", copy_from="Metric", dimtxt=4) +S.rename("Title", "Heading") # entities, dimension styles and tables follow +S.set_current("Heading"); D.set_current("Metric") +D.delete("Metric2"); S["Heading"]["height"]; D.current["name"]; S.names() +``` + +Same guarantees as layers: host-validated, one undo step, a refusal raises +`RuntimeError` and changes nothing (making a style current is a setting). +Text properties: `height` (0 = each text chooses), `width_factor` (0-100), +`oblique` (degrees, within 85), `font`, `big_font`, `backward`, `upside_down`, +`vertical`, `annotative`; use `font="arial.ttf"` for a TrueType face, because +the codec does not persist a separate family name. Dimension styles use the +`DimStyle` field names (`dimscale`, `dimtxt`, `dimasz`, ...); records list all +of them. Handles, `xref_*` fields and `name` are host-managed and refused, unknown +fields and wrongly typed values are refused, `dimscale` and `dimtxt` must be +positive, and `dimtxsty` must name an existing text style. `Standard` is never +renamed or deleted; a style that is current or still referenced is never deleted; +a case-only rename is refused. Style operations act on the active document. + +Known persistence limits (cadcodec, see `docs/cadcodec-reader-gaps.md`; the first two are fixed in +an open upstream pull request but OCS has not yet adopted it): a DXF +save drops the backward/upside-down flags of a text style and does not restore a +dimension style's `dimtxsty` name (the handle link survives); DWG keeps both. + +### Blocks: `ocs.active_document.blocks` + +```python +doc = ocs.active_document +line = doc.create_entity("Line", start=..., end=...) +circle = doc.create_entity("Circle", center=..., radius=1) +B = doc.blocks +B.create("Widget", [line, circle], base_point=(1, 1, 0), description="a widget") +doc.create_entity("Insert", block_name="Widget", insert_point={"x": 10, "y": 0, "z": 0}) +B.modify("Widget", explodable=False) +B.rename("Widget", "Gadget") # every insert follows +B["Gadget"]["entities"], B["Gadget"]["insert_count"], B.names() +B.delete("Gadget") # refused while an insert, style or leader uses it +``` + +`create` copies model or paper space entities (descriptors or handles) into a new +definition shifted by `-base_point`, so an insert at `p` puts the base point at +`p`. `erase_originals=True` removes the sources, as the BLOCK command does; the +default keeps them. No insert is placed. Refused: an empty, duplicate, invalid or +`*`-prefixed name, no entities, a missing or repeated entity, an entity inside a +block, a viewport, a non-finite base point, renaming or deleting layout, +anonymous or externally referenced blocks, and a case-only rename. Deleting also +removes the definition's contents. Records list user blocks only (`handle`, +`name`, `description`, `explodable`, `scale_uniformly`, `base_point`, +`insert_count`, `entities` as `{handle, kind}`). A DXF save drops the block +description (cadcodec); DWG keeps it. + +**Block contents.** Add to a definition with `create_entity(kind, block="Part", +...)`. It is validated exactly like a model-space entity and the host sets the +owner (so attribute definitions work: `create_entity("AttributeDefinition", +block="Part", tag=..., prompt=..., default_value=..., insertion_point=..., +height=...)`). Members are read, edited (`entity.end = (8, 0, 0)` inside +`doc.transaction(...)`) and deleted (`doc.delete_entity`) with the ordinary entity +API, using the handles in `blocks["Part"]["entities"]`. Refused: an unknown, +layout, anonymous or external block, an invalid entity, a viewport, raster image, +block marker or nested attribute, an entity on a locked layer, and an insert that +would nest a block inside itself directly or through other blocks. Adding while the +block editor is open is refused. Each add or delete is one undo step. + +### Linetypes and layouts: `doc.linetypes`, `doc.layouts` + +```python +doc = ocs.active_document +LT, LY = doc.linetypes, doc.layouts +LT.create("Bracket", [12, -3, 2, -3], "bracket line") # dash 12, gap 3, dash 2, gap 3 +LT.modify("Bracket", description="renamed"); LT.rename("Bracket", "Cut") +doc.layers.create("Cuts", linetype="Cut") +LT.delete("Cut") # refused while a layer uses it + +LY.create("Sheet1") +LY.set_page("Sheet1", paper_size=(420, 297), rotation=0, scale=(1, 50)) +LY.current = "Sheet1" # create_entity now draws on the sheet +doc.create_entity("Circle", center={"x": 50, "y": 50, "z": 0}, radius=5) +LY.current = "Model" +LY.rename("Sheet1", "Plan"); LY.delete("Plan") # deletes the sheet's entities too +LY.names(), LY.current["name"], LY["Plan"]["paper_size"] +``` + +Both follow the same rules as the other tables: host-validated, one undo step +(switching layout is not one), a refusal raises `RuntimeError` and changes +nothing. A linetype pattern is signed lengths (positive dash, negative gap, zero +dot; 2-12 elements with a gap and a dash or dot); text and shape linetypes can be +read (`complex`) but not created or edited. `Continuous`, `ByLayer` and `ByBlock` +are never changed, names of standard linetypes are taken, renaming updates the +layers and entities that use it, and a linetype used by a layer, entity, dimension +style or the current setting is never deleted. Layouts: `Model` cannot be created, +renamed or deleted; deleting the current layout falls back to `Model`; a +case-only rename is refused. `set_page` takes the sheet in millimetres, a rotation +of 0/90/180/270 and a custom scale `(numerator, denominator)`, and updates the +layout limits. Layout operations act on the active document. Records list +`current`, `tab_order`, `entity_count`, `viewport_count`, `paper_size`, `rotation` +and `scale`. Reordering layouts and plot devices or styles are not available yet. + +### Running OCS commands: `doc.command`, `doc.start_command`, `doc.modify` + +Scripts can drive OCS's own tools. The host runs the real command, so it behaves +exactly as at the command line (same prompts, same geometry, same undo), and does +it synchronously on the editor thread, so a step never waits on outside input. + +```python +doc = ocs.active_document +doc.command("CIRCLE 5,5 3") # a whole line: tokens answer the prompts, then Enter +with doc.start_command("OFFSET") as c: # or answer prompt by prompt + c.text(2) # a typed value + c.entity(line, at=(5, 0, 0)) # an object pick + c.point((5, 4, 0)) # a point + c.enter() + print(c.outcome["status"], c.outcome["prompt"], c.outcome["added"]) + +M = doc.modify # the common tools as plain calls +M.offset(line, 2, side=(5, 5)) # returns the new entity +M.trim(target, at=(108, 0)); M.extend(target, at=(208, 0)) +M.fillet(first, second, radius=2) # radius 0 squares the corner +M.move([a, b], (0, 0, 0), (5, 5, 0)); M.copy([a], base, target) +M.rotate([a], base, 90); M.scale([a], base, 2) +M.mirror([a], p1, p2, erase_source=False); M.erase([a]) +M.chamfer(first, second, 2, 3) # distance 2 along first, 3 along second +M.array_rect([a], rows=2, columns=3, row_spacing=10, column_spacing=20) +M.array_polar([a], center, count=4, angle=360) # counts include the original +M.explode([polyline]); M.join([line1, line2]) +M.break_entity(line, first_point, second_point) +M.stretch(corner1, corner2, base, target) # crossing window, then the displacement +M.lengthen(line, 5, at=near_the_end) # delta; negative shortens +M.array_path([a], path_curve, count=5) # evenly along a curve +M.array_3d([a], rows=2, columns=2, levels=2, row_spacing=10, column_spacing=20, level_spacing=30) +M.polyline_close(pl); M.polyline_open(pl); M.polyline_reverse(pl) +M.polyline_width(line_or_pl, 0.5) # a line or arc is turned into a polyline first +M.polyline_join(pl, [line1, arc1]) # returns the merged polyline +``` + +Each step returns an outcome dict: `status` (`completed` or `waiting_input`), +`blocked_by`, `command`, `prompt`, `accepts` (`point`, `entity`, `selection`, +`text`, `token`, `enter`), `options` (keyword tokens), `entities`, `added`, +`unconsumed` and `error`. `c.point`, `c.text`, `c.token`, `c.entity`, +`c.selection`, `c.enter` and `c.cancel` map to those prompts; an `error` raises +`RuntimeError`, and leaving the `with` block cancels a command that is still waiting, +also closing any editor or dialog it opened. `doc.command` cancels and raises when +the line leaves a prompt open. + +Selection-based tools (`move`, `copy`, `rotate`, `scale`, `mirror`, `erase`, the arrays, +`explode` and `join`) select +the entities you pass first, so the command skips its own selection prompt. Pick +points default to the middle of a line, arc or circle and the first vertex of a polyline; give `at=` for anything else. +`trim` and `extend` cut or extend against every entity in the drawing. Each command is +undone as the editor would undo it. + +Guards: a command is refused while another is active (cancel it first) or when the +line names a command that could end the session, re-enter Python or change this +setting: `QUIT`, `EXIT`, `CLOSE`, `CLOSEALL`, `NEW`, `QNEW`, `OPEN`, `SAVE`, `QSAVE`, +`SAVEAS`, `SAVEALL`, `RECOVER`, `SCRIPT`, `RUNSCRIPT`, `SCRIPTCOMMANDS` and any `PY_*`. +The user can switch the whole facility off with the `SCRIPTCOMMANDS 0` command (`1` +turns it back on, plain `SCRIPTCOMMANDS` shows it); it is a saved preference, on by +default, and while it is off every `doc.command`, `start_command` and `doc.modify` call +raises `RuntimeError`. Only the command line can change it, never a script. Commands run on the active +document only. Commands that need an interactive surface a script cannot fill +(the rich-text editor, a file dialog) report it in `blocked_by` and are cancelled. +PEDIT's fit, spline, decurve and vertex-edit options are not wrapped (reach them with +`start_command("PEDIT")`); `polyline_*` return the polyline entity, which is a new +entity when the command replaced the original. Hatching has no wrapper: create it with `create_entity("Hatch", ...)`, because the +HATCH command needs an interactive boundary pick. `ocs.command_step(kind, options)` +is the raw single step behind all of this. + +### Historical command-replay experiment (not in the default build) + +The following records an earlier experiment and is kept for the findings, not as +instructions. It used a fire-and-forget `ocs.command()` that replayed a line through the +message loop; on OCS 2026.37 that could hang. It has been superseded by +`doc.command` / `doc.start_command` (see "Running OCS commands" above), which run the +real command synchronously through the host's `run_command` step API and were exercised +against every command tried without a hang. The prompt-sequence findings below (selection +prompts, `ALIGN`'s extra Enters) still describe how the commands behave. + +**`ocs.command()` + `ocs.select()` together can apply a real, persistent +constraint, entirely from a script** — this required two host changes +(`HostApi::run_command` and `HostApi::set_selection`, `ocs_plugin_api` +v0.2.1+ built from a worktree that isn't upstream yet — see the note at the +bottom of this file), not something achievable from the plugin alone. +Verified end-to-end against a real running host, not just "it compiles": +```python +ocs.select([line1_handle, line2_handle]) +ocs.command("PCONSTRAINT") +``` +run as a plain `.py` file via `PY_RUN` — no automation-API help, no manual +pre-selection — ran the actual geometric solver and visibly adjusted both +lines' coordinates to be genuinely parallel (checked numerically: +direction-vector cross product ≈ 0 after, nonzero before). A real, +persistent `PCONSTRAINT` object. Same result verified for `TCONSTRAINT` +(circle-to-line tangency): center-to-line distance equals the radius +exactly after. `examples/example.py` does both — its "parallel" lines and +"tangent" circle are real constraint objects, not geometry computed once. + +Why two calls, not one: constraint commands read a prior *selection* (a +`"Select objects:"` prompt), not picks fed as command-line tokens the way +`LINE`'s points are — feeding `ocs.command("PCONSTRAINT {h1} {h2}")` with +handles as trailing tokens starts the command but ends in "Command +cancelled", not a constraint. `ocs.select()` sets that selection state +first; `ocs.command()` then reads it. + +**Not every command completes cleanly this way — `ALIGN` is the found +counterexample.** `ocs.command()`'s point-feeding sends exactly *one* +trailing Enter after the fed tokens, to finish the command. `ALIGN` needs +two after its point pairs (skip the optional 3rd source/destination pair, +then answer a "Scale objects? [Yes/No]" question) — so +`ocs.command("ALIGN 0,0 20,20 2,0 22,22")` **does** apply the actual +move/rotate transform correctly (verified: the entities land at the right +transformed coordinates), but leaves that Scale prompt open instead of +cleanly finishing the command. Not attempted in `examples/example.py` — +shipping something that half-completes seemed worse than leaving it out. +A general fix would need `ocs.command()` to accept a trailing-Enter *count* +(or an explicit terminator convention) instead of always exactly one; +not implemented. + +**`ocs.command()` cannot invoke itself or any other plugin's command** — +only built-ins. This call is a nested plugin→host request arriving while the +plugin's own `dispatch()` is still running (its one runner thread is +blocked waiting for this exact call to return); routing it back through +normal plugin dispatch would hand that same plugin (or any plugin) a second +`Dispatch` request it has no free thread to answer, deadlocking both sides. +Found this the hard way — `ocs.command("LINE 0,0 10,10")` hung completely +before the host-side fix. `run_command` skips plugin dispatch entirely, by +design, not as an oversight. + +### Which built-in commands `ocs.command()` can reach + +`ocs.command()` goes through the exact same dispatcher as the interactive +command line — `HostApi::run_command` → the host's +`run_command_line_no_plugin_reentry` → `dispatch_command_inner(cmd, +allow_suggest=false, try_plugins=false)` — so almost everything typeable in +OCS also works from a script, minus one deliberate carve-out. + +**Reachable: the full built-in command set**, spread across ten dispatch +families in the host's `src/app/commands/` (`fileops`, `layers`, `blocks`, +`draw`, `dim`, `inquiry`, `view`, `layerprops`, `styleprops`, `display`). +That covers everything AutoCAD-compatible the host implements natively — +drawing entities and constraints (`LINE`, `CIRCLE`, `ARC`, `POLYLINE`, +`MOVE`, `ROTATE`, `ALIGN`, `PCONSTRAINT`, `TCONSTRAINT`, in `dispatch_draw`), +dimensions (`DIMLINEAR`, `DIMALIGNED`, `DIMSTYLE`, ...), blocks (`BLOCK`, +`INSERT`, `WBLOCK`, `ATTSYNC`, ...), layers/properties (`LAYER`, `LAYISO`, +`LAYDEL`, `CHPROP`, `PROPERTIES`, ...), file ops (`NEW`, `OPEN`, `SAVE`, +`SAVEAS`, `EXPORT...`, `PLOT`, ...), inquiry (`LIST`, `DIST`, `AREA`, +`DBLIST`, ...), view (`ZOOM`, `PAN`, `VIEW`, `REGEN`, ...), and +directly-settable system variables typed as commands (`PICKBOX 5`, +`MIRRTEXT 1`, `LUPREC 4`, ...). Aliases still resolve too (`ocs.command("L +0,0 10,10")` works the same as `LINE`), since alias expansion happens before +the plugin-skip. + +**Not reachable: any plugin-provided command, including this plugin's own** +(`PYTHONSHELL`, `PY_EVAL`, `PY_RUN`) — see the paragraph above for why. This +is the *only* restriction versus the interactive command line; there is no +narrower allow-list beyond it. `allow_suggest=false` also means no +autocomplete/fuzzy-matching of partial verbs (unlike typing `BAC` and +pressing Enter for `BACKGROUND`) — a script must spell out the exact command +name or a real alias. + +Usage notes already verified end-to-end (see the constraint example above +and `examples/example.py`): + +- Multi-point/interactive commands take their args inline on one string, + exactly as if typed then Enter: `ocs.command("LINE 0,0 10,10")`. +- A command that reads a *prior selection* rather than inline picks + (constraint commands are the concrete case) needs `ocs.select(handles)` + called first — `ocs.command()` alone can't feed a "Select objects:" + prompt. +- `ALIGN` needs two trailing Enters (skip the optional 3rd point pair, then + answer the Scale prompt); `ocs.command()` only ever sends one, so it + applies the transform but leaves that prompt dangling — see above. + +For the exhaustive, exact list rather than this summary, the source of +truth is the `inventory::submit!(CommandRegistration { names: &[...] })` +blocks scattered across the host's `src/app/commands/*.rs` (105 files +register at least one command as of this writing); the largest single block +— everything without its own interactive command module — is +`src/app/commands/mod.rs`'s command-line autocomplete registry. Since it's +the same dispatcher, OCS's own command-line autocomplete is also a live +index of what `ocs.command()` can run. + +**One `push_undo` group per script, not per `ocs.add_*` call.** A `PY_RUN` +script that calls `ocs.add_line` ten times undoes as one step. Verified +end-to-end: a two-line script undoes both lines in a single `undo`. +Implemented via `host_ctx::ensure_undo_started`, called lazily by the first +write in a given `PY_EVAL`/`PY_RUN` — a read-only script never touches the +undo stack. Note: `ocs.command()` does *not* go through this — a built-in +command manages its own undo the same way it would if typed, so `ocs.command` +calls are not folded into the script's shared undo group. + +**`ocs.selection()` is a cache, not a live query.** `HostApi` has no +synchronous "give me the current selection" call — only a best-effort +`SelectionChanged` / `SelectionChangedV4` notification. It reflects the +selection as of the last time it changed, not necessarily the instant the +script calls it. + +That cache is populated via `BuiltinPlugin::on_notification`, **not** by +calling `HostApi::try_recv_notification()` from `dispatch()`. Found the hard +way: `ocs_plugin_api::runner`'s own V4 event loop (`run_v4` in `runner.rs`) +drains that queue itself on every iteration and forwards each notification to +`on_notification` *before* a `Dispatch` request ever reaches `dispatch()` — so +polling it from inside `dispatch` always finds it empty. This plugin +overrides `on_notification` and stashes the latest selection in a +process-wide cache (`selection_cache.rs`); `ocs.selection()` reads that. +Verified end-to-end against a real host build: select an entity, then +`PY_EVAL ocs.selection()` returns its handle. + +Each `PY_EVAL`/`PY_RUN` runs in a fresh interpreter (no stdlib, no session +state across calls) — REPL/session persistence is an open question for a +later phase. + +**Typing or pasting `PY_EVAL`/`PY_RUN` directly into the interactive command +line is broken for anything beyond trivial arithmetic — a host issue, not +this plugin.** `Message::CommandInput` in `src/app/update/mod.rs` (this +repo) uppercases the *entire* input line on every keystroke *and* on paste, +and auto-submits the instant it contains a space (both deliberate, for +classic single-line CAD commands like `LINE 0,0 10,10`). That mangles case +(`ocs.add_line` → `OCS.ADD_LINE`, a `NameError`) and truncates arguments +(`PY_EVAL ` submits before you finish typing the expression) for *any* +multi-word, case-sensitive plugin argument — automation +(`ocs_execute`/`--mcp`) bypasses this UI path entirely, which is why it +never surfaced in this session's own testing until a live user tried typing +it by hand. **`Run Script…` (below) sidesteps this correctly** — the host +authors already solved the identical problem for file paths via +`ModuleEvent::PluginFileDialog`, which dispatches with original case intact. +There is no equivalent bypass for `PY_EVAL` today; typed/pasted `PY_EVAL` +only survives if it has no spaces and no case-sensitive identifiers. + +## Ribbon + +Two tools in the "Tools" group: + +| Tool | Behavior | +|------|----------| +| Eval 1+1 | Dispatches `PY_EVAL 1 + 1` directly — a smoke test. | +| Run Script… | `ModuleEvent::PluginFileDialog` opens a native "pick a `.py` file" dialog; on selection the host dispatches `PY_RUN ` back to the plugin. Cancel does nothing. | + +**A real script manager UI (list/run/save scripts, or a dockable REPL +console) is not possible with today's `ocs_plugin_api`.** This was an open +question in `ROADMAP.md` — now answered by reading +`ribbon.rs`: `CadModule` exposes exactly one hook, `ribbon_groups()`, which +returns a **static** set of ribbon buttons/dropdowns (`RibbonItem`). There is +no API for a plugin to host a persistent custom panel, list dynamic content, +or render arbitrary UI — the ribbon vocabulary is deliberately "plain data +the host renders," not a widget toolkit. Getting a script list or REPL panel +would need a new `HostApi`/`CadModule` capability, i.e. an `ocs_plugin_api` +version bump — a host-side feature, out of scope for a plugin repo. `Run +Script…` (above) is the most a plugin can do today: reuse the host's own +native file picker via the existing `PluginFileDialog` event, which was +already there for other plugins to import files. + +## AutoLISP migration + +An AutoLISP-to-Python translator is a separate project and is not part of this +repository: scripts written for this plugin use the `ocs` module and the document +model described above, and a translated macro would run through `PY_RUN` like any +other script. Nothing in the plugin depends on it. + +## XDATA + +`ocs.read_record`/`ocs.write_record`/`ocs.remove_record` wrap +`HostApi::read_record`/`write_record`/`remove_record` — already implemented +by the host (unlike `run_command`/`set_selection`, these needed no host +change or `[patch]` entry). A record is `{"app_name": str, "values": [...]}`; +each value is `{"kind": str, "value": ...}`. Supported kinds and their +Python `value` shape: + +| `kind` | Python `value` | +|---|---| +| `String`, `ControlString`, `LayerName` | `str` | +| `BinaryData` | `bytes` | +| `Handle` | `int` | +| `Point3D`, `Position3D`, `Displacement3D`, `Direction3D` | `[x, y, z]` (`float`) | +| `Real`, `Distance`, `ScaleFactor` | `float` | +| `Integer16`, `Integer32` | `int` | + +```python +ocs.write_record(handle, "MYAPP", [ + {"kind": "String", "value": "part-042"}, + {"kind": "Integer32", "value": 7}, +]) +record = ocs.read_record(handle, "MYAPP") # {"app_name": "MYAPP", "values": [...]} +ocs.remove_record(handle, "MYAPP") +``` + +This `{app_name, values}`/`{kind, value}` shape is deliberately the same one +`schoeller/ocs_python_repl` (a separate, out-of-process PyO3-based Python +plugin for OCS) uses in its `ocs.doc.read_record`/`write_record`, so a +script's XDATA handling ports between the two plugins unchanged. `write_record` +starts the script's shared undo group the same way `ocs.add_*` does (see +below); `remove_record` only starts it when a record actually existed to +remove. + +## Build note: bundled host API + +`Cargo.toml` uses the repository-relative `ocs_plugin_api` crate. Packaging +builds the plugin with the same compiler and CAD codec as the host and stages +matching metadata beside the library. The `experimental-command-replay` Cargo +feature keeps the old bridge code available, but the tested nested-command +path can hang. Do not enable it for production. diff --git a/plugins/opencad-python/README.md b/plugins/opencad-python/README.md new file mode 100644 index 000000000..cf29cf2f9 --- /dev/null +++ b/plugins/opencad-python/README.md @@ -0,0 +1,129 @@ +# opencad-python + +Python scripting for Open CAD Studio, shipped as a bundled first-party plugin. Embeds +[RustPython](https://github.com/RustPython/RustPython) and exposes an `ocs` +module wrapping `HostApi`, so `PY_`-prefixed commands can run Python against +the open document. + +The crate remains an independent process plugin under `plugins/` so Python +cannot crash the editor process and all writes still cross the versioned +`HostApi`. Its API dependency is repository relative, and the macOS packaging +flow builds and places it in the application bundle automatically. + +Full design and phased plan: `ROADMAP.md` in this directory. +Command reference, the `ocs` module's functions, and every real gap/limit +found while building this: `PLUGIN.md` in this directory. + +## Status + +The API v7 build provides `PY_EVAL`/`PY_RUN`, the read/write `ocs` module, and +the ribbon integration. On top of that, the `experimental-host-model` feature +(the name stays until a later API cleanup; the staged bundled plugin is built with +it) adds a general Python document model over the host API: + +- **Entities:** create, read, edit, delete and undo for all 43 canvas kinds + (41 creatable), validated by the host, with per-kind DWG/DXF round-trip audits. +- **Drawing tables:** `doc.layers`, `text_styles`, `dim_styles`, `blocks` (and their + contents), `linetypes` and `layouts`, plus kernel-backed solids (`doc.solids`). +- **Commands:** `doc.command`, `doc.start_command` and `doc.modify` (offset, trim, + extend, fillet, chamfer, move, copy, rotate, scale, mirror, arrays, explode, join, + break, stretch, lengthen, PEDIT polyline edits) drive the real OCS commands. + +The design and guarantees are in `../../docs/plugin-host-model.md`, the per-kind +evidence in `../../docs/plugin-host-model-coverage-ledger.md`, and the script-facing +reference in `PLUGIN.md`. The older experimental `ocs.command()` replay is +superseded and not in the default build. + +## Files + +| File | Purpose | +|------|---------| +| `Cargo.toml` | `cdylib` crate depending on `ocs_plugin_api` (`host` feature) and `rustpython-vm` (stdlib disabled — see `PLUGIN.md`'s sandboxing section) | +| `src/lib.rs` | manifest + `CadModule` ribbon + `BuiltinPlugin` + `export_plugin!` + `PY_EVAL`/`PY_RUN` dispatch | +| `src/ocs_module.rs` | the `ocs` Python module (`selection`, `get`, drawing access; experimental `command`/`select` are feature-gated) | +| `src/host_ctx.rs` | thread-local bridge from `dispatch()`'s `HostApi` into the `#[pymodule]` native functions | +| `src/selection_cache.rs` | process-wide selection cache, populated via `on_notification` | +| `src/document_model.py` | API v7 Python document view and transaction context manager | +| `src/event_cache.rs`, `src/input_cache.rs` | bounded event queue and interactive-pick results | +| `tools/stage-bundled.sh` | build and stage the matching API v7 package | +| `plugin.toml` | metadata read by the host (mirrors the manifest) | +| `examples/example.py` | a script demonstrating shapes plus two real, persistent constraints | +| `../../.github/workflows/release.yml` | bundles the cdylib into the macOS application | +| `PLUGIN.md` | command reference, the `ocs` module, and every real gap/limit found | + +## Build + +```sh +cargo build --release --locked --features experimental-host-model \ + --manifest-path plugins/opencad-python/Cargo.toml +``` + +## Stage the bundled plugin + +The staging script builds against the in-tree `ocs_plugin_api`, writes exact +compiler and CAD codec metadata, and creates the directory layout consumed by +bundled plugin discovery. + +```sh +bash plugins/opencad-python/tools/stage-bundled.sh /path/to/stage-directory +``` + +Add `--debug` for a faster local build. Build with the same Rust toolchain as +the OCS binary; the host checks the compiler version when loading plugins. + +```python +doc = ocs.active_document +line = doc.create_entity("Line", start={"x": 0, "y": 0, "z": 0}, + end={"x": 10, "y": 0, "z": 0}) +line = doc.entities[handle] +print(line.start, line.end, line.layer, line.coverage) +with doc.transaction("Move line"): + line.start = (0, 0, 0) + line.end = (10, 0, 0) + +doc.selection = [line] +token = doc.request_point("Pick destination") +# After the host has collected the click, run another script: +result = doc.poll_input(token) +events = doc.poll_events() +doc.delete_entity(line) +``` + +`doc.entities` iterates every document entity. The generated Python schema +currently edits Point, Line, Circle, Arc, Ellipse, Polyline, Polyline2D, +Polyline3D, LwPolyline, Spline, Text, MText, Ray, XLine, Solid, Face3D, +Insert, Tolerance, Shape, AttributeDefinition, AttributeEntity, and Hatch. +Insert block references support edits to placement and transform +properties; their block name is readable and creation is not yet exposed. +Other canvas kinds expose +handle, kind, and layer; their layer can be changed in a transaction. Internal +and opaque kinds remain read-only. Each +mapped entity reports its actual readable/editable keys through `coverage`. +The host-owned catalog classifies all 48 entity variants and tracks every +traced field, including fields marked `unmapped`. Query it with +`doc.coverage()` or `doc.coverage("Line")`; each row includes source field, +type, access, and current validation status. Geometry edits remain limited to +the 43 mapped kinds. +For any entity, `entity.snapshot` returns a detached dictionary of its raw +CAD fields, including `snapshot["common"]` and kind-specific nested values. +It can inspect an unmapped kind; editing the dictionary does +not commit changes to the drawing. +Point and entity requests return a token because `PY_RUN` uses a fresh +interpreter for each invocation; `poll_input` reports pending, picked, or +cancelled on a later run in the same drawing tab. Another tab cannot consume +that token. `doc.selection` preserves the exact requested handle order and +rejects duplicate or absent handles. `poll_events` drains tab-scoped drawing, selection, +and active-command state changes. Each tab retains up to 256 events; an +`{"type": "overflow", "dropped": n}` event means older events were lost, so +refresh the document and selection before acting on cached state. + +## Test locally as a bundled plugin + +```sh +bash plugins/opencad-python/tools/stage-bundled.sh \ + work/bundled-plugins/opencad.python --debug +OCS_BUNDLED_PLUGINS_DIR="$PWD/work/bundled-plugins" cargo run -- --new +``` + +The macOS release and signed packaging scripts perform this staging inside +`OpenCADStudio.app/Contents/Resources/plugins/opencad.python` automatically. diff --git a/plugins/opencad-python/ROADMAP.md b/plugins/opencad-python/ROADMAP.md new file mode 100644 index 000000000..05ad1f503 --- /dev/null +++ b/plugins/opencad-python/ROADMAP.md @@ -0,0 +1,389 @@ +# Open CAD Studio — Python Scripting & AutoLISP Migration Plan + +**Status:** Phase 1 is done and host-verified, and the general document model that +followed (entities, drawing tables, block contents and the command runner) is described in +[`../../docs/plugin-host-model.md`](../../docs/plugin-host-model.md). Phase 2 (an AutoLISP +translator) is a separate project that is not part of this repository. This file is kept as +the original planning document and Phase 1 build log. + +**Author:** Open CAD Studio contributors +**Date:** September 2026 + +This document plans embedded Python scripting for Open CAD Studio as an +**external plugin** (not a host change), followed by a best-effort AutoLISP → +Python transpiler built on top of it. It supersedes the host-embedded design +sketched in an earlier draft of this document: scripting fits OCS's existing +plugin model better than it fits inside the host. OpenCADStudio's +`docs/plugin-architecture.md` explicitly lists "sandboxed scripting +(Python/Lua)" as a non-goal of the external-plugin *system* itself — this +plan doesn't touch that system, it just builds a plugin that happens to +embed an interpreter, same as any plugin embeds whatever dependency it +needs. + +**Why a plugin, not a host feature:** + +| | Host-embedded | Plugin (this plan) | +|---|---|---| +| Requires OCS core changes and maintainer sign-off | Yes | No — ships from an independent repo, installed via the existing Plugin Manager | +| Crash isolation | Relies on RustPython's memory safety alone | Gets OS-process isolation for free (plugins already run out-of-process) | +| Distribution | Bundled into every OCS release, forever | Opt-in install, versioned independently, can iterate fast | +| Cost | New maintenance burden on the host | Same ABI-pinning cost every plugin already has (see Phase 1, Risk) | + +--- + +## Phase 1 — Python scripting plugin + +**Goal:** a working bundled `opencad-python` plugin (`cdylib`) that embeds +RustPython and exposes an `ocs` module wrapping `HostApi` while retaining the +out-of-process runner boundary. + +### 1.1 — Scaffold + +- [x] New repo from OpenCADStudio's `docs/plugin-template/`; `plugin.toml` + + `Cargo.toml` pinning `ocs_plugin_api` (`host` feature) and matching the + target host's `acadrust_source` / `rustc_version` (see Risks). Scaffolded + locally at `~/Documents/MacApps/opencad-python` (this repo — no remote + yet at the time, not pushed anywhere). +- [x] Add RustPython as a dependency; confirm it builds cleanly as part of a + `cdylib` on **macOS** (arm64) — Windows/Linux still unverified. Used + crates.io `rustpython-vm = "0.5"` (not the `rustpython` facade crate; + matches how the upstream `examples/hello_embed.rs` embeds it). Builds + clean, no warnings: `libopencad_python.dylib`, 17MB, ~80s from a cold + `cargo clean` release build. Exports the expected + `ocs_plugin_register` / `ocs_plugin_api_version` C-ABI symbols. A + `PY_HELLO` command runs a real RustPython smoke test (compiles and + evaluates `1 + 1` via `Interpreter::without_stdlib`) — confirms the + interpreter actually runs inside the cdylib, not just that it links. + One API gap vs. the upstream example: 0.5.0's `Vm::compile` takes an + owned `String` filename (not `&str`), and `CompileError` has no + `into_pyexception` method — worked around by matching on the `Result` + directly instead of using `?` with `PyResult`. + +### 1.2 — Minimal `ocs` module (read-only) + +- [x] `ocs.selection()` → list of entity handles. Implemented as a cache, not + a live query — `HostApi` has no synchronous "current selection" call + (only a best-effort `SelectionChanged`/`SelectionChangedV4` + notification). Populated via `BuiltinPlugin::on_notification` into a + process-wide static (`selection_cache.rs`) — see the host-runtime + verification note below for why `dispatch`-time polling doesn't work. + Documented as a real limitation in `PLUGIN.md`, not silently papered + over. +- [x] `ocs.get(handle)` → read-only geometry/property access, via + `HostApi::document_reader()` (zero-copy) + `for_each_entity`, matching + by handle. Returns `{handle, kind, layer, point}` or `None`. +- [x] `PY_EVAL ` command (`command_prefixes = ["PY_"]`): evaluate one + inline expression, print the result via `host.push_output`. Runs a + fresh, stdlib-free `Interpreter` per call with only the `ocs` native + module registered (`Interpreter::builder(...).add_native_module(...)`) + — no `import os`/`socket`/etc. is even possible since no stdlib is + loaded, which happens to satisfy most of §1.5's sandboxing goal already + as a side effect of keeping this phase minimal, not a deliberate + Phase 1.5 pass yet. +- [x] Errors: both compile errors (`CompileError`, which implements + `Display` directly — no `into_pyexception` needed) and runtime + exceptions (formatted as `": "`, no traceback yet) + go to `host.push_error`. +- [ ] Bridging `host: &mut dyn HostApi` into the `#[pymodule]` native + functions needed a thread-local raw-pointer scratch slot + (`host_ctx.rs`) with a documented safety argument, since + `add_native_module` wants `&'static PyModuleDef` — there's no closure + capture available to a native pyfunction. Worth a second pair of eyes; + it's sound as written (guard clears the slot before the real borrow + ends, single-threaded, non-reentrant) but is the one genuinely + load-bearing `unsafe` block in the plugin so far. +- [x] Verified: `cargo build`/`--release` clean, zero warnings, against the + real `ocs_plugin_api`/`rustpython-vm` 0.5.0 APIs (not guessed — traced + through the actual crate sources for `HostApi`, `DocumentReader`, + `InterpreterBuilder`, `ToPyObject`/`DictKey` impls, etc.). +- [x] **Host-runtime verified**, not just "compiles": built the actual OCS + host (debug, ~1m43s), installed the plugin, drove it through the real + `--mcp` automation API with an isolated `$HOME` (so the test couldn't + collide with an already-running instance — see below, that mistake cost + real time). Confirmed working end-to-end: + - `PY_EVAL 6 * 7` → `"42"`. + - `PY_EVAL 1 / 0` → `"ZeroDivisionError: division by zero"` via + `host.push_error` — not a silent no-op. + - `ocs.get(handle)` on a drawn `LINE` → correct + `{'handle': 98, 'kind': 'line', 'layer': '0', 'point': None}`. + - Along the way found and fixed a real bug: `PY_EVAL`'s `ocs` name was + never bound in scope (scripts would need `import ocs`, impossible + under `Mode::Eval`, which only accepts one expression). Fixed by + `vm.import("ocs", 0)` + `scope.globals.set_item` before compiling — + `ocs` is now pre-bound, no import needed or possible. + - **Found and fixed a second real bug, this one plugin-side, not the + host's**: `ocs.selection()` always returned `[]`. Root cause (found by + temporarily instrumenting the host, then reverted — no host changes + were needed): `ocs_plugin_api::runner`'s own V4 event loop (`run_v4` + in `runner.rs`) drains `HostApi::try_recv_notification()` on every + iteration and forwards each notification to + `BuiltinPlugin::on_notification` — *before* a `Dispatch` request ever + reaches `dispatch()`. Polling `try_recv_notification()` from inside + `dispatch` (what the plugin did) therefore always sees an empty + queue; the host's `SelectionChangedV4` broadcast + (`notify_plugins_selection_changed` / + `publish_selection_changed_v4` in `src/plugin/v4_support.rs`) was + firing correctly the whole time. Fix: override `on_notification` and + stash the selection in a process-wide cache instead + (`opencad-python`'s `selection_cache.rs`); `dispatch` and + `ocs.selection()` just read that. Verified end-to-end: select an + entity, then `PY_EVAL ocs.selection()` returns its handle. + - Worth folding into this doc's guidance for anyone writing a V4+ + plugin: **notifications must be consumed via `on_notification`, not + by polling `try_recv_notification()` from `dispatch`** — the stock + runner loop already drains that queue for you. + - **Testing pitfall worth remembering**: `--mcp` doesn't run headless — + it's a thin JSON-RPC client that reuses any already-running OCS + instance it finds under `$HOME/Library/Application Support/ + OpenCADStudio/automation` (`descriptors()` in `src/mcp.rs`), spawning + a fresh one only if none exists. First test run silently talked to + the *user's own already-running production app* (which obviously had + never seen this plugin — plugins load once at startup) instead of the + freshly built debug binary. Fix: override `$HOME` for the test + subprocess to a scratch directory, which isolates `config_dir()`, + `plugins_dir()` and the automation discovery directory all at once. + - macOS only, per current scope; Windows/Linux still unverified. + +### 1.3 — Write access + scripts + +- [x] `ocs.add_line(x1, y1, x2, y2)` / `ocs.add_circle(x, y, radius)` — 2D + only for now (z=0, layer `"0"`), wrapped so a whole script is **one** + `host.push_undo` group, not one per call (`host_ctx::ensure_undo_started`, + called lazily by the first write). Verified end-to-end: a `PY_RUN` + script that calls `ocs.add_line` twice undoes both lines in one `undo`. + Take `rustpython_vm::function::ArgIntoFloat`, not a bare `f64` — found + by actually running `ocs.add_line(0, 0, 10, 10)` (plain ints, the + natural way to write it) and hitting a `TypeError`: unlike CPython's + C-function argument parsing, a bare `f64` `#[pyfunction]` parameter in + RustPython does not implicitly coerce an `int`. +- [x] `PY_RUN ` to run a file (`Mode::Exec`, so statements are + allowed, unlike `PY_EVAL`'s `Mode::Eval`). Same error reporting as + `PY_EVAL`: a missing file or a raised exception is never a silent + no-op. Reusing the automation `run` op needed no transport changes, as + expected — `{"op":"run","cmd":"PY_RUN "}` just works. +- [x] `ocs.command(cmd)` to invoke existing built-in commands by name — + **implemented, required a host change**. Added `HostApi::run_command` + (default `Err` impl, no forced version bump) by reusing + `run_command_line` + `drive_headless_task` — the exact machinery + `--serve`/`--mcp` automation's `"run"` op already uses — via a new + `PluginRequest::RunCommand` and one match arm in + `handle_plugin_request`. Full detail: OpenCADStudio's + `docs/plugin-architecture.md`'s `HostApi` table, and this plugin's own + `PLUGIN.md`. + - **Found and fixed a real deadlock along the way**: the naive version + routed back through normal plugin dispatch, which — since this call + is a nested plugin→host request arriving *while* the calling + plugin's `dispatch()` is still running on its one runner thread — + sends that same plugin a second `Dispatch` it has no free thread to + answer. Both sides wait forever. Fixed by skipping plugin dispatch + entirely for this one call path + (`dispatch_command_no_plugin_reentry` / + `run_command_line_no_plugin_reentry`); confirmed via the full + `cargo test --lib` suite (1040 passed, 1 pre-existing unrelated + failure) that normal command-line/automation behavior is unchanged. + Consequence, by design: `ocs.command()` can reach built-ins only, + never another plugin's command. + - **Verified against a real, non-trivial case, not just `LINE`**: + pre-selecting two non-parallel lines then `ocs.command("PCONSTRAINT")` + ran the actual geometric solver and adjusted both lines to be + genuinely parallel — checked numerically (direction-vector cross + product ≈ 0 after), a real persistent constraint object. + - **Gap found, then closed same session**: constraint commands read a + prior *selection*, not picks fed as command tokens — feeding handles + as trailing `PCONSTRAINT` tokens starts the command but ends in + "Command cancelled", not a constraint. Added `HostApi::set_selection` + (same shape as `run_command`: new `PluginRequest::SetSelection`, + default-`Err` trait method, no forced version bump; validates every + handle exists before changing anything) and `ocs.select(handles)` on + top of it. Re-verified **fully scripted, no automation-API help**: a + plain `.py` file via `PY_RUN` calling `ocs.select([h1, h2])` then + `ocs.command("PCONSTRAINT")` produces the same genuinely-parallel + result as before, now reachable entirely from a script. Confirmed no + regressions via the full `cargo test --lib` suite both times (1040 + passed, 1 pre-existing unrelated font-fallback failure). +- [x] **`ocs.add_points(points)` and XDATA CRUD + (`ocs.read_record`/`write_record`/`remove_record`), ported from + reviewing `schoeller/ocs_python_repl`** — a separate, independently + maintained out-of-process PyO3-based Python plugin for OCS (real + CPython + IPython in a terminal, generated typed bindings) reviewed for + reusable ideas. Both needed **no host change**: `HostApi::add_entities` + (batch add) and `read_record`/`write_record`/`remove_record` (XDATA) + already existed in `ocs_plugin_api`, unused by this plugin until now — + unlike `run_command`/`set_selection` in §1.3, which needed new `HostApi` + methods. `add_points` batches many points into one + `host.add_entities()` call instead of looping `add_entity`, so the host + pays for undo/dirty/geometry-rebuild bookkeeping once per batch, not + once per point — the in-process analogue of `ocs_python_repl`'s + `ocs.doc.add_many(...)`, which batches to avoid one IPC round trip per + point instead. The XDATA functions deliberately reuse + `ocs_python_repl`'s exact `{app_name, values}`/`{kind, value}` record + shape (see this plugin's `PLUGIN.md`'s XDATA section) so a script's + XDATA handling ports between the two plugins unchanged. Verified with + unit tests that run a real RustPython interpreter (no live `HostApi`) + and assert that every new function gets all the way through its own + argument/dict parsing — down to every `XDataValue` kind — before + failing on the expected "not running inside a PY_ command" sentinel; + `cargo test`/`cargo clippy --all-targets` both clean. Not yet verified + against a real running host (the "host-runtime verified" bar the rest + of this phase holds itself to) — that still needs a live OCS build, as + noted for `ocs.command()`/`ocs.select()` above. + +### 1.4 — Usability + +- [x] **Open question resolved by reading `ribbon.rs` end to end, not + assumed**: a script manager UI (or a dockable REPL console) is **not + possible** with today's `ocs_plugin_api`. `CadModule` exposes exactly + one hook, `ribbon_groups()`, returning a *static* set of + buttons/dropdowns (`RibbonItem`) — there is no API for a plugin to host + a persistent custom panel or render dynamic content (a script list, a + REPL buffer, ...). That needs a new `HostApi`/`CadModule` capability — + an `ocs_plugin_api` version bump, a host feature request. Out of scope + for a plugin repo; documented as a real, checked answer in this + plugin's own `PLUGIN.md`, not left open. +- [x] `ModuleEvent::PluginFileDialog` (already exists) for "Run Script…": + opens a native file picker filtered to `.py`, and on selection the host + dispatches `PY_RUN ` back to the plugin — no new plugin code + needed beyond the ribbon entry, since `PY_RUN` already exists from + §1.3. Verified the plugin still loads and dispatches correctly with the + new ribbon entry; the file-picker interaction itself isn't automatable + headlessly over `--mcp` (no way to drive a native OS dialog over + JSON-RPC), so that specific interaction is unverified beyond "doesn't + crash". "Save Script As…" was dropped — with no in-app editor (which + needs the same missing custom-panel capability above), there's no + script content to save. + +### 1.5 — Sandboxing + +- [x] Audit: the `ocs` module is the *only* thing a script can call — done, + and **the earlier assumption was wrong, which is exactly why this was + worth auditing instead of assuming**. Phase 1.2/1.3 claimed "no stdlib + is loaded, so `import os`/`socket`/`subprocess` is already impossible" + — true for those three names, but RustPython 0.5.0's `host_env` Cargo + feature is **on by default** and bakes `posix`/`_ctypes` into every + interpreter's core module registry regardless of `rustpython-stdlib`. + Confirmed by actually running it: with `host_env` on, + `PY_EVAL __import__('posix').getcwd()` and + `PY_EVAL open('/etc/passwd').read()` both worked — full filesystem + read/write from any script. Fixed: `rustpython-vm` now builds with + `default-features = false, features = ["compiler", "gc"]`. + Re-verified the full matrix after: `os`/`posix`/`nt`/`subprocess`/ + `socket`/`ctypes`/`_ctypes`/`_socket`/`_subprocess`/`_signal`/`pwd` + all blocked, `open()` blocked for both read and write (its `FileIO` + needs `host_env` internally too, even though `_io` itself is always + registered). No functional regression — `ocs.add_line` and the full + `examples/example.py` (both real constraints) still run correctly. + Audited what remains exhaustively rather than assuming "fewer is + enough": 15 `sys.modules` entries, all core language/import/codec + plumbing plus `ocs`. One accepted, documented residual gap: `_thread` + is real and functional (verified callable) and has no feature guard + at all in this RustPython version — no dependency-level way to + remove it without patching RustPython. Grants no filesystem/network/ + process access; worst case is a resource/stability issue local to + the already crash-isolated plugin process. Full detail in this + plugin's own `PLUGIN.md`. + +### 1.6 — Distribution + +- [ ] Publish binaries per platform on GitHub Releases (matches every other + plugin's release shape already documented in OpenCADStudio's + `plugin-architecture.md`). +- [ ] Manual repo link works immediately (`owner/repo`, no OCS PR needed); + listing in OpenCADStudio's curated `plugins/registry.json` is a + separate, optional later step once the plugin is stable. + +**Risk to track continuously:** a plugin must match the host's exact +`rustc_version` and `acadrust_source` (API v4+ enforces this at load time). +Every OCS host release potentially requires a matching rebuild + republish of +this plugin, or users get a version-mismatch refusal. Budget for this as an +ongoing CI job (build matrix triggered off OCS's release tags), not a one-time +cost. + +--- + +## Phase 2 — AutoLISP → Python parser/translator + +**This phase lives in a separate project that is not part of this repository.** The sub-goals below are the original +scoping; §2.2/2.3 shipped with substantially wider builtin coverage than +first planned (measured from a real AutoLISP corpus sample, not just the +constructs listed here). + +**Goal:** a best-effort, offline source-to-source translator from `.lsp` to +the Phase 1 plugin's Python dialect. It is explicitly **not** an interpreter — +output is a draft a human reviews and finishes, not a guaranteed-correct +program. That's what makes this tractable where a live AutoLISP runtime isn't +(see the earlier discussion: the language core is easy, AutoCAD's builtin +library is the hard, open-ended part — a translator only needs a lookup table +for the builtins it recognizes, not a faithful live implementation of all of +them). + +### 2.1 — Corpus & scoping + +- [x] Pull a representative sample from [Lee Mac's routines](https://lee-mac.com/) + and the [Whole-Spec catalog](https://whole-spec.com/en/cad-tips/lisp-catalog/) + (157 curated routines) to find the actual distribution of constructs + used in real scripts — don't guess coverage priorities, measure them. +- [x] From that sample, explicitly decide and document what's out of scope: + **`vla-*`/`vlax-*` (COM/ActiveX)** — doesn't run in AutoCAD for Mac + either, so this isn't a new limitation — and **DCL dialogs** (no + renderer in OCS). Both get left as commented-out LISP with a + `# TODO: manual port needed` marker in the generated output, never + silently dropped. + +### 2.2 — Reader & core-language transform + +- [x] S-expression tokenizer/parser (standalone, no OCS runtime dependency). +- [x] AST-to-AST transform for the Lisp core: `setq`, `defun` + (`C:CMDNAME` → a Python function), `if`/`cond`, `lambda`/`function`, + arithmetic, string/list operations, `foreach`/`while`. + +### 2.3 — Builtin mapping table + +- [x] `entget` → `ocs.get(handle)`; `ssget` (no-arg) → `ocs.selection()`. +- [x] `command` sequences → `ocs.command("...")`. +- [x] `getvar`/`setvar` — confirmed no host system-variable surface exists; + documented as unsupported rather than guessed at. +- [x] Widened well past this original list via measured corpus frequency — + see that project's own README for the full table. + +### 2.4 — Validation loop + +- [ ] Run translated output through the Phase 1 plugin's interpreter against + the same corpus; every failure either fixes the mapping table or gets + added to the documented unsupported list. This is the actual acceptance + criterion for "done" — not 100% translation, but every gap being a known, + documented one. + +### 2.5 — Packaging + +- [x] Ship as a standalone CLI (`ocs-lisp2py file.lsp > file.py`) — needs + nothing from the OCS runtime, useful even to someone not running OCS yet. +- [ ] Optionally surface as an "Import AutoLISP Macro…" button in the Phase 1 + plugin's ribbon, calling the same conversion logic in-process. + +--- + +## Phase 3 — polish (not yet scoped in detail) + +- [ ] Decide the open question from Phase 1.4: dockable console REPL vs. + run-only. Depends on what `HostApi` can support without a version bump. +- [ ] Versioning story for the `ocs` module surface as it grows: same strict + `ApiVersion` gate as `ocs_plugin_api`, or a looser contract since scripts + aren't compiled artifacts and can tolerate softer compatibility breaks? +- [ ] Whether scripts should ever be able to register permanent ribbon tools + (leaning: no — keep that exclusive to compiled plugins, per + OpenCADStudio's `plugin-architecture.md`'s "one package, one entry + point" goal). + +--- + +## Reference + +| Piece | Location | +|-------|----------| +| Existing plugin architecture (non-goal note) | OpenCADStudio's `docs/plugin-architecture.md` | +| Plugin host API this plugin wraps | OpenCADStudio's `crates/ocs_plugin_api` (`HostApi` trait) | +| Plugin scaffold this was started from | OpenCADStudio's `docs/plugin-template/` | +| Existing automation/headless surface | OpenCADStudio's `src/app/control/transport.rs` | +| RustPython | https://github.com/RustPython/RustPython | +| AutoLISP corpus — Lee Mac | https://lee-mac.com/ | +| AutoLISP corpus — Whole-Spec catalog (157 routines) | https://whole-spec.com/en/cad-tips/lisp-catalog/ | diff --git a/plugins/opencad-python/build.rs b/plugins/opencad-python/build.rs new file mode 100644 index 000000000..0fe185325 --- /dev/null +++ b/plugins/opencad-python/build.rs @@ -0,0 +1,28 @@ +//! Generates `OUT_DIR/entity_crud.rs` — the `dict_to_entity`/`entity_to_dict` +//! conversion functions `ocs_module.rs` `include!`s — from `entity_manifest.json` +//! plus the type registry `ocs_plugin_api` embeds at its own build time (see +//! `crates/ocs_plugin_api::get_embedded_type_registry_json`, always compiled, +//! not gated behind the `host` feature — see that crate's `src/lib.rs`). See +//! `build/generate.rs` for the actual codegen. + +use std::env; +use std::path::PathBuf; + +include!("build/generate.rs"); + +fn main() { + println!("cargo:rerun-if-changed=entity_manifest.json"); + println!("cargo:rerun-if-changed=build/generate.rs"); + + let registry_json = ocs_plugin_api::get_embedded_type_registry_json(); + let registry: TypeRegistry = serde_json::from_str(registry_json) + .expect("ocs_plugin_api's embedded type registry should be valid JSON"); + + let manifest = Manifest::load(include_str!("entity_manifest.json")); + + let generated = generate_entity_crud(&manifest, ®istry); + + let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR set by cargo")); + std::fs::write(out_dir.join("entity_crud.rs"), generated) + .expect("failed to write generated entity_crud.rs"); +} diff --git a/plugins/opencad-python/build/generate.rs b/plugins/opencad-python/build/generate.rs new file mode 100644 index 000000000..1a84d5a10 --- /dev/null +++ b/plugins/opencad-python/build/generate.rs @@ -0,0 +1,1103 @@ +// Codegen for `ocs`'s generic entity conversion functions (`dict_to_entity` +// / `entity_to_dict`), driven by `entity_manifest.json` plus the type +// registry embedded in `ocs_plugin_api` (`get_embedded_type_registry_json`, +// traced by `crates/ocs_plugin_api/build.rs` in the OCS fork). +// +// Adapted from the RustPython-flavored port of `schoeller/ocs_python_repl`'s +// `build/generate.rs` (a PyO3-based generator over the same registry + +// manifest shape) — see `project_opencad_python_generic_entity_crud_plan.md` +// in this session's memory for the full design rationale. The two crates +// target different Python runtimes (PyO3/real CPython vs. RustPython), so +// the leaf conversion code differs, but the manifest schema and the +// registry-driven-with-overrides approach carries over directly. +// +// `include!`'d directly into `build.rs` (not a separate crate) so it shares +// `build.rs`'s own dependency graph without adding a workspace member. + +use std::collections::BTreeSet; + +use ocs_plugin_api::{FieldInfo, TypeId, TypeInfo, TypeKind, TypeRegistry}; +use serde::Deserialize; + +// ════════════════════════════════════════════════════════════════════════════ +// Manifest schema +// ════════════════════════════════════════════════════════════════════════════ + +#[derive(Debug, Deserialize)] +pub struct Manifest { + /// Kinds whose payload is a nested enum: converted by hand-written + /// `_to_dict`/`_from_dict`/`_apply` functions. + #[serde(default)] + pub manual_kinds: Vec, + /// Entity kinds exposed to `ocs.add`/`ocs.update`/`entity_to_dict`. The + /// central, easy-to-widen filter for Phase 2 (see the plan doc). + pub type_filter: Vec, + /// `EntityCommon` fields promoted onto every entity dict. Only `handle` + /// and `layer` are supported today — the rest of `EntityCommon` (color, + /// line weight, XDATA, ...) has its own existing `ocs.*` accessors and is + /// deliberately out of scope for this generic path. + #[serde(default = "default_base_fields")] + pub base_fields: Vec, + #[serde(default)] + pub overrides: std::collections::BTreeMap, + /// Fields of nested structs (a polyline vertex, a hatch edge) that must be + /// present in a scripted dict. Everything else in a nested struct still + /// defaults when absent, but an unknown key is always refused. + #[serde(default)] + pub required_struct_fields: std::collections::BTreeMap>, +} + +fn default_base_fields() -> Vec { + vec!["handle".to_string(), "layer".to_string()] +} + +#[derive(Debug, Deserialize, Default, Clone)] +pub struct EntityOverride { + /// Rust expression constructing a fresh value when this entity has no Default. + #[serde(default)] + pub rust_constructor: Option, + /// Existing entities can be inspected and patched, but `ocs.add` cannot + /// create one without host-side reference setup. + #[serde(default)] + pub update_only: bool, + /// Message returned when a script tries to create an update-only kind. + #[serde(default)] + pub update_only_message: Option, + #[serde(default)] + pub fields: std::collections::BTreeMap, +} + +#[derive(Debug, Deserialize, Default, Clone)] +pub struct FieldOverride { + /// Emit a getter but reject patches to this field. + #[serde(default)] + pub read_only: bool, + /// Rename the dict key for this field. + #[serde(default)] + pub python_name: Option, + /// Raw Rust *value* expression (not yet wrapped in `vm.new_pyobj`), + /// with `VAR` standing in for the entity's local variable name. + /// Requires `rust_setter` too. + #[serde(default)] + pub rust_getter: Option, + /// Raw Rust *statement* (semicolon-terminated), with `VAR` standing in + /// for the entity's local variable name and `dict`/`vm` bound as usual. + /// Requires `rust_getter` too. + #[serde(default)] + pub rust_setter: Option, + /// Drop this field from both directions entirely (e.g. a field whose + /// type this generator doesn't support yet, such as a tagged enum). + #[serde(default)] + pub exclude: bool, + /// `ocs.add()` errors if this field is missing rather than silently + /// defaulting it to zero/empty (Phase 1 review feedback: "add should + /// reject missing required geometry rather than defaulting to zero"). + /// Only meaningful on the generic (non-`rust_getter`/`rust_setter`) + /// path — `ocs.update()` never requires a field (a merge onto an + /// already-valid entity never needs it re-specified). + #[serde(default)] + pub required: bool, + /// Still generate this field type's converters although a + /// `rust_getter`/`rust_setter` override replaces the generic path (the + /// override's hand-written code calls them). + #[serde(default)] + pub keep_types: bool, +} + +impl Manifest { + pub fn load(json: &str) -> Self { + serde_json::from_str(json).expect("entity_manifest.json should be valid JSON") + } +} + +fn get<'a>(registry: &'a TypeRegistry, name: &str) -> Option<&'a TypeInfo> { + registry.types.get(&TypeId::new(name)) +} + +// ════════════════════════════════════════════════════════════════════════════ +// Type classification +// ════════════════════════════════════════════════════════════════════════════ + +const BUILTIN: &[&str] = &[ + "f64", "f32", "i8", "i16", "i32", "i64", "u8", "u16", "u32", "u64", "bool", "String", "str", +]; +/// Types with their own hand-written conversion helpers in `ocs_module.rs` +/// (`vector3_to_py`/`py_to_vector3`, etc.) rather than generated ones. +const SPECIAL: &[&str] = &["Vector3", "Vector2", "Handle", "Color"]; + +enum Classify { + /// A C-like enum: every variant is a unit variant. Represented in Python + /// as its variant name (a plain string). + UnitEnum, + /// An enum whose variants carry one payload. Represented as + /// `{ "kind": "Variant", "value": ... }` so nested geometry such as + /// hatch boundary edges remains explicit and round-trippable. + TaggedEnum, + /// A struct whose only field is `bits` (an acadrust bitflags newtype, + /// e.g. `PolylineFlags`/`VertexFlags`). Represented in Python as a plain + /// integer via `.bits()`/`::from_bits(..)` — the named boolean accessors + /// some of these types have (`is_closed`/`set_closed`, ...) are exposed + /// per-field instead, via a manifest override, not generically. + BitFlags, + /// An ordinary struct: represented in Python as a nested dict. + Struct, +} + +fn classify<'a>(name: &str, info: &'a TypeInfo) -> Classify { + match info.kind { + TypeKind::Struct => { + if info.fields.len() == 1 && info.fields[0].name == "bits" { + Classify::BitFlags + } else { + Classify::Struct + } + } + TypeKind::Enum => { + if info.variants.iter().any(|v| !v.fields.is_empty()) { + if info.variants.iter().all(|v| v.fields.len() <= 1) { + Classify::TaggedEnum + } else { + panic!("entity_manifest.json: {name} has a tagged variant without exactly one payload"); + } + } else { + Classify::UnitEnum + } + } + // acadrust `bitflags!` types serialize as a newtype over their integer + // bits; they convert exactly like the hand-written `bits` structs. + TypeKind::Newtype => Classify::BitFlags, + other => panic!("entity_manifest.json: {name} has unsupported registry kind {other:?}"), + } +} + +fn bits_field_type(info: &TypeInfo) -> &str { + info.fields[0].type_id.as_str() +} + +// ════════════════════════════════════════════════════════════════════════════ +// Helper-type closure (topologically ordered so each generated fn only calls +// ones already emitted above it) +// ════════════════════════════════════════════════════════════════════════════ + +fn helper_closure(manifest: &Manifest, registry: &TypeRegistry) -> Vec { + let mut needed: BTreeSet = BTreeSet::new(); + let mut queue: Vec = Vec::new(); + + for kind in &manifest.type_filter { + if manifest.manual_kinds.contains(kind) { + continue; + } + let Some(info) = get(registry, kind) else { + panic!("entity_manifest.json: unknown entity kind {kind}"); + }; + let override_def = manifest.overrides.get(kind); + for f in &info.fields { + if f.name == "common" { + continue; + } + if override_def + .and_then(|o| o.fields.get(&f.name)) + .map(|o| o.exclude || (o.rust_getter.is_some() && !o.keep_types)) + .unwrap_or(false) + { + // Excluded, or fully hand-supplied by the override — its + // type (if any) never needs a generated conversion pair. + continue; + } + queue.push(f.type_id.as_str().to_string()); + } + } + + while let Some(name) = queue.pop() { + if let Some(parts) = tuple_parts(&name) { + queue.extend(parts.into_iter().map(str::to_owned)); + continue; + } + if BUILTIN.contains(&name.as_str()) + || SPECIAL.contains(&name.as_str()) + || f64_array_len(&name).is_some() + { + continue; + } + if !needed.insert(name.clone()) { + continue; + } + let Some(info) = get(registry, &name) else { + panic!("entity_manifest.json: type {name} (reached transitively) is not in the type registry"); + }; + for f in &info.fields { + if f.type_id.as_str() != "EntityCommon" { + queue.push(f.type_id.as_str().to_string()); + } + } + for v in &info.variants { + for f in &v.fields { + queue.push(f.type_id.as_str().to_string()); + } + } + } + + // Fixpoint topo sort: repeatedly emit any type whose remaining + // dependencies (among `needed`) are already ordered. + let mut ordered = Vec::new(); + let mut remaining: Vec = needed.into_iter().collect(); + while !remaining.is_empty() { + let mut progressed = false; + for name in remaining.clone() { + let info = get(registry, &name).unwrap(); + let deps_pending = info + .fields + .iter() + .chain(info.variants.iter().flat_map(|v| v.fields.iter())) + .any(|f| { + let t = f.type_id.as_str(); + t != name && remaining.iter().any(|r| r == t) + }); + if !deps_pending { + ordered.push(name.clone()); + remaining.retain(|n| n != &name); + progressed = true; + } + } + if !progressed { + // Shouldn't happen for our closed, cycle-free set of types, but + // don't hang the build if it ever does. + ordered.append(&mut remaining); + break; + } + } + ordered +} + +// ════════════════════════════════════════════════════════════════════════════ +// Leaf conversion expressions (single value, not sequence/optional wrapping) +// ════════════════════════════════════════════════════════════════════════════ + +fn snake_case(name: &str) -> String { + let mut out = String::new(); + for (i, ch) in name.chars().enumerate() { + if ch.is_uppercase() && i > 0 { + out.push('_'); + } + out.push(ch.to_ascii_lowercase()); + } + out +} + +fn native_rust_type(type_id: &str) -> &'static str { + match type_id { + "f64" | "f32" => "f64", + "i8" => "i8", + "i16" => "i16", + "i32" => "i32", + "i64" => "i64", + "u8" => "u8", + "u16" => "u16", + "u32" => "u32", + "u64" => "u64", + "bool" => "bool", + "String" | "str" => "String", + other => panic!("not a native type: {other}"), + } +} + +/// Rust path of a traced type; most live in `acadrust::entities`. +fn type_path(name: &str) -> String { + match name { + "LineWeight" => "acadrust::types::LineWeight".to_owned(), + "LeaderLineBreakInfo" => "acadrust::entities::multileader::LeaderLineBreakInfo".to_owned(), + _ => format!("acadrust::entities::{name}"), + } +} + +fn is_native(type_id: &str) -> bool { + BUILTIN.contains(&type_id) +} + +/// Split a tuple type id such as `(u32,CellBorder)` into its element types. +fn tuple_parts(type_id: &str) -> Option> { + let inner = type_id.strip_prefix('(')?.strip_suffix(')')?; + let mut parts = Vec::new(); + let (mut depth, mut start) = (0i32, 0usize); + for (i, ch) in inner.char_indices() { + match ch { + '(' | '[' | '<' => depth += 1, + ')' | ']' | '>' => depth -= 1, + ',' if depth == 0 => { + parts.push(inner[start..i].trim()); + start = i + 1; + } + _ => {} + } + } + parts.push(inner[start..].trim()); + (parts.len() >= 2).then_some(parts) +} + +/// `[f64; N]` fixed arrays (e.g. a 4x4 transform) convert as flat float lists. +fn f64_array_len(type_id: &str) -> Option { + type_id + .strip_prefix("[f64; ")? + .strip_suffix(']')? + .parse() + .ok() +} + +fn default_expr(type_id: &str) -> String { + if let Some(n) = f64_array_len(type_id) { + return format!("[0.0f64; {n}]"); + } + if let Some(parts) = tuple_parts(type_id) { + let items: Vec = parts.iter().map(|part| default_expr(part)).collect(); + return format!("({})", items.join(", ")); + } + match type_id { + "f64" | "f32" => "0.0".to_string(), + "i8" | "i16" | "i32" | "i64" | "u8" | "u16" | "u32" | "u64" => "0".to_string(), + "bool" => "false".to_string(), + "String" | "str" => "String::new()".to_string(), + "Vector3" => "acadrust::types::Vector3::ZERO".to_string(), + "Vector2" => "acadrust::types::Vector2::ZERO".to_string(), + "Handle" => "acadrust::Handle::new(0)".to_string(), + "Color" => "acadrust::types::Color::ByLayer".to_string(), + other => format!("default_{}()", snake_case(other)), + } +} + +/// `item_expr` must already be a `&T` expression (typically a bare place like +/// `&value.field`, sometimes already parenthesized). Result: a +/// `PyResult` expression. +fn leaf_to_py(registry: &TypeRegistry, type_id: &str, item_expr: &str) -> String { + // A plain function argument (`f(item_expr)`) never needs parens; a method + // call or cast directly on `item_expr` (`item_expr.method()`, + // `item_expr as T`) does, since `&` binds looser than both. + let parenthesized = format!("({item_expr})"); + if f64_array_len(type_id).is_some() { + return format!( + "Ok(vm.ctx.new_list(({item_expr}).iter().map(|item| vm.new_pyobj(*item)).collect()).into())" + ); + } + if let Some(parts) = tuple_parts(type_id) { + let items: Vec = parts + .iter() + .enumerate() + .map(|(i, part)| format!("{}?", leaf_to_py(registry, part, &format!("&({item_expr}).{i}")))) + .collect(); + return format!("Ok(vm.ctx.new_list(vec![{}]).into())", items.join(", ")); + } + match type_id { + "f64" | "f32" | "i8" | "i16" | "i32" | "i64" | "u8" | "u16" | "u32" | "u64" | "bool" => { + format!("Ok(vm.new_pyobj(*{parenthesized}))") + } + "String" | "str" => format!("Ok(vm.new_pyobj({parenthesized}.clone()))"), + "Vector3" => format!("vector3_to_py_dict(vm, {item_expr})"), + "Vector2" => format!("vector2_to_py(vm, {item_expr})"), + "Handle" => format!("Ok(vm.new_pyobj({parenthesized}.value()))"), + "Color" => format!("color_to_py_dict(vm, {item_expr})"), + other => { + let info = get(registry, other) + .unwrap_or_else(|| panic!("type {other} missing from registry")); + match classify(other, info) { + Classify::UnitEnum => { + format!("Ok(vm.new_pyobj({}_name({item_expr}).to_owned()))", snake_case(other)) + } + Classify::TaggedEnum => format!("{}_to_dict(vm, {item_expr})", snake_case(other)), + Classify::BitFlags => format!("Ok(vm.new_pyobj({parenthesized}.bits() as i64))"), + Classify::Struct => format!("{}_to_dict(vm, {item_expr})", snake_case(other)), + } + } + } +} + +/// `value_expr` must already be an owned `PyObjectRef` expression. Result: a `PyResult` expression. +fn leaf_from_py(registry: &TypeRegistry, type_id: &str, value_expr: &str) -> String { + if type_id == "f64" { + // RustPython's f64 conversion rejects ints; scripts write `radius=5`. + return format!("py_number_to_f64({value_expr}, vm)"); + } + if type_id == "u64" { + // serde reports Rust `usize` as `u64`; the inferred cast serves both. + return format!("{value_expr}.try_into_value::(vm).map(|n| n as _)"); + } + if is_native(type_id) { + return format!("{value_expr}.try_into_value::<{}>(vm)", native_rust_type(type_id)); + } + if let Some(n) = f64_array_len(type_id) { + return format!("py_to_f64_array::<{n}>({value_expr}, vm)"); + } + if let Some(parts) = tuple_parts(type_id) { + let n = parts.len(); + let items: Vec = parts + .iter() + .enumerate() + .map(|(i, part)| format!("{}?", leaf_from_py(registry, part, &format!("items.remove(0 * {i})")))) + .collect(); + return format!( + "(|| -> PyResult<_> {{ let mut items: Vec = ({value_expr}).try_into_value(vm)?; if items.len() != {n} {{ return Err(vm.new_value_error(\"expected a {n}-item list\".to_owned())); }} Ok(({})) }})()", + items.join(", ") + ); + } + match type_id { + "Vector3" => format!("py_to_vector3_dict({value_expr}, vm)"), + "Vector2" => format!("py_to_vector2({value_expr}, vm)"), + "Handle" => format!("{value_expr}.try_into_value::(vm).map(acadrust::Handle::new)"), + "Color" => format!("py_to_color_dict({value_expr}, vm)"), + other => { + let info = get(registry, other) + .unwrap_or_else(|| panic!("type {other} missing from registry")); + match classify(other, info) { + Classify::UnitEnum => format!( + "{value_expr}.try_into_value::(vm).and_then(|s| str_to_{}(s, vm))", + snake_case(other) + ), + Classify::TaggedEnum => format!("dict_to_{}({value_expr}, vm)", snake_case(other)), + Classify::BitFlags => { + let path = type_path(other); + let bits_ty = bits_field_type(info); + if matches!(info.kind, TypeKind::Newtype) { + // bitflags 2: `from_bits` is fallible and rejects unknown bits. + format!( + "{value_expr}.try_into_value::(vm).and_then(|n| <{bits_ty}>::try_from(n).ok().and_then({path}::from_bits).ok_or_else(|| vm.new_value_error(\"{other} has unknown or out-of-range bits\".to_owned())))" + ) + } else { + format!( + "{value_expr}.try_into_value::(vm).and_then(|n| <{bits_ty}>::try_from(n).map({path}::from_bits).map_err(|_| vm.new_value_error(\"{other} bits out of range\".to_owned())))" + ) + } + } + Classify::Struct => format!("dict_to_{}({value_expr}, vm)", snake_case(other)), + } + } + } +} + +// ════════════════════════════════════════════════════════════════════════════ +// Field-level (sequence/optional-aware) getter/setter expressions +// ════════════════════════════════════════════════════════════════════════════ + +/// `field_place` is a place expression for the field itself (e.g. `"value.radius"`), +/// reached through an already-borrowed parent. Result: a `PyResult` expression. +fn getter_for_field( + registry: &TypeRegistry, + type_id: &str, + optional: bool, + is_sequence: bool, + field_place: &str, +) -> String { + // The registry reports a fixed array as a sequence of itself. + let is_sequence = is_sequence && f64_array_len(type_id).is_none(); + if is_sequence { + let inner = leaf_to_py(registry, type_id, "item"); + format!( + "{{ let items = {field_place}.iter().map(|item| {inner}).collect::>>()?; Ok(vm.ctx.new_list(items).into()) }}" + ) + } else if optional { + let inner = leaf_to_py(registry, type_id, "v"); + format!("match &{field_place} {{ None => Ok(vm.ctx.none()), Some(v) => {inner} }}") + } else { + leaf_to_py(registry, type_id, &format!("&{field_place}")) + } +} + +/// `py_name` is the dict key to read; `keep_expr` is the expression to use +/// when the key is absent (or explicitly `None`, for non-`Option` fields) — +/// the fresh constructor's value for `ocs.add()`, the field's current value +/// (`{var}.{field}.clone()`) for `ocs.update()`, or a diverging `return Err(..)` block for +/// a required-but-missing `ocs.add()` field. Result: a plain (non-`PyResult`) +/// expression of the field's own Rust type (`T`, `Vec`, or `Option`). +fn setter_for_field( + registry: &TypeRegistry, + type_id: &str, + optional: bool, + is_sequence: bool, + py_name: &str, + keep_expr: &str, +) -> String { + let is_sequence = is_sequence && f64_array_len(type_id).is_none(); + if is_sequence { + let inner = leaf_from_py(registry, type_id, "item"); + format!( + "match dict.get_item_opt(\"{py_name}\", vm)? {{ None => {keep_expr}, Some(v) => {{ let items: Vec = v.try_into_value(vm)?; items.into_iter().map(|item| {inner}).collect::>>()? }} }}" + ) + } else if optional { + let inner = leaf_from_py(registry, type_id, "v"); + format!( + "match dict.get_item_opt(\"{py_name}\", vm)? {{ None => {keep_expr}, Some(v) if vm.is_none(&v) => None, Some(v) => Some({inner}?) }}" + ) + } else { + let inner = leaf_from_py(registry, type_id, "v"); + format!( + "match dict.get_item_opt(\"{py_name}\", vm)? {{ None => {keep_expr}, Some(v) if vm.is_none(&v) => {keep_expr}, Some(v) => {inner}? }}" + ) + } +} + +fn field_default_expr(f: &FieldInfo) -> String { + if f.is_sequence && f64_array_len(f.type_id.as_str()).is_none() { + "Vec::new()".to_string() + } else if f.optional { + "None".to_string() + } else { + default_expr(f.type_id.as_str()) + } +} + +/// A diverging block expression: aborts `dict_to_entity` with a clear error +/// instead of producing a value. Valid anywhere an expression of any type is +/// expected (Rust's `!` coerces to everything), so it drops straight into a +/// `match` arm exactly like a real value would. +fn required_error_expr(entity_kind: &str, py_name: &str) -> String { + format!( + "{{ return Err(vm.new_value_error(\"ocs.add: {entity_kind} requires \\\"{py_name}\\\"\".to_owned())); }}" + ) +} + +/// Wraps an already-built sequence-field setter expression with a +/// non-empty check, for a required sequence field (e.g. a polyline's +/// `vertices`) — covers both "key missing" and "key present but `[]`" in +/// one place, since both parse to the same empty `Vec`. +fn wrap_required_nonempty(setter_expr: &str, entity_kind: &str, py_name: &str) -> String { + format!( + "{{ let parsed = {setter_expr}; if parsed.is_empty() {{ return Err(vm.new_value_error(\"ocs.add: {entity_kind} requires at least one \\\"{py_name}\\\"\".to_owned())); }} parsed }}" + ) +} + +// ════════════════════════════════════════════════════════════════════════════ +// Per-helper-type generation +// ════════════════════════════════════════════════════════════════════════════ + +fn gen_unit_enum(name: &str, info: &TypeInfo) -> String { + let path = type_path(name); + let snake = snake_case(name); + let mut name_arms = String::new(); + let mut parse_arms = String::new(); + for v in &info.variants { + name_arms.push_str(&format!(" {path}::{v} => \"{v}\",\n", v = v.name)); + parse_arms.push_str(&format!(" \"{v}\" => Ok({path}::{v}),\n", v = v.name)); + } + let first_variant = info + .variants + .first() + .unwrap_or_else(|| panic!("{name}: unit enum has no variants")) + .name + .clone(); + format!( + r#"fn {snake}_name(value: &{path}) -> &'static str {{ + match value {{ +{name_arms} }} +}} + +#[allow(dead_code)] +fn str_to_{snake}(value: String, vm: &VirtualMachine) -> PyResult<{path}> {{ + match value.as_str() {{ +{parse_arms} other => Err(vm.new_value_error(format!("ocs: unsupported {name} value: {{other}}"))), + }} +}} + +#[allow(dead_code)] +fn default_{snake}() -> {path} {{ + {path}::{first_variant} +}} + +"# + ) +} + +fn gen_bitflags(name: &str, info: &TypeInfo) -> String { + let path = type_path(name); + let snake = snake_case(name); + let ctor = if matches!(info.kind, TypeKind::Newtype) { "empty" } else { "new" }; + format!( + r#"#[allow(dead_code)] +fn default_{snake}() -> {path} {{ + {path}::{ctor}() +}} + +"# + ) +} + +fn gen_tagged_enum(name: &str, info: &TypeInfo, registry: &TypeRegistry) -> String { + let path = type_path(name); + let snake = snake_case(name); + let mut to_arms = String::new(); + let mut from_arms = String::new(); + for variant in &info.variants { + if variant.fields.is_empty() { + to_arms.push_str(&format!( + " {path}::{variant} => {{ let dict = vm.ctx.new_dict(); dict.set_item(\"kind\", vm.new_pyobj(\"{variant}\"), vm)?; Ok(dict.into()) }}\n", + variant = variant.name, + )); + from_arms.push_str(&format!( + " \"{variant}\" => Ok({path}::{variant}),\n", + variant = variant.name, + )); + continue; + } + let payload = &variant.fields[0]; + let getter = leaf_to_py(registry, payload.type_id.as_str(), "value"); + let setter = leaf_from_py(registry, payload.type_id.as_str(), "payload"); + to_arms.push_str(&format!( + " {path}::{variant}(value) => {{ let dict = vm.ctx.new_dict(); dict.set_item(\"kind\", vm.new_pyobj(\"{variant}\"), vm)?; dict.set_item(\"value\", {getter}?, vm)?; Ok(dict.into()) }}\n", + variant = variant.name, + )); + from_arms.push_str(&format!( + " \"{variant}\" => {{ let payload = dict.get_item(\"value\", vm)?; Ok({path}::{variant}({setter}?)) }}\n", + variant = variant.name, + )); + } + let first = &info.variants[0]; + let default_value = match first.fields.first() { + None => format!("{path}::{}", first.name), + Some(payload) => format!("{path}::{}({})", first.name, default_expr(payload.type_id.as_str())), + }; + format!( + r#"fn {snake}_to_dict(vm: &VirtualMachine, value: &{path}) -> PyResult {{ + match value {{ +{to_arms} }} +}} + +#[allow(dead_code)] +fn dict_to_{snake}(value: PyObjectRef, vm: &VirtualMachine) -> PyResult<{path}> {{ + let dict = value.try_into_value::(vm)?; + let kind = dict.get_item("kind", vm)?.try_into_value::(vm)?; + match kind.as_str() {{ +{from_arms} other => Err(vm.new_value_error(format!("ocs: unsupported {name} kind: {{other}}"))), + }} +}} + +#[allow(dead_code)] +fn default_{snake}() -> {path} {{ + {default_value} +}} + +"# + ) +} + +fn gen_struct(name: &str, info: &TypeInfo, registry: &TypeRegistry, required: &[String]) -> String { + let path = type_path(name); + let snake = snake_case(name); + let mut to_dict_body = String::new(); + let mut from_dict_fields = String::new(); + let mut default_fields = String::new(); + let mut allowed: Vec = Vec::new(); + for f in &info.fields { + if f.type_id.as_str() != "EntityCommon" { + allowed.push(format!("\"{}\"", f.name)); + } + } + let allowed_list = allowed.join(", "); + let required_list = required + .iter() + .map(|field| format!("\"{field}\"")) + .collect::>() + .join(", "); + for f in &info.fields { + if f.type_id.as_str() == "EntityCommon" { + // A sub-record's own common data (layer, XDATA, handles) is not part + // of the scripted model; it defaults on the way in. + let default = "acadrust::entities::EntityCommon::default()"; + from_dict_fields.push_str(&format!(" {}: {default},\n", f.name)); + default_fields.push_str(&format!(" {}: {default},\n", f.name)); + continue; + } + let getter = getter_for_field( + registry, + f.type_id.as_str(), + f.optional, + f.is_sequence, + &format!("value.{}", f.name), + ); + to_dict_body.push_str(&format!( + " dict.set_item(\"{fname}\", {getter}?, vm)?;\n", + fname = f.name + )); + let setter = setter_for_field( + registry, + f.type_id.as_str(), + f.optional, + f.is_sequence, + &f.name, + &field_default_expr(f), + ); + from_dict_fields.push_str(&format!(" {fname}: {setter},\n", fname = f.name)); + default_fields.push_str(&format!( + " {fname}: {def},\n", + fname = f.name, + def = field_default_expr(f) + )); + } + format!( + r#"fn {snake}_to_dict(vm: &VirtualMachine, value: &{path}) -> PyResult {{ + let dict = vm.ctx.new_dict(); +{to_dict_body} Ok(dict.into()) +}} + +#[allow(dead_code)] +fn dict_to_{snake}(value: PyObjectRef, vm: &VirtualMachine) -> PyResult<{path}> {{ + let dict = value.try_into_value::(vm)?; + let allowed: &[&str] = &[{allowed_list}]; + for key in dict.keys_vec() {{ + let key = key.try_into_value::(vm)?; + if !allowed.contains(&key.as_str()) {{ + return Err(vm.new_value_error(format!("{name} has no property {{key:?}}; it takes: {{}}", allowed.join(", ")))); + }} + }} + let required_fields: &[&str] = &[{required_list}]; + for required in required_fields.iter().copied() {{ + if dict.get_item_opt(required, vm)?.map_or(true, |v| vm.is_none(&v)) {{ + return Err(vm.new_value_error(format!("{name} needs {{required}}"))); + }} + }} + Ok({path} {{ +{from_dict_fields} }}) +}} + +#[allow(dead_code)] +fn default_{snake}() -> {path} {{ + {path} {{ +{default_fields} }} +}} + +"# + ) +} + +// ════════════════════════════════════════════════════════════════════════════ +// Top-level entity fields (registry + manifest overrides) +// ════════════════════════════════════════════════════════════════════════════ + +struct ResolvedEntityField { + py_name: String, + /// A `PyResult` expression. + getter: String, + /// A full, semicolon-terminated Rust statement. + setter: String, +} + +/// `ocs.add()` builds an entity from scratch (missing fields fall back to a +/// type default, or error if `required`); `ocs.update()` merges onto an +/// already-`.clone()`d existing entity (missing fields simply keep whatever +/// `var` already holds — see [[project_opencad_python_generic_entity_crud_plan]] +/// in this session's memory for why: reconstructing from a partial dict on +/// update could silently reset properties this generator doesn't represent +/// at all, e.g. `EntityCommon`'s color/line-weight/XDATA). +#[derive(Clone, Copy, PartialEq, Eq)] +enum Mode { + Add, + Update, +} + +fn resolve_entity_fields( + manifest: &Manifest, + registry: &TypeRegistry, + kind: &str, + info: &TypeInfo, + var: &str, + mode: Mode, +) -> Vec { + let override_def = manifest.overrides.get(kind).cloned().unwrap_or_default(); + let mut out = Vec::new(); + for f in &info.fields { + if f.name == "common" { + continue; + } + let field_override = override_def.fields.get(&f.name).cloned(); + if let Some(ov) = &field_override { + if ov.exclude { + continue; + } + if let (Some(getter_expr), Some(setter_stmt)) = (&ov.rust_getter, &ov.rust_setter) { + assert!(!ov.read_only, "{kind}.{} cannot be read_only with a rust_setter", f.name); + assert!( + !ov.required, + "entity_manifest.json: {kind}.{}: \"required\" only applies to the generic \ + field path, not a rust_getter/rust_setter override", + f.name + ); + let py_name = ov.python_name.clone().unwrap_or_else(|| f.name.clone()); + let getter = format!("Ok(vm.new_pyobj({}))", getter_expr.replace("VAR", var)); + let setter = setter_stmt.replace("VAR", var); + out.push(ResolvedEntityField { py_name, getter, setter }); + continue; + } + } + let py_name = field_override + .as_ref() + .and_then(|o| o.python_name.clone()) + .unwrap_or_else(|| f.name.clone()); + let required = field_override.as_ref().map(|o| o.required).unwrap_or(false); + let getter = getter_for_field( + registry, + f.type_id.as_str(), + f.optional, + f.is_sequence, + &format!("{var}.{}", f.name), + ); + if field_override.as_ref().is_some_and(|ov| ov.read_only) { + let setter = format!("if dict.get_item_opt(\"{py_name}\", vm)?.is_some() {{ return Err(vm.new_value_error(\"{kind}.{py_name} is read-only\".to_owned())); }}"); + out.push(ResolvedEntityField { py_name, getter, setter }); + continue; + } + let keep_expr = match mode { + Mode::Update => format!("{var}.{}.clone()", f.name), + Mode::Add if required && !f.is_sequence => required_error_expr(kind, &py_name), + Mode::Add => format!("{var}.{}.clone()", f.name), + }; + let mut setter_expr = + setter_for_field(registry, f.type_id.as_str(), f.optional, f.is_sequence, &py_name, &keep_expr); + if mode == Mode::Add && required && f.is_sequence { + setter_expr = wrap_required_nonempty(&setter_expr, kind, &py_name); + } + let setter = format!("{var}.{} = {setter_expr};", f.name); + out.push(ResolvedEntityField { py_name, getter, setter }); + } + out +} + +fn gen_entity_to_dict(manifest: &Manifest, registry: &TypeRegistry) -> String { + let mut arms = String::new(); + for kind in &manifest.type_filter { + if manifest.manual_kinds.contains(kind) { + arms.push_str(&format!( + " acadrust::EntityType::{kind}(value) => {}_to_dict(vm, value),\n", + snake_case(kind) + )); + continue; + } + let info = get(registry, kind).unwrap(); + let var = "value"; + let fields = resolve_entity_fields(manifest, registry, kind, info, var, Mode::Add); + let mut body = String::new(); + body.push_str(" let dict = vm.ctx.new_dict();\n"); + body.push_str(&format!(" dict.set_item(\"kind\", vm.new_pyobj(\"{kind}\"), vm)?;\n")); + body.push_str(&format!( + " dict.set_item(\"handle\", vm.new_pyobj({var}.common.handle.value()), vm)?;\n" + )); + body.push_str(&format!( + " dict.set_item(\"layer\", vm.new_pyobj({var}.common.layer.clone()), vm)?;\n" + )); + if manifest.base_fields.iter().any(|field| field == "owner_handle") { + body.push_str(&format!( + " dict.set_item(\"owner_handle\", vm.new_pyobj({var}.common.owner_handle.value()), vm)?;\n" + )); + } + for f in &fields { + body.push_str(&format!( + " dict.set_item(\"{}\", {}?, vm)?;\n", + f.py_name, f.getter + )); + } + body.push_str(" Ok(dict.into())\n"); + arms.push_str(&format!( + " acadrust::EntityType::{kind}({var}) => {{\n{body} }}\n" + )); + } + format!( + r#"/// Convert any generic-CRUD-supported entity into a plain dict with a +/// `"kind"` key (matching the convention every other `ocs.*` dict already +/// uses, e.g. XDATA's `{{"kind": ..., "value": ...}}`), plus `"handle"` and +/// `"layer"` and every entity-specific field the type registry knows about. +pub(crate) fn entity_to_dict(vm: &VirtualMachine, entity: &acadrust::EntityType) -> PyResult {{ + match entity {{ +{arms} _ => Err(vm.new_value_error( + "ocs: this entity kind is not supported by the generic add/update API yet".to_owned(), + )), + }} +}} + +"# + ) +} + +fn gen_dict_to_entity(manifest: &Manifest, registry: &TypeRegistry) -> String { + let mut arms = String::new(); + for kind in &manifest.type_filter { + if let Some(override_def) = manifest.overrides.get(kind).filter(|o| o.update_only) { + let message = override_def.update_only_message.clone().unwrap_or_else(|| { + format!("{kind} cannot be created through the generic API; only existing {kind} entities can be edited") + }); + arms.push_str(&format!( + " \"{kind}\" => {{ return Err(vm.new_value_error({message:?}.to_owned())); }}\n" + )); + continue; + } + if manifest.manual_kinds.contains(kind) { + arms.push_str(&format!( + " \"{kind}\" => acadrust::EntityType::{kind}({}_from_dict(dict, vm)?),\n", + snake_case(kind) + )); + continue; + } + let info = get(registry, kind).unwrap(); + let var = "value"; + let fields = resolve_entity_fields(manifest, registry, kind, info, var, Mode::Add); + let mut body = String::new(); + let mut allowed = vec!["kind", "handle", "layer"]; + if manifest.base_fields.iter().any(|field| field == "owner_handle") { + allowed.push("owner_handle"); + } + allowed.extend(fields.iter().map(|field| field.py_name.as_str())); + body.push_str(&format!( + " ensure_known_entity_keys(dict, \"{kind}\", &{:?}, vm)?;\n", + allowed + )); + let constructor = manifest.overrides.get(kind) + .and_then(|override_def| override_def.rust_constructor.as_deref()) + .map(str::to_owned) + .unwrap_or_else(|| format!("acadrust::entities::{kind}::default()")); + body.push_str(&format!(" let mut {var} = {constructor};\n")); + body.push_str(&format!( + " {var}.common.handle = acadrust::Handle::new(get_opt_u64(dict, \"handle\", vm)?);\n" + )); + body.push_str(&format!( + " {var}.common.layer = get_opt_string(dict, \"layer\", \"0\", vm)?;\n" + )); + if manifest.base_fields.iter().any(|field| field == "owner_handle") { + body.push_str(&format!( + " {var}.common.owner_handle = acadrust::Handle::new(get_opt_u64(dict, \"owner_handle\", vm)?);\n" + )); + } + for f in &fields { + body.push_str(&format!(" {}\n", f.setter)); + } + body.push_str(&format!(" acadrust::EntityType::{kind}({var})\n")); + arms.push_str(&format!(" \"{kind}\" => {{\n{body} }}\n")); + } + format!( + r#"/// Build a brand-new entity from a dict with a `"kind"` key (see +/// `entity_to_dict`) — the `ocs.add()` direction. Fields the manifest marks +/// `"required"` (the geometry that actually defines the shape — a line's +/// endpoints, a circle's center/radius, ...) error if missing rather than +/// silently defaulting to zero; every other field defaults sensibly (what a +/// fresh `acadrust::entities::X::default()` would already hold). +pub(crate) fn dict_to_entity(dict: &rustpython_vm::builtins::PyDictRef, vm: &VirtualMachine) -> PyResult {{ + let kind = dict.get_item("kind", vm)?.try_into_value::(vm)?; + Ok(match kind.as_str() {{ +{arms} other => {{ + return Err(vm.new_value_error(format!("ocs: unsupported entity kind: {{other}}"))); + }} + }}) +}} + +"# + ) +} + +fn gen_apply_dict_to_entity(manifest: &Manifest, registry: &TypeRegistry) -> String { + let mut arms = String::new(); + for kind in &manifest.type_filter { + if manifest.manual_kinds.contains(kind) { + arms.push_str(&format!( + " acadrust::EntityType::{kind}(existing_value) => acadrust::EntityType::{kind}({}_apply(existing_value, dict, vm)?),\n", + snake_case(kind) + )); + continue; + } + let info = get(registry, kind).unwrap(); + let var = "value"; + let fields = resolve_entity_fields(manifest, registry, kind, info, var, Mode::Update); + let mut body = String::new(); + let mut allowed = vec!["kind", "handle", "layer"]; + if manifest.base_fields.iter().any(|field| field == "owner_handle") { + allowed.push("owner_handle"); + } + allowed.extend(fields.iter().map(|field| field.py_name.as_str())); + body.push_str(&format!( + " ensure_known_entity_keys(dict, \"{kind}\", &{:?}, vm)?;\n", + allowed + )); + body.push_str(&format!(" let mut {var} = existing_value.clone();\n")); + // `handle` is never touched — an update can't rehome an entity's + // identity. `layer` (unlike every other field) is promoted onto + // every dict by `entity_to_dict`/read by `dict_to_entity` without a + // manifest entry, so it's handled by hand here too, the same way + // `dict_to_entity` hand-writes it above — merge semantics: keep the + // existing layer unless the dict actually specifies one. + body.push_str(&format!( + " {var}.common.layer = match dict.get_item_opt(\"layer\", vm)? {{ \ + None => {var}.common.layer.clone(), \ + Some(v) if vm.is_none(&v) => {var}.common.layer.clone(), \ + Some(v) => v.try_into_value::(vm)? }};\n" + )); + if manifest.base_fields.iter().any(|field| field == "owner_handle") { + body.push_str(&format!( + " if let Some(owner) = dict.get_item_opt(\"owner_handle\", vm)? {{ \ + let owner = owner.try_into_value::(vm)?; \ + if owner != {var}.common.owner_handle.value() {{ \ + return Err(vm.new_value_error(\"owner_handle is read-only after creation\".to_owned())); }} }};\n" + )); + } + for f in &fields { + body.push_str(&format!(" {}\n", f.setter)); + } + body.push_str(&format!(" acadrust::EntityType::{kind}({var})\n")); + arms.push_str(&format!( + " acadrust::EntityType::{kind}(existing_value) => {{\n{body} }}\n" + )); + } + format!( + r#"/// Apply a dict's keys onto a *clone* of `existing` — the `ocs.update()` +/// direction. Unlike `dict_to_entity`, a key missing from `dict` means +/// "leave this field as it already was", not "reset it to a type default": +/// reconstructing a fresh entity from a necessarily-partial dict would +/// silently drop whatever this generator can't represent at all (most of +/// `EntityCommon` — color, line weight, XDATA, ...) the moment a script +/// updates just one field. `existing`'s own kind is authoritative; a `dict` +/// `"kind"` (if present at all — round-tripped from a prior `get_entity()`) +/// is never consulted, since an update can't change an entity's shape. +pub(crate) fn apply_dict_to_entity( + existing: &acadrust::EntityType, + dict: &rustpython_vm::builtins::PyDictRef, + vm: &VirtualMachine, +) -> PyResult {{ + Ok(match existing {{ +{arms} _ => {{ + return Err(vm.new_value_error( + "ocs: this entity kind is not supported by the generic add/update API yet" + .to_owned(), + )); + }} + }}) +}} +"# + ) +} + +// ════════════════════════════════════════════════════════════════════════════ +// Entry point +// ════════════════════════════════════════════════════════════════════════════ + +pub fn generate_entity_crud(manifest: &Manifest, registry: &TypeRegistry) -> String { + let mut out = String::new(); + out.push_str( + "// @generated by opencad-python/build.rs from entity_manifest.json + the\n\ + // ocs_plugin_api embedded type registry. Do not edit by hand — edit\n\ + // entity_manifest.json or build/generate.rs instead.\n\n", + ); + + for name in helper_closure(manifest, registry) { + let info = get(registry, &name).unwrap(); + match classify(&name, info) { + Classify::UnitEnum => out.push_str(&gen_unit_enum(&name, info)), + Classify::TaggedEnum => out.push_str(&gen_tagged_enum(&name, info, registry)), + Classify::BitFlags => out.push_str(&gen_bitflags(&name, info)), + Classify::Struct => { + let required = manifest.required_struct_fields.get(&name).cloned().unwrap_or_default(); + for field in &required { + assert!( + info.fields.iter().any(|f| &f.name == field), + "entity_manifest.json: required_struct_fields: {name} has no field {field}" + ); + } + out.push_str(&gen_struct(&name, info, registry, &required)) + } + } + } + + out.push_str(&gen_entity_to_dict(manifest, registry)); + out.push_str(&gen_dict_to_entity(manifest, registry)); + out.push_str(&gen_apply_dict_to_entity(manifest, registry)); + out +} diff --git a/plugins/opencad-python/entity_manifest.json b/plugins/opencad-python/entity_manifest.json new file mode 100644 index 000000000..a714143e4 --- /dev/null +++ b/plugins/opencad-python/entity_manifest.json @@ -0,0 +1,1018 @@ +{ + "type_filter": [ + "Point", + "Line", + "Circle", + "Arc", + "Ellipse", + "Polyline", + "Polyline2D", + "Polyline3D", + "LwPolyline", + "Spline", + "Text", + "MText", + "Ray", + "XLine", + "Solid", + "Face3D", + "Insert", + "Tolerance", + "Shape", + "AttributeDefinition", + "AttributeEntity", + "Hatch", + "Leader", + "MLine", + "Dimension", + "MultiLeader", + "Table", + "PolygonMesh", + "PolyfaceMesh", + "Mesh", + "Helix", + "RasterImage", + "Wipeout", + "Underlay", + "Viewport", + "ViewBorder", + "Light", + "Ole2Frame", + "Solid3D", + "Body", + "Region", + "Surface", + "SectionSymbol" + ], + "manual_kinds": [ + "Dimension" + ], + "base_fields": [ + "handle", + "layer", + "owner_handle" + ], + "overrides": { + "Point": { + "fields": { + "location": { + "required": true + } + } + }, + "Line": { + "fields": { + "start": { + "required": true + }, + "end": { + "required": true + } + } + }, + "Circle": { + "fields": { + "center": { + "required": true + }, + "radius": { + "required": true + } + } + }, + "Arc": { + "fields": { + "center": { + "required": true + }, + "radius": { + "required": true + }, + "start_angle": { + "required": true + }, + "end_angle": { + "required": true + } + } + }, + "Ellipse": { + "fields": { + "center": { + "required": true + }, + "major_axis": { + "required": true + }, + "minor_axis_ratio": { + "required": true + } + } + }, + "Polyline": { + "fields": { + "vertices": { + "required": true + }, + "flags": { + "python_name": "closed", + "rust_getter": "VAR.flags.is_closed()", + "rust_setter": "VAR.flags.set_closed(get_opt_bool(dict, \"closed\", vm)?);" + } + }, + "update_only": true, + "update_only_message": "Polyline is the legacy 3-D-vertex polyline and both file formats save it as Polyline3D; create a Polyline2D or Polyline3D instead (existing Polyline entities can still be edited)" + }, + "Polyline2D": { + "fields": { + "vertices": { + "required": true + }, + "flags": { + "python_name": "closed", + "rust_getter": "VAR.flags.is_closed()", + "rust_setter": "VAR.flags.set_closed(get_opt_bool(dict, \"closed\", vm)?);" + } + } + }, + "Polyline3D": { + "fields": { + "vertices": { + "required": true + }, + "smooth_type": { + "exclude": true, + "_comment": "acadrust defines two distinct SmoothSurfaceType enums with the same name (polyline.rs, used by Polyline2D's smooth_surface \u2014 and a separate one private to polyline3d.rs, used here); the type registry's tracer keys purely by name and collapses them, so the generically generated conversion resolves to the wrong one. Needs a hand-written override with the fully qualified acadrust::entities::polyline3d::SmoothSurfaceType path to support, not just a rename." + } + } + }, + "LwPolyline": { + "fields": { + "vertices": { + "required": true + } + } + }, + "Spline": { + "fields": { + "control_points": { + "required": true + } + } + }, + "Text": { + "fields": { + "value": { + "python_name": "text", + "required": true + }, + "insertion_point": { + "python_name": "insertion", + "required": true + } + } + }, + "MText": { + "fields": { + "value": { + "python_name": "text", + "required": true + }, + "insertion_point": { + "python_name": "insertion", + "required": true + }, + "background_color": { + "exclude": true, + "_comment": "Color is a tagged enum (Index(u8)/Rgb{r,g,b} carry data, not just unit variants) \u2014 this generator only handles C-like unit enums generically; EntityCommon's own color/line_weight/transparency fields are out of scope for the same reason (see base_fields)." + } + } + }, + "Ray": { + "fields": { + "base_point": { + "required": true + }, + "direction": { + "required": true + } + } + }, + "XLine": { + "fields": { + "base_point": { + "required": true + }, + "direction": { + "required": true + } + } + }, + "Solid": { + "rust_constructor": "acadrust::entities::Solid::new(acadrust::types::Vector3::ZERO, acadrust::types::Vector3::ZERO, acadrust::types::Vector3::ZERO, acadrust::types::Vector3::ZERO)", + "fields": { + "first_corner": { + "required": true + }, + "second_corner": { + "required": true + }, + "third_corner": { + "required": true + }, + "fourth_corner": { + "required": true + } + } + }, + "Face3D": { + "rust_constructor": "acadrust::entities::Face3D::new(acadrust::types::Vector3::ZERO, acadrust::types::Vector3::ZERO, acadrust::types::Vector3::ZERO, acadrust::types::Vector3::ZERO)", + "fields": { + "first_corner": { + "required": true + }, + "second_corner": { + "required": true + }, + "third_corner": { + "required": true + }, + "fourth_corner": { + "required": true + } + } + }, + "Insert": { + "fields": { + "block_name": { + "required": true + }, + "x_scale": { + "rust_getter": "VAR.x_scale()", + "rust_setter": "if let Some(v) = dict.get_item_opt(\"x_scale\", vm)? { let scale = py_number_to_f64(v, vm)?; if !scale.is_finite() || scale.abs() < 1e-12 { return Err(vm.new_value_error(\"Insert.x_scale must be finite and nonzero\".to_owned())); } VAR.set_x_scale(scale); }" + }, + "y_scale": { + "rust_getter": "VAR.y_scale()", + "rust_setter": "if let Some(v) = dict.get_item_opt(\"y_scale\", vm)? { let scale = py_number_to_f64(v, vm)?; if !scale.is_finite() || scale.abs() < 1e-12 { return Err(vm.new_value_error(\"Insert.y_scale must be finite and nonzero\".to_owned())); } VAR.set_y_scale(scale); }" + }, + "z_scale": { + "rust_getter": "VAR.z_scale()", + "rust_setter": "if let Some(v) = dict.get_item_opt(\"z_scale\", vm)? { let scale = py_number_to_f64(v, vm)?; if !scale.is_finite() || scale.abs() < 1e-12 { return Err(vm.new_value_error(\"Insert.z_scale must be finite and nonzero\".to_owned())); } VAR.set_z_scale(scale); }" + }, + "dwg_minsert": { + "exclude": true + }, + "attributes": { + "exclude": true + }, + "view_rep_handle": { + "exclude": true + }, + "seqend_handle": { + "exclude": true + }, + "insert_point": { + "required": true + } + }, + "rust_constructor": "acadrust::entities::Insert::new(String::new(), acadrust::types::Vector3::ZERO)" + }, + "Tolerance": { + "fields": { + "insertion_point": { + "required": true + }, + "text": { + "required": true + }, + "dimension_style_name": { + "rust_getter": "VAR.dimension_style_name.clone()", + "rust_setter": "if let Some(v) = dict.get_item_opt(\"dimension_style_name\", vm)? { VAR.dimension_style_name = v.try_into_value::(vm)?; VAR.dimension_style_handle = None; }" + }, + "dimension_style_handle": { + "read_only": true + }, + "dwg_unknown_short": { + "exclude": true + } + } + }, + "Shape": { + "fields": { + "insertion_point": { + "required": true + }, + "size": { + "required": true + }, + "shape_name": { + "required": true + }, + "shape_number": { + "required": true + }, + "style_name": { + "rust_getter": "VAR.style_name.clone()", + "rust_setter": "if let Some(v) = dict.get_item_opt(\"style_name\", vm)? { VAR.style_name = v.try_into_value::(vm)?; VAR.style_handle = None; }" + }, + "style_handle": { + "read_only": true + } + } + }, + "AttributeDefinition": { + "fields": { + "tag": { + "required": true + }, + "insertion_point": { + "required": true + }, + "height": { + "required": true + }, + "embedded_mtext": { + "exclude": true, + "_comment": "Embedded R2018+ MTEXT carries linked layout state. It remains snapshot-readable but is preserved untouched by Python edits until linked-payload mutation has dedicated validation." + } + } + }, + "AttributeEntity": { + "fields": { + "tag": { + "required": true + }, + "value": { + "required": true + }, + "insertion_point": { + "required": true + }, + "height": { + "required": true + }, + "attdef_handle": { + "read_only": true + }, + "embedded_mtext": { + "exclude": true, + "_comment": "The embedded R2018+ MTEXT payload remains snapshot-readable and is preserved by partial edits." + } + } + }, + "Hatch": { + "fields": { + "paths": { + "required": true + }, + "is_mpolygon": { + "exclude": true, + "_comment": "MPOLYGON is a distinct class-backed record and is preserved without exposing Hatch creation semantics." + }, + "gradient_color": { + "exclude": true, + "_comment": "Gradient payload mutation requires a separate validated model; partial Hatch edits preserve it." + }, + "mpolygon_hatch_color": { + "exclude": true + }, + "mpolygon_x_direction": { + "exclude": true + }, + "mpolygon_boundary_handle_count": { + "exclude": true + } + } + }, + "MLine": { + "fields": { + "vertices": { + "required": true + }, + "flags": { + "rust_getter": "(VAR.flags.bits() as i64)", + "rust_setter": "if let Some(v) = dict.get_item_opt(\"flags\", vm)? { let bits = v.try_into_value::(vm)?; VAR.flags = i16::try_from(bits).ok().and_then(acadrust::entities::MLineFlags::from_bits).ok_or_else(|| vm.new_value_error(\"MLine.flags has unknown bits\".to_owned()))?; }" + }, + "start_point": { + "read_only": true + }, + "style_handle": { + "read_only": true + }, + "style_element_count": { + "read_only": true + }, + "style_name": { + "rust_getter": "VAR.style_name.clone()", + "rust_setter": "if let Some(v) = dict.get_item_opt(\"style_name\", vm)? { VAR.style_name = v.try_into_value::(vm)?; VAR.style_handle = None; }" + } + } + }, + "Leader": { + "fields": { + "vertices": { + "required": true + }, + "origin": { + "read_only": true + }, + "dimension_gap": { + "read_only": true + }, + "arrowhead_type": { + "read_only": true + }, + "arrow_size": { + "read_only": true + }, + "byblock_color": { + "read_only": true + }, + "dwg_unknown_bit1": { + "exclude": true + }, + "dwg_unknown_bit2": { + "exclude": true + }, + "dwg_unknown_bit3": { + "exclude": true + }, + "dwg_unknown_bit4": { + "exclude": true + }, + "dwg_unknown_bit5": { + "exclude": true + }, + "dwg_unknown_short1": { + "exclude": true + } + } + }, + "MultiLeader": { + "fields": { + "arrowhead_overrides": { + "exclude": true, + "_comment": "Per-arrow override records carry linked block handles; preserved untouched until dedicated validation exists." + }, + "block_attributes": { + "exclude": true, + "_comment": "Block attribute records carry attribute-definition handles; preserved untouched." + }, + "dwg_version": { + "read_only": true + }, + "text_height": { + "read_only": true, + "_comment": "Neither writer stores the entity-level text height; scripts set the persisted context.text_height." + }, + "property_override_flags": { + "read_only": true + }, + "text_align_in_ipe": { + "read_only": true + }, + "context": { + "keep_types": true, + "rust_getter": "multi_leader_annot_context_to_dict(vm, &VAR.context)?", + "rust_setter": "if let Some(v) = dict.get_item_opt(\"context\", vm)? { VAR.context = merge_multileader_context(&VAR.context, v, vm)?; }" + } + } + }, + "Table": { + "fields": { + "insertion_point": { + "required": true + }, + "block_name": { + "read_only": true + }, + "block_record_handle": { + "read_only": true + }, + "data_version": { + "read_only": true + }, + "value_flags": { + "read_only": true + }, + "override_flag": { + "read_only": true + }, + "override_border_color": { + "read_only": true + }, + "override_border_line_weight": { + "read_only": true + }, + "override_border_visibility": { + "read_only": true + }, + "legacy_style_override": { + "read_only": true + }, + "name": { + "read_only": true + }, + "description": { + "read_only": true + }, + "field_handles": { + "read_only": true + }, + "base_style": { + "read_only": true + }, + "break_data": { + "read_only": true + }, + "break_ranges": { + "read_only": true + }, + "dwg_unknown_byte": { + "exclude": true + }, + "dwg_unknown_handle": { + "exclude": true + }, + "dwg_unknown_long1": { + "exclude": true + }, + "dwg_r2010_unknown_bit": { + "exclude": true + }, + "dwg_unknown_long2": { + "exclude": true + }, + "dwg_unknown_short": { + "exclude": true + }, + "legacy_border_colors": { + "exclude": true, + "_comment": "Generic LegacyBorderOverrides is not expressible by the generator; legacy R13-R2000 border overrides are preserved untouched." + }, + "legacy_border_line_weights": { + "exclude": true + }, + "legacy_border_visibility": { + "exclude": true + } + } + }, + "PolygonMesh": { + "rust_constructor": "acadrust::entities::PolygonMeshEntity::default()", + "fields": { + "vertices": { + "required": true + } + } + }, + "PolyfaceMesh": { + "fields": { + "vertices": { + "required": true + }, + "faces": { + "required": true + }, + "seqend_handle": { + "read_only": true + } + } + }, + "Mesh": { + "fields": { + "vertices": { + "required": true + }, + "faces": { + "required": true + }, + "version": { + "read_only": true + } + } + }, + "Helix": { + "fields": { + "start_point": { + "required": true + }, + "spline": { + "read_only": true, + "_comment": "Derived by the host from the generating parameters." + }, + "major_version": { + "read_only": true + }, + "maintenance_version": { + "read_only": true + } + } + }, + "RasterImage": { + "fields": { + "insertion_point": { + "required": true + }, + "file_path": { + "required": true + }, + "class_version": { + "read_only": true + }, + "size": { + "read_only": true, + "_comment": "Pixel size of the image; the host takes it from the file." + }, + "definition_handle": { + "read_only": true + }, + "definition_reactor_handle": { + "read_only": true + } + } + }, + "Wipeout": { + "fields": { + "insertion_point": { + "required": true + }, + "class_version": { + "read_only": true + }, + "definition_handle": { + "read_only": true + }, + "definition_reactor_handle": { + "read_only": true + } + } + }, + "Underlay": { + "fields": { + "definition_handle": { + "required": true + }, + "insertion_point": { + "required": true + } + } + }, + "Viewport": { + "fields": { + "id": { + "read_only": true + }, + "snap_base": { + "read_only": true + }, + "snap_spacing": { + "read_only": true + }, + "grid_spacing": { + "read_only": true + }, + "snap_angle": { + "read_only": true + }, + "clip_boundary_handle": { + "read_only": true + }, + "style_sheet": { + "read_only": true + }, + "ucs_at_origin": { + "read_only": true + }, + "ucs_per_viewport": { + "read_only": true + }, + "ucs_origin": { + "read_only": true + }, + "ucs_x_axis": { + "read_only": true + }, + "ucs_y_axis": { + "read_only": true + }, + "ucs_handle": { + "read_only": true + }, + "base_ucs_handle": { + "read_only": true + }, + "ucs_ortho_type": { + "read_only": true + }, + "elevation": { + "read_only": true + }, + "shade_plot_mode": { + "read_only": true + }, + "grid_flags": { + "read_only": true + }, + "grid_major": { + "read_only": true + }, + "background_handle": { + "read_only": true + }, + "shade_plot_handle": { + "read_only": true + }, + "visual_style_handle": { + "read_only": true + }, + "sun_handle": { + "read_only": true + }, + "default_lighting": { + "read_only": true + }, + "default_lighting_type": { + "read_only": true + }, + "brightness": { + "read_only": true + }, + "contrast": { + "read_only": true + }, + "ambient_color": { + "read_only": true + }, + "center": { + "required": true + }, + "width": { + "required": true + }, + "height": { + "required": true + }, + "custom_scale": { + "read_only": true, + "_comment": "Derived by the host as height / view_height, like MVIEW." + } + } + }, + "ViewBorder": { + "fields": { + "version": { + "read_only": true + }, + "min": { + "required": true + }, + "max": { + "required": true + }, + "active_viewport": { + "required": true + }, + "scale_handle": { + "required": true + } + } + }, + "Light": { + "fields": { + "class_version": { + "read_only": true + }, + "position": { + "required": true + }, + "name": { + "required": true + } + } + }, + "Ole2Frame": { + "update_only": true, + "update_only_message": "create an Ole2Frame with doc.embed_picture(path, origin, width); existing frames can be moved, resized and aspect-locked, and the stored object is preserved untouched", + "fields": { + "storage": { + "exclude": true, + "_comment": "Opaque compound-file payload; snapshot-readable and preserved untouched." + }, + "envelope": { + "exclude": true, + "_comment": "Opaque frame envelope; preserved untouched." + }, + "version": { + "read_only": true + }, + "source_application": { + "read_only": true + }, + "ole_object_type": { + "read_only": true + }, + "dwg_mode": { + "read_only": true + } + } + }, + "Solid3D": { + "update_only": true, + "update_only_message": "Solid3D is built and edited by the geometry kernel: use doc.solids to create solids and move them; a script never writes the ACIS payload", + "fields": { + "acis_data": { + "exclude": true, + "_comment": "Opaque ACIS payload; snapshot-readable and never rewritten by a script." + }, + "wires": { + "exclude": true, + "_comment": "Derived edge wires; regenerated by the host." + }, + "silhouettes": { + "exclude": true, + "_comment": "Derived; regenerated by the host." + }, + "uid": { + "read_only": true + }, + "point_of_reference": { + "read_only": true + }, + "history_handle": { + "read_only": true + } + } + }, + "Body": { + "update_only": true, + "update_only_message": "Body is built and edited by the geometry kernel: use doc.solids to create solids and move them; a script never writes the ACIS payload", + "fields": { + "acis_data": { + "exclude": true, + "_comment": "Opaque ACIS payload; snapshot-readable and never rewritten by a script." + }, + "wires": { + "exclude": true, + "_comment": "Derived edge wires; regenerated by the host." + }, + "silhouettes": { + "exclude": true, + "_comment": "Derived; regenerated by the host." + }, + "uid": { + "read_only": true + }, + "point_of_reference": { + "read_only": true + }, + "history_handle": { + "read_only": true + } + } + }, + "Region": { + "update_only": true, + "update_only_message": "Region is built and edited by the geometry kernel: use doc.solids to create solids and move them; a script never writes the ACIS payload", + "fields": { + "acis_data": { + "exclude": true, + "_comment": "Opaque ACIS payload; snapshot-readable and never rewritten by a script." + }, + "wires": { + "exclude": true, + "_comment": "Derived edge wires; regenerated by the host." + }, + "silhouettes": { + "exclude": true, + "_comment": "Derived; regenerated by the host." + }, + "uid": { + "read_only": true + }, + "point_of_reference": { + "read_only": true + }, + "history_handle": { + "read_only": true + } + } + }, + "Surface": { + "update_only": true, + "update_only_message": "Surface is built by the geometry kernel: use doc.solids.surface(profile) for a plane surface or doc.solids.extrude(open_profile, direction) for an extruded one; a script never writes the ACIS payload", + "fields": { + "acis_data": { + "exclude": true, + "_comment": "Opaque ACIS payload; never rewritten by a script." + }, + "surface_data": { + "exclude": true, + "_comment": "Kind-specific sweep/loft parameters and embedded profiles; preserved untouched." + }, + "wires": { + "exclude": true + }, + "silhouettes": { + "exclude": true + }, + "kind": { + "read_only": true, + "python_name": "surface_kind", + "_comment": "Renamed: a bare `kind` key names the entity type in every Python dict." + }, + "modeler_format_version": { + "read_only": true + }, + "point_of_reference": { + "read_only": true + }, + "history_handle": { + "read_only": true + } + } + }, + "SectionSymbol": { + "fields": { + "points": { + "required": true + }, + "view_symbol_version": { + "read_only": true + }, + "raw_view_symbol_70": { + "read_only": true + }, + "version": { + "read_only": true + }, + "raw_point_count_90": { + "read_only": true + }, + "raw_flags_90": { + "read_only": true, + "_comment": "Semantics unknown in cadcodec; preserved as read, 0 for a new symbol." + }, + "raw_point_record_count": { + "read_only": true + }, + "end_a": { + "read_only": true + }, + "end_b": { + "read_only": true + }, + "tick_a": { + "read_only": true + }, + "tick_b": { + "read_only": true + }, + "label": { + "read_only": true + } + } + } + }, + "required_struct_fields": { + "LwVertex": [ + "location" + ], + "Vertex2D": [ + "location" + ], + "Vertex3D": [ + "location" + ], + "Vertex3DPolyline": [ + "position" + ], + "PolygonMeshVertex": [ + "location" + ], + "PolyfaceVertex": [ + "location" + ], + "MLineVertex": [ + "position" + ], + "LineEdge": [ + "start", + "end" + ], + "CircularArcEdge": [ + "center", + "radius" + ], + "EllipticArcEdge": [ + "center", + "major_axis_endpoint" + ], + "PolylineEdge": [ + "vertices" + ], + "SectionSymbolPoint": [ + "point" + ] + } +} diff --git a/plugins/opencad-python/examples/create_layout_fixture.rs b/plugins/opencad-python/examples/create_layout_fixture.rs new file mode 100644 index 000000000..26e61e341 --- /dev/null +++ b/plugins/opencad-python/examples/create_layout_fixture.rs @@ -0,0 +1,54 @@ +//! Create one disposable DWG with a paper layout and rectangular viewport. +//! Usage: cargo run --example create_layout_fixture -- /absolute/output.dwg + +use std::io::Write; + +use ocs_plugin_api::host::acadrust::{ + entities::{Line, Text, Viewport}, + CadDocument, DwgWriter, EntityType, Vector3, +}; + +fn main() -> Result<(), Box> { + let output = std::env::args().nth(1).ok_or("expected output DWG path")?; + let output = std::path::PathBuf::from(output); + let twisted = std::env::args().nth(2).as_deref() == Some("--twisted"); + if output.extension().is_none_or(|value| value != "dwg") + || output.parent().is_none_or(|parent| !parent.is_dir()) + { + return Err("output must be a .dwg in an existing directory".into()); + } + let mut document = CadDocument::new(); + document.add_layout("Sheet")?; + document.add_entity(EntityType::Line(Line::from_coords( + -50.0, 0.0, 0.0, 50.0, 0.0, 0.0, + )))?; + document.add_entity_to_layout( + EntityType::Text(Text::with_value( + "Sheet note", + Vector3::new(20.0, 20.0, 0.0), + )), + "Sheet", + )?; + let mut viewport = Viewport::new(); + viewport.id = 2; + viewport.center = Vector3::new(100.0, 80.0, 0.0); + viewport.width = 100.0; + viewport.height = 50.0; + viewport.view_height = 100.0; + viewport.view_center = Vector3::new(10.0, 20.0, 0.0); + viewport.view_target = Vector3::ZERO; + if twisted { + viewport.twist_angle = std::f64::consts::FRAC_PI_2; + viewport.view_target = Vector3::new(5.0, 10.0, 0.0); + } + document.add_entity_to_layout(EntityType::Viewport(viewport), "Sheet")?; + + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&output)?; + DwgWriter::write_to_writer(&mut file, &document)?; + file.flush()?; + println!("Created {}", output.display()); + Ok(()) +} diff --git a/plugins/opencad-python/examples/example.py b/plugins/opencad-python/examples/example.py new file mode 100644 index 000000000..464ffc8f9 --- /dev/null +++ b/plugins/opencad-python/examples/example.py @@ -0,0 +1,90 @@ +# Example script for opencad-python's PY_RUN command. +# +# Historical experimental command-replay example. The portable default +# build omits ocs.command()/ocs.select(); nested replay may hang in OCS 2026.37. +# +# Draws two rectangles, a triangle, an arc, a pair of lines made parallel by +# a real PCONSTRAINT, a circle made tangent to a line by a real TCONSTRAINT, +# and a row of points added in one ocs.add_points() batch call, one of which +# gets an XDATA record written, read back, and removed. Every ocs.add_*/ +# ocs.write_record call in one PY_RUN shares a script undo group. Command +# replay has separate host undo semantics. +# +# These ARE real, persistent constraint objects, not just geometrically +# correct-looking shapes: drag one of the two "parallel" lines afterward and +# the other follows to stay parallel, same for the tangent circle. Verified +# end-to-end (not just "it compiles"): after ocs.command("PCONSTRAINT") / +# ("TCONSTRAINT"), the geometry visibly changes and satisfies the +# relationship exactly (checked numerically — direction-vector cross +# product ~0 for parallel, center-to-line distance == radius for tangent). +# +# ocs.select(handles) + ocs.command(name) is what makes this possible: +# constraint commands read a prior *selection* (a "Select objects:" +# prompt), not picks fed as command-line tokens the way LINE's points are — +# select the entities first, then run the bare command name. See PLUGIN.md +# for the two host-side additions (HostApi::run_command / set_selection) +# this needed and exactly what they do and don't cover. + +if not hasattr(ocs, "command") or not hasattr(ocs, "select"): + raise RuntimeError("This example requires experimental command replay host APIs") + +def rectangle(x, y, w, h): + l1 = ocs.add_line(x, y, x + w, y) + l2 = ocs.add_line(x + w, y, x + w, y + h) + l3 = ocs.add_line(x + w, y + h, x, y + h) + l4 = ocs.add_line(x, y + h, x, y) + return [l1, l2, l3, l4] + +# ── Two rectangles (not aligned to each other — see the note below) ─────── +rectangle(0, 0, 20, 10) +rectangle(30, 2, 18, 9) + +# ── A triangle (three lines closing a loop) ──────────────────────────────── +ocs.add_line(60, 0, 75, 0) +ocs.add_line(75, 0, 67.5, 12) +ocs.add_line(67.5, 12, 60, 0) + +# ── An arc, for variety ───────────────────────────────────────────────────── +ocs.add_arc(67.5, 20, 8, 0, 180) + +# ── Two lines, deliberately NOT parallel — then made parallel by a real ─── +# ── persistent PCONSTRAINT (not just recomputed coordinates). +line_a = ocs.add_line(0, 20, 20, 24) +line_b = ocs.add_line(0, 30, 20, 33) +ocs.select([line_a, line_b]) +ocs.command("PCONSTRAINT") + +# ── A line and a circle, deliberately NOT touching — then made tangent by ── +# ── a real persistent TCONSTRAINT. +tangent_line = ocs.add_line(0, 40, 20, 40) +tangent_circle = ocs.add_circle(10, 44, 3) +ocs.select([tangent_line, tangent_circle]) +ocs.command("TCONSTRAINT") + +# ── What's NOT in this script: aligning the two rectangles to each other ── +# ── (a real ALIGN, not just matching coordinates). Tried it separately: +# ── ocs.command("ALIGN ") DOES apply the transform correctly, +# ── but ALIGN asks a trailing "Scale objects? [Yes/No]" question that +# ── ocs.command()'s point-feeding doesn't answer (it sends exactly one +# ── trailing Enter, and ALIGN needs two — one to skip the optional 3rd +# ── point pair, one for the scale question) — it leaves that prompt open +# ── instead of cleanly finishing. Left out of this script rather than +# ── shipping something that half-works; see PLUGIN.md for the detail. + +# ── A batch of points, added in one HostApi::add_entities() call instead ── +# ── of one ocs.add_line-style call per point. +point_handles = ocs.add_points([[x, 50, 0] for x in range(0, 21, 2)]) + +# ── Tag one of those points with an XDATA record, read it back, then ─────── +# ── remove it — round-tripping every supported value kind. +tagged = point_handles[0] +ocs.write_record(tagged, "OPENCAD_EXAMPLE", [ + {"kind": "String", "value": "origin marker"}, + {"kind": "Point3D", "value": [0.0, 50.0, 0.0]}, + {"kind": "Integer32", "value": 1}, +]) +record = ocs.read_record(tagged, "OPENCAD_EXAMPLE") +assert record["app_name"] == "OPENCAD_EXAMPLE" +assert record["values"][0] == {"kind": "String", "value": "origin marker"} +ocs.remove_record(tagged, "OPENCAD_EXAMPLE") +assert ocs.read_record(tagged, "OPENCAD_EXAMPLE") is None diff --git a/plugins/opencad-python/plugin.toml b/plugins/opencad-python/plugin.toml new file mode 100644 index 000000000..ca975ae86 --- /dev/null +++ b/plugins/opencad-python/plugin.toml @@ -0,0 +1,22 @@ +[plugin] +id = "opencad.python" +name = "Python Scripting" +version = "0.1.0" +description = "Embedded Python scripting (RustPython) for Open CAD Studio." +author = "Felix Riestra" +license = "GPL-3.0-only" +repository = "HakanSeven12/OpenCADStudio" + +[opencad] +api_version = 7 +# Both filled by release.yml at build time from the actual compiler and +# Cargo.lock's resolved acadrust source. Declare acadrust_source even though +# it's optional: the host's compatibility check only runs at all if this +# field is present, so an omitted field is silently treated as compatible +# rather than rejected (see OpenCADStudio's plugin-architecture.md, +# "Build-time ABI metadata"). +rustc_version = "__RUSTC_VERSION__" +acadrust_source = "__ACADRUST_SOURCE__" +ribbon_order = 60 +command_prefixes = ["PY_"] +xdata_apps = [] diff --git a/plugins/opencad-python/src/dimension_model.rs.inc b/plugins/opencad-python/src/dimension_model.rs.inc new file mode 100644 index 000000000..bc3f29923 --- /dev/null +++ b/plugins/opencad-python/src/dimension_model.rs.inc @@ -0,0 +1,327 @@ +// Hand-written Dimension model, `include!`d next to the generated CRUD code. +// +// `EntityType::Dimension` wraps a nine-variant enum, so the generator cannot +// map it. Python sees one flat dict: `subtype` (fixed at creation), the +// shared base fields, and the fields of that subtype only. Fields that belong +// to another subtype are rejected rather than ignored. `actual_measurement` +// and the base `definition_point` are host-derived, never scripted. + +enum DimField<'a> { + V(&'a mut Vector3), + F(&'a mut f64), + B(&'a mut bool), +} + +const DIM_SUBTYPES: &[&str] = &[ + "Aligned", "Linear", "Radius", "Diameter", "Angular2Ln", "Angular3Pt", "Ordinate", "Arc", + "LargeRadial", +]; +const DIM_BASE_KEYS: &[&str] = &[ + "text", "style_name", "normal", "text_middle_point", "attachment_point", "text_rotation", + "horizontal_direction", "flip_arrow1", "flip_arrow2", "text_user_positioned", +]; +const DIM_READ_ONLY_KEYS: &[&str] = &["subtype", "actual_measurement"]; + +fn dimension_subtype_name(dim: &acadrust::entities::Dimension) -> &'static str { + use acadrust::entities::Dimension as D; + match dim { + D::Aligned(_) => "Aligned", + D::Linear(_) => "Linear", + D::Radius(_) => "Radius", + D::Diameter(_) => "Diameter", + D::Angular2Ln(_) => "Angular2Ln", + D::Angular3Pt(_) => "Angular3Pt", + D::Ordinate(_) => "Ordinate", + D::Arc(_) => "Arc", + D::LargeRadial(_) => "LargeRadial", + } +} + +/// Subtype-specific fields in stable order. +fn dimension_fields(dim: &mut acadrust::entities::Dimension) -> Vec<(&'static str, DimField<'_>)> { + use acadrust::entities::Dimension as D; + use DimField::{B, F, V}; + match dim { + D::Aligned(d) => vec![ + ("definition_point", V(&mut d.definition_point)), + ("first_point", V(&mut d.first_point)), + ("second_point", V(&mut d.second_point)), + ("ext_line_rotation", F(&mut d.ext_line_rotation)), + ], + D::Linear(d) => vec![ + ("definition_point", V(&mut d.definition_point)), + ("first_point", V(&mut d.first_point)), + ("second_point", V(&mut d.second_point)), + ("rotation", F(&mut d.rotation)), + ("ext_line_rotation", F(&mut d.ext_line_rotation)), + ], + D::Radius(d) => vec![ + ("definition_point", V(&mut d.definition_point)), + ("angle_vertex", V(&mut d.angle_vertex)), + ("leader_length", F(&mut d.leader_length)), + ], + D::Diameter(d) => vec![ + ("definition_point", V(&mut d.definition_point)), + ("angle_vertex", V(&mut d.angle_vertex)), + ("leader_length", F(&mut d.leader_length)), + ], + D::Angular2Ln(d) => vec![ + ("definition_point", V(&mut d.definition_point)), + ("dimension_arc", V(&mut d.dimension_arc)), + ("first_point", V(&mut d.first_point)), + ("second_point", V(&mut d.second_point)), + ("angle_vertex", V(&mut d.angle_vertex)), + ], + D::Angular3Pt(d) => vec![ + ("definition_point", V(&mut d.definition_point)), + ("first_point", V(&mut d.first_point)), + ("second_point", V(&mut d.second_point)), + ("angle_vertex", V(&mut d.angle_vertex)), + ], + D::Ordinate(d) => vec![ + ("definition_point", V(&mut d.definition_point)), + ("feature_location", V(&mut d.feature_location)), + ("leader_endpoint", V(&mut d.leader_endpoint)), + ("is_ordinate_type_x", B(&mut d.is_ordinate_type_x)), + ], + D::Arc(d) => vec![ + ("definition_point", V(&mut d.definition_point)), + ("first_extension_point", V(&mut d.first_extension_point)), + ("second_extension_point", V(&mut d.second_extension_point)), + ("center_point", V(&mut d.center_point)), + ("is_partial", B(&mut d.is_partial)), + ("arc_start_parameter", F(&mut d.arc_start_parameter)), + ("arc_end_parameter", F(&mut d.arc_end_parameter)), + ("has_leader", B(&mut d.has_leader)), + ("first_leader_point", V(&mut d.first_leader_point)), + ("second_leader_point", V(&mut d.second_leader_point)), + ], + D::LargeRadial(d) => vec![ + ("definition_point", V(&mut d.definition_point)), + ("chord_point", V(&mut d.chord_point)), + ("jog_angle", F(&mut d.jog_angle)), + ("override_center", V(&mut d.override_center)), + ("jog_point", V(&mut d.jog_point)), + ], + } +} + +/// Fields `ocs.add()` must receive (the geometry that defines the dimension). +fn dimension_required(subtype: &str) -> &'static [&'static str] { + match subtype { + "Aligned" | "Linear" => &["definition_point", "first_point", "second_point"], + "Radius" | "Diameter" => &["definition_point", "angle_vertex"], + "Angular2Ln" => &["definition_point", "dimension_arc", "first_point", "second_point", "angle_vertex"], + "Angular3Pt" => &["definition_point", "first_point", "second_point", "angle_vertex"], + "Ordinate" => &["feature_location", "leader_endpoint"], + "Arc" => &["definition_point", "first_extension_point", "second_extension_point", "center_point"], + "LargeRadial" => &["definition_point", "chord_point"], + _ => &[], + } +} + +fn dimension_base_mut(dim: &mut acadrust::entities::Dimension) -> &mut acadrust::entities::DimensionBase { + dim.base_mut() +} + +fn dim_attachment_point_name(value: acadrust::entities::AttachmentPointType) -> &'static str { + use acadrust::entities::AttachmentPointType as A; + match value { + A::TopLeft => "TopLeft", + A::TopCenter => "TopCenter", + A::TopRight => "TopRight", + A::MiddleLeft => "MiddleLeft", + A::MiddleCenter => "MiddleCenter", + A::MiddleRight => "MiddleRight", + A::BottomLeft => "BottomLeft", + A::BottomCenter => "BottomCenter", + A::BottomRight => "BottomRight", + } +} + +fn parse_dim_attachment_point( + value: &str, + vm: &VirtualMachine, +) -> PyResult { + use acadrust::entities::AttachmentPointType as A; + Ok(match value { + "TopLeft" => A::TopLeft, + "TopCenter" => A::TopCenter, + "TopRight" => A::TopRight, + "MiddleLeft" => A::MiddleLeft, + "MiddleCenter" => A::MiddleCenter, + "MiddleRight" => A::MiddleRight, + "BottomLeft" => A::BottomLeft, + "BottomCenter" => A::BottomCenter, + "BottomRight" => A::BottomRight, + other => { + return Err(vm.new_value_error(format!("ocs: unsupported AttachmentPointType value: {other}"))) + } + }) +} + +fn dimension_to_dict( + vm: &VirtualMachine, + dimension: &acadrust::entities::Dimension, +) -> PyResult { + let mut dim = dimension.clone(); + let dict = vm.ctx.new_dict(); + let base = dimension.base(); + dict.set_item("kind", vm.new_pyobj("Dimension"), vm)?; + dict.set_item("handle", vm.new_pyobj(base.common.handle.value()), vm)?; + dict.set_item("layer", vm.new_pyobj(base.common.layer.clone()), vm)?; + dict.set_item("owner_handle", vm.new_pyobj(base.common.owner_handle.value()), vm)?; + dict.set_item("subtype", vm.new_pyobj(dimension_subtype_name(dimension)), vm)?; + dict.set_item("actual_measurement", vm.new_pyobj(base.actual_measurement), vm)?; + dict.set_item("text", vm.new_pyobj(base.text.clone()), vm)?; + dict.set_item("style_name", vm.new_pyobj(base.style_name.clone()), vm)?; + dict.set_item("normal", vector3_to_py_dict(vm, &base.normal)?, vm)?; + dict.set_item("text_middle_point", vector3_to_py_dict(vm, &base.text_middle_point)?, vm)?; + dict.set_item("attachment_point", vm.new_pyobj(dim_attachment_point_name(base.attachment_point)), vm)?; + dict.set_item("text_rotation", vm.new_pyobj(base.text_rotation), vm)?; + dict.set_item("horizontal_direction", vm.new_pyobj(base.horizontal_direction), vm)?; + dict.set_item("flip_arrow1", vm.new_pyobj(base.flip_arrow1), vm)?; + dict.set_item("flip_arrow2", vm.new_pyobj(base.flip_arrow2), vm)?; + dict.set_item("text_user_positioned", vm.new_pyobj(base.text_user_positioned), vm)?; + for (name, field) in dimension_fields(&mut dim) { + let value: PyObjectRef = match field { + DimField::V(v) => vector3_to_py_dict(vm, v)?, + DimField::F(v) => vm.new_pyobj(*v), + DimField::B(v) => vm.new_pyobj(*v), + }; + dict.set_item(name, value, vm)?; + } + Ok(dict.into()) +} + +/// Merge `dict` onto `dim`. With `creating`, geometry the subtype needs must +/// be present so a script cannot silently default a point to the origin. +fn dimension_merge( + dim: &mut acadrust::entities::Dimension, + dict: &rustpython_vm::builtins::PyDictRef, + creating: bool, + vm: &VirtualMachine, +) -> PyResult<()> { + let subtype = dimension_subtype_name(dim); + let applicable: Vec<&'static str> = + dimension_fields(&mut dim.clone()).into_iter().map(|(name, _)| name).collect(); + for key in dict.keys_vec() { + let key = key.try_into_value::(vm)?; + let key = key.as_str(); + if matches!(key, "kind" | "handle" | "layer" | "owner_handle") || DIM_BASE_KEYS.contains(&key) { + continue; + } + if key == "subtype" { + let value = dict.get_item("subtype", vm)?.try_into_value::(vm)?; + if value != subtype { + return Err(vm.new_value_error("Dimension.subtype is read-only".to_owned())); + } + continue; + } + if key == "actual_measurement" { + return Err(vm.new_value_error("Dimension.actual_measurement is read-only".to_owned())); + } + if !applicable.contains(&key) { + let known = [ + "definition_point", "first_point", "second_point", "angle_vertex", "dimension_arc", + "feature_location", "leader_endpoint", "first_extension_point", "second_extension_point", + "center_point", "first_leader_point", "second_leader_point", "chord_point", + "override_center", "jog_point", "rotation", "ext_line_rotation", "leader_length", + "arc_start_parameter", "arc_end_parameter", "jog_angle", "is_ordinate_type_x", + "is_partial", "has_leader", + ]; + return Err(vm.new_value_error(if known.contains(&key) { + format!("Dimension.{key} does not apply to the {subtype} subtype") + } else { + format!("ocs: Dimension property {key:?} is outside the editable schema") + })); + } + } + if creating { + for name in dimension_required(subtype) { + if dict.get_item_opt(*name, vm)?.is_none() { + return Err(vm.new_value_error(format!("ocs.add: Dimension {subtype} requires {name}"))); + } + } + } + for (name, field) in dimension_fields(dim) { + let Some(value) = dict.get_item_opt(name, vm)? else { continue }; + match field { + DimField::V(target) => *target = py_to_vector3_dict(value, vm)?, + DimField::F(target) => *target = py_number_to_f64(value, vm)?, + DimField::B(target) => *target = value.try_into_value::(vm)?, + } + } + let base = dimension_base_mut(dim); + if let Some(v) = dict.get_item_opt("text", vm)? { base.text = v.try_into_value::(vm)?; } + if let Some(v) = dict.get_item_opt("style_name", vm)? { base.style_name = v.try_into_value::(vm)?; } + if let Some(v) = dict.get_item_opt("normal", vm)? { base.normal = py_to_vector3_dict(v, vm)?; } + if let Some(v) = dict.get_item_opt("text_middle_point", vm)? { base.text_middle_point = py_to_vector3_dict(v, vm)?; } + if let Some(v) = dict.get_item_opt("attachment_point", vm)? { + base.attachment_point = parse_dim_attachment_point(&v.try_into_value::(vm)?, vm)?; + } + if let Some(v) = dict.get_item_opt("text_rotation", vm)? { base.text_rotation = py_number_to_f64(v, vm)?; } + if let Some(v) = dict.get_item_opt("horizontal_direction", vm)? { base.horizontal_direction = py_number_to_f64(v, vm)?; } + if let Some(v) = dict.get_item_opt("flip_arrow1", vm)? { base.flip_arrow1 = v.try_into_value::(vm)?; } + if let Some(v) = dict.get_item_opt("flip_arrow2", vm)? { base.flip_arrow2 = v.try_into_value::(vm)?; } + if let Some(v) = dict.get_item_opt("text_user_positioned", vm)? { base.text_user_positioned = v.try_into_value::(vm)?; } + Ok(()) +} + +fn dimension_from_dict( + dict: &rustpython_vm::builtins::PyDictRef, + vm: &VirtualMachine, +) -> PyResult { + use acadrust::entities::*; + let subtype = match dict.get_item_opt("subtype", vm)? { + Some(v) => v.try_into_value::(vm)?, + None => { + return Err(vm.new_value_error(format!( + "ocs.add: Dimension requires subtype, one of {DIM_SUBTYPES:?}" + ))) + } + }; + let mut dim = match subtype.as_str() { + "Aligned" => Dimension::Aligned(DimensionAligned::default()), + "Linear" => Dimension::Linear(DimensionLinear::default()), + "Radius" => Dimension::Radius(DimensionRadius::default()), + "Diameter" => Dimension::Diameter(DimensionDiameter::default()), + "Angular2Ln" => Dimension::Angular2Ln(DimensionAngular2Ln::default()), + "Angular3Pt" => Dimension::Angular3Pt(DimensionAngular3Pt::default()), + "Ordinate" => Dimension::Ordinate(DimensionOrdinate::default()), + "Arc" => Dimension::Arc(DimensionArc::default()), + "LargeRadial" => Dimension::LargeRadial(DimensionLargeRadial::default()), + other => { + return Err(vm.new_value_error(format!( + "ocs.add: unsupported Dimension subtype {other:?}, expected one of {DIM_SUBTYPES:?}" + ))) + } + }; + dimension_merge(&mut dim, dict, true, vm)?; + let base = dimension_base_mut(&mut dim); + base.common.handle = acadrust::Handle::new(get_opt_u64(dict, "handle", vm)?); + base.common.layer = get_opt_string(dict, "layer", "0", vm)?; + base.common.owner_handle = acadrust::Handle::new(get_opt_u64(dict, "owner_handle", vm)?); + Ok(dim) +} + +fn dimension_apply( + existing: &acadrust::entities::Dimension, + dict: &rustpython_vm::builtins::PyDictRef, + vm: &VirtualMachine, +) -> PyResult { + let mut dim = existing.clone(); + dimension_merge(&mut dim, dict, false, vm)?; + let base = dimension_base_mut(&mut dim); + if let Some(v) = dict.get_item_opt("layer", vm)? { + if !vm.is_none(&v) { + base.common.layer = v.try_into_value::(vm)?; + } + } + if let Some(owner) = dict.get_item_opt("owner_handle", vm)? { + if owner.try_into_value::(vm)? != base.common.owner_handle.value() { + return Err(vm.new_value_error("owner_handle is read-only after creation".to_owned())); + } + } + Ok(dim) +} diff --git a/plugins/opencad-python/src/document_model.py b/plugins/opencad-python/src/document_model.py new file mode 100644 index 000000000..81d77d5ed --- /dev/null +++ b/plugins/opencad-python/src/document_model.py @@ -0,0 +1,993 @@ +"""Thin Python view over OCS host API v7 entity transactions and coverage.""" + +def _coordinates(value, width): + return dict(zip("xyz"[:width], (float(axis) for axis in value))) + + +def _coerce_points(kind, properties): + """Let create_entity take (x, y, z) tuples for point properties, as + transaction edits already do, by using the property types in the schema.""" + schema = ocs.entity_coverage(kind) + types = {row["name"]: row for row in schema.get("properties", [])} if schema else {} + result = {} + for name, value in properties.items(): + row = types.get(name) + if row is not None and row["type"] in ("Vector2", "Vector3"): + width = 2 if row["type"] == "Vector2" else 3 + if row["sequence"]: + value = [_coordinates(item, width) if isinstance(item, (tuple, list)) else item for item in value] + elif isinstance(value, (tuple, list)): + value = _coordinates(value, width) + result[name] = value + return result + + +class _Entity: + def __init__(self, document, handle): + object.__setattr__(self, "_document", document) + object.__setattr__(self, "handle", handle) + + def _data(self): + data = ocs.entity_descriptor(self.handle) + if data is None: + raise KeyError("entity %s is absent or outside the Python schema" % self.handle) + return data + + def __getattr__(self, name): + data = self._data() + if name not in data: + raise AttributeError("property %s is not in the Python entity schema" % name) + pending = self._document._pending or {} + value = pending.get(self.handle, {}).get(name, data[name]) + if isinstance(value, dict) and all(axis in value for axis in ("x", "y")): + return tuple(value[axis] for axis in ("x", "y", "z") if axis in value) + return value + + @property + def coverage(self): + return ocs.entity_coverage(self._data()["kind"]) + + @property + def snapshot(self): + """Detached copy of all serialized CAD fields for this entity.""" + data = ocs.entity_snapshot(self.handle) + if data is None: + raise KeyError("entity %s is absent" % self.handle) + return data[self._data()["kind"]] + + def __setattr__(self, name, value): + if self._document._pending is None: + raise RuntimeError("entity writes require doc.transaction(...)") + data = self._data() + can_edit = name in self.coverage["editable"] + if not can_edit or name not in data or name in ("kind", "handle", "_editable"): + raise AttributeError("property %s is not editable through this schema" % name) + old = data[name] + if isinstance(old, dict) and all(axis in old for axis in ("x", "y")): + axes = tuple(axis for axis in ("x", "y", "z") if axis in old) + if not isinstance(value, (tuple, list)) or len(value) != len(axes): + raise ValueError("%s expects %s coordinates" % (name, len(axes))) + value = dict(zip(axes, value)) + self._document._pending.setdefault(self.handle, {})[name] = value + + +class _Entities: + def __init__(self, document): + self._document = document + + def __getitem__(self, handle): + entity = _Entity(self._document, int(handle)) + entity._data() + return entity + + def __iter__(self): + for handle in ocs.entity_handles(): + yield self[handle] + + def __len__(self): + return len(ocs.entity_handles()) + + +class _Transaction: + def __init__(self, document, label): + self.document, self.label = document, label + + def __enter__(self): + if self.document._pending is not None: + raise RuntimeError("nested document transactions are unsupported") + self.document._pending = {} + return self.document + + def __exit__(self, exc_type, exc, tb): + updates = self.document._pending + self.document._pending = None + if exc_type is None and updates: + ocs.update_many(self.label, [dict(handle=handle, **patch) for handle, patch in updates.items()]) + return False + + +class _Solids: + """Kernel-backed solid creation and rigid moves. + + The host builds and edits the geometry; a script never sees the ACIS + payload. Every call is its own operation in the script's undo group and + fails, leaving the drawing unchanged, when the kernel cannot do it. + """ + + def __init__(self, document): + self._document = document + + def _create(self, primitive, values, layer): + handle = ocs.solid_create(primitive, [float(v) for v in values], layer) + return self._document.entities[handle] + + def box(self, center=(0, 0, 0), size=(1, 1, 1), layer=None): + return self._create("box", tuple(center) + tuple(size), layer) + + def wedge(self, origin=(0, 0, 0), size=(1, 1, 1), layer=None): + return self._create("wedge", tuple(origin) + tuple(size), layer) + + def cylinder(self, center=(0, 0, 0), radius=1, height=1, layer=None): + return self._create("cylinder", tuple(center) + (radius, height), layer) + + def sphere(self, center=(0, 0, 0), radius=1, layer=None): + return self._create("sphere", tuple(center) + (radius,), layer) + + def torus(self, center=(0, 0, 0), major=2, minor=0.5, layer=None): + return self._create("torus", tuple(center) + (major, minor), layer) + + def pyramid(self, center=(0, 0, 0), radius=1, height=1, sides=4, layer=None): + return self._create("pyramid", tuple(center) + (radius, height, sides), layer) + + def region(self, profile, layer=None, delete_source=False): + """Make a planar region from one closed planar profile (a circle, + ellipse, closed polyline or spline). Keeps the profile unless + `delete_source` is true, and uses the profile's layer by default.""" + handle = profile.handle if isinstance(profile, _Entity) else int(profile) + return self._document.entities[ocs.solid_region(handle, layer, bool(delete_source))] + + def _boolean(self, operation, first, second, layer, keep_operands): + a = first.handle if isinstance(first, _Entity) else int(first) + b = second.handle if isinstance(second, _Entity) else int(second) + return self._document.entities[ocs.solid_boolean(a, b, operation, layer, bool(keep_operands))] + + def union(self, first, second, layer=None, keep_operands=False): + """Join two solids into a new one. The operands are consumed unless + `keep_operands`. The kernel may refuse (the error says why) and a + refusal changes nothing. Curved operands can take a second or two, + and a whole script has a 30 second budget.""" + return self._boolean("union", first, second, layer, keep_operands) + + def subtract(self, first, second, layer=None, keep_operands=False): + """Remove `second` from `first`, giving a new solid.""" + return self._boolean("subtract", first, second, layer, keep_operands) + + def intersect(self, first, second, layer=None, keep_operands=False): + """Keep only the volume both solids share, as a new solid.""" + return self._boolean("intersect", first, second, layer, keep_operands) + + def surface(self, profile, layer=None, delete_source=False): + """Make a plane surface from one closed planar profile.""" + handle = profile.handle if isinstance(profile, _Entity) else int(profile) + return self._document.entities[ocs.solid_surface(handle, layer, bool(delete_source))] + + def extrude(self, profile, direction, layer=None, delete_source=False): + """Extrude a planar profile along `direction`: a Solid3D when the + profile is closed, a Surface when it is open.""" + handle = profile.handle if isinstance(profile, _Entity) else int(profile) + vector = [float(v) for v in direction] + return self._document.entities[ocs.solid_extrude(handle, vector, layer, bool(delete_source))] + + def transform(self, entity, matrix): + """Apply a column-major 4x4 rigid transform (16 numbers) in place.""" + handle = entity.handle if isinstance(entity, _Entity) else int(entity) + ocs.solid_transform(handle, [float(v) for v in matrix]) + return self._document.entities[handle] + + def translate(self, entity, offset): + dx, dy, dz = (float(v) for v in offset) + return self.transform(entity, [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, dx, dy, dz, 1]) + + def rotate(self, entity, axis, angle, about=(0, 0, 0)): + """Turn about the world axis 'x', 'y' or 'z' through `about`.""" + index = {"x": 0, "y": 1, "z": 2}[axis] + return self.transform(entity, ocs.rotation_matrix(index, float(angle), [float(v) for v in about])) + + def mirror(self, entity, axis, about=(0, 0, 0)): + """Reflect across the plane normal to the world axis through `about`.""" + index = {"x": 0, "y": 1, "z": 2}[axis] + return self.transform(entity, ocs.mirror_matrix(index, [float(v) for v in about])) + + +class _Layers: + """The drawing's layer table. Every change goes through the host: it is + validated, becomes one undo step, and a refusal raises `RuntimeError` + without changing the drawing.""" + + _KEYS = ("color", "linetype", "lineweight", "off", "frozen", "locked", + "plottable", "transparency", "description") + + def _records(self): + return ocs.layer_records() + + def __iter__(self): + return iter(self._records()) + + def __len__(self): + return len(self._records()) + + def __contains__(self, name): + wanted = str(name).strip().upper() + return any(r["name"].upper() == wanted for r in self._records()) + + def __getitem__(self, name): + wanted = str(name).strip().upper() + for record in self._records(): + if record["name"].upper() == wanted: + return record + raise KeyError(name) + + def get(self, name, default=None): + try: + return self[name] + except KeyError: + return default + + def names(self): + return [r["name"] for r in self._records()] + + @property + def current(self): + for record in self._records(): + if record["current"]: + return record + return None + + @current.setter + def current(self, name): + self.set_current(name) + + def _options(self, properties): + unknown = [k for k in properties if k not in self._KEYS] + if unknown: + raise TypeError("unknown layer propert%s: %s" % ("y" if len(unknown) == 1 else "ies", ", ".join(sorted(unknown)))) + return {k: v for k, v in properties.items() if v is not None} + + def create(self, name, **properties): + """Create a layer and return its record. `color` is an ACI index + (1-255), an (r, g, b) tuple or a Color dict; `lineweight` is in + 1/100 mm (-1 ByLayer, -2 ByBlock, -3 Default); `transparency` is a + percentage 0-90.""" + ocs.layer_operation("create", str(name), self._options(properties)) + return self[name] + + def modify(self, name, **properties): + """Change only the properties given and return the updated record.""" + ocs.layer_operation("modify", str(name), self._options(properties)) + return self[name] + + def rename(self, name, new_name): + ocs.layer_operation("rename", str(name), {"to": str(new_name)}) + return self[new_name] + + def delete(self, name, erase_objects=False): + """Delete a layer. A layer that still holds objects is refused unless + `erase_objects=True`. Layer 0, Defpoints and the current layer stay.""" + ocs.layer_operation("delete", str(name), {"erase_objects": bool(erase_objects)}) + + def set_current(self, name): + ocs.layer_operation("set_current", str(name), None) + return self[name] + + +class _Styles: + """Common lookup, rename, delete and current-style handling for the text + and dimension style tables. Every change is validated by the host, is one + undo step (making a style current is a setting), and a refusal raises + `RuntimeError` without changing the drawing. `Standard` is never renamed + or deleted, and a style that is current or in use is never deleted.""" + + _KIND = None + _RECORDS = None + + def _records(self): + return getattr(ocs, self._RECORDS)() + + def __iter__(self): + return iter(self._records()) + + def __len__(self): + return len(self._records()) + + def __contains__(self, name): + wanted = str(name).strip().upper() + return any(r["name"].upper() == wanted for r in self._records()) + + def __getitem__(self, name): + wanted = str(name).strip().upper() + for record in self._records(): + if record["name"].upper() == wanted: + return record + raise KeyError(name) + + def get(self, name, default=None): + try: + return self[name] + except KeyError: + return default + + def names(self): + return [r["name"] for r in self._records()] + + @property + def current(self): + for record in self._records(): + if record["current"]: + return record + return None + + @current.setter + def current(self, name): + self.set_current(name) + + def _run(self, op, name, options=None): + ocs.style_operation(self._KIND, op, str(name), options) + + def rename(self, name, new_name): + self._run("rename", name, {"to": str(new_name)}) + return self[new_name] + + def delete(self, name): + self._run("delete", name) + + def set_current(self, name): + self._run("set_current", name) + return self[name] + + +class _TextStyles(_Styles): + """`ocs.active_document.text_styles`. Properties: `height` (0 = each text + chooses), `width_factor`, `oblique` (degrees, within 85), `font` (SHX file), + `big_font`, `backward`, `upside_down`, `vertical`, `annotative`. Give a + TrueType face as a font file name such as `font="arial.ttf"`: the codec does + not persist a separate TrueType family name, so it is not exposed.""" + + _KIND = "text" + _RECORDS = "text_style_records" + _KEYS = ("height", "width_factor", "oblique", "font", "big_font", + "backward", "upside_down", "vertical", "annotative") + + def _options(self, properties): + unknown = [k for k in properties if k not in self._KEYS] + if unknown: + raise TypeError("unknown text style propert%s: %s" % ("y" if len(unknown) == 1 else "ies", ", ".join(sorted(unknown)))) + return {k: v for k, v in properties.items() if v is not None} + + def create(self, name, **properties): + self._run("create", name, self._options(properties)) + return self[name] + + def modify(self, name, **properties): + self._run("modify", name, self._options(properties)) + return self[name] + + +class _DimStyles(_Styles): + """`ocs.active_document.dim_styles`. Records and properties use the DimStyle + field names (`dimscale`, `dimtxt`, `dimasz`, `dimtxsty` ...); handles and + xref fields are host-managed and refused. `create(..., copy_from="Other")` + starts from an existing style.""" + + _KIND = "dim" + _RECORDS = "dim_style_records" + + def _options(self, properties): + return {k: v for k, v in properties.items() if v is not None or k == "dimclrd_true_color"} + + def create(self, name, copy_from=None, **properties): + options = self._options(properties) + if copy_from is not None: + options["copy_from"] = str(copy_from) + self._run("create", name, options) + return self[name] + + def modify(self, name, **properties): + self._run("modify", name, self._options(properties)) + return self[name] + + +class _Blocks: + """`ocs.active_document.blocks`: user block definitions (layout and + anonymous blocks are not listed). Every change is validated by the host, is + one undo step, and a refusal raises `RuntimeError` without changing the + drawing. Place a block with `create_entity("Insert", block_name=...)`.""" + + def _records(self): + return ocs.block_records() + + def __iter__(self): + return iter(self._records()) + + def __len__(self): + return len(self._records()) + + def __contains__(self, name): + wanted = str(name).strip().upper() + return any(r["name"].upper() == wanted for r in self._records()) + + def __getitem__(self, name): + wanted = str(name).strip().upper() + for record in self._records(): + if record["name"].upper() == wanted: + return record + raise KeyError(name) + + def get(self, name, default=None): + try: + return self[name] + except KeyError: + return default + + def names(self): + return [r["name"] for r in self._records()] + + def create(self, name, entities, base_point=(0, 0, 0), erase_originals=False, description=None): + """Define a block from existing model/paper-space entities (descriptors + or handles). They are copied into the definition shifted by + `-base_point`; `erase_originals=True` removes them from the drawing, as + the BLOCK command does. No insert is placed.""" + handles = [e.handle if isinstance(e, _Entity) else int(e) for e in entities] + options = {"entities": handles, "base_point": [float(v) for v in base_point], + "erase_originals": bool(erase_originals)} + if description is not None: + options["description"] = str(description) + ocs.block_operation("create", str(name), options) + return self[name] + + def modify(self, name, description=None, explodable=None, scale_uniformly=None): + options = {k: v for k, v in (("description", description), ("explodable", explodable), + ("scale_uniformly", scale_uniformly)) if v is not None} + ocs.block_operation("modify", str(name), options) + return self[name] + + def rename(self, name, new_name): + """Rename a block; every insert of it follows.""" + ocs.block_operation("rename", str(name), {"to": str(new_name)}) + return self[new_name] + + def delete(self, name): + """Delete a block that no insert, style or leader still uses.""" + ocs.block_operation("delete", str(name), None) + + +class _Named: + """Shared lookup for the linetype and layout collections.""" + + _RECORDS = None + + def _records(self): + return getattr(ocs, self._RECORDS)() + + def __iter__(self): + return iter(self._records()) + + def __len__(self): + return len(self._records()) + + def __contains__(self, name): + wanted = str(name).strip().upper() + return any(r["name"].upper() == wanted for r in self._records()) + + def __getitem__(self, name): + wanted = str(name).strip().upper() + for record in self._records(): + if record["name"].upper() == wanted: + return record + raise KeyError(name) + + def get(self, name, default=None): + try: + return self[name] + except KeyError: + return default + + def names(self): + return [r["name"] for r in self._records()] + + +class _Linetypes(_Named): + """`ocs.active_document.linetypes`. A pattern is signed lengths in drawing + units: positive is a dash, negative a gap, zero a dot (2-12 elements with at + least one gap and one dash or dot). Text and shape linetypes can be read + but not created or edited. Every change is validated by the host, is one + undo step, and a refusal raises `RuntimeError` without changing anything. + `Continuous`, `ByLayer` and `ByBlock` are never changed; a linetype used by + a layer, an entity, a dimension style or the current setting is never + deleted.""" + + _RECORDS = "linetype_records" + + def create(self, name, pattern, description=""): + ocs.linetype_operation("create", str(name), {"pattern": [float(v) for v in pattern], + "description": str(description)}) + return self[name] + + def modify(self, name, pattern=None, description=None): + options = {} + if pattern is not None: + options["pattern"] = [float(v) for v in pattern] + if description is not None: + options["description"] = str(description) + ocs.linetype_operation("modify", str(name), options) + return self[name] + + def rename(self, name, new_name): + ocs.linetype_operation("rename", str(name), {"to": str(new_name)}) + return self[new_name] + + def delete(self, name): + ocs.linetype_operation("delete", str(name), None) + + +class _Layouts(_Named): + """`ocs.active_document.layouts`: `Model` and the paper-space layouts in tab + order. Creation entities into a layout by making it current first + (`layouts.current = "Sheet1"`), then `create_entity(...)`. Every change is + validated by the host and is one undo step (switching layout is not); a + refusal raises `RuntimeError`. Layout operations act on the active + document.""" + + _RECORDS = "layout_records" + + @property + def current(self): + for record in self._records(): + if record["current"]: + return record + return None + + @current.setter + def current(self, name): + self.set_current(name) + + def create(self, name): + ocs.layout_operation("create", str(name), None) + return self[name] + + def rename(self, name, new_name): + ocs.layout_operation("rename", str(name), {"to": str(new_name)}) + return self[new_name] + + def delete(self, name): + """Delete a paper-space layout and everything on it (never `Model`).""" + ocs.layout_operation("delete", str(name), None) + + def set_current(self, name): + ocs.layout_operation("set_current", str(name), None) + return self[name] + + def set_page(self, name, paper_size=None, rotation=None, scale=None): + """`paper_size` is (width, height) in mm, `rotation` 0/90/180/270 degrees + and `scale` a custom plot scale (numerator, denominator).""" + options = {} + if paper_size is not None: + options["paper_size"] = [float(v) for v in paper_size] + if rotation is not None: + options["rotation"] = int(rotation) + if scale is not None: + options["scale"] = [float(v) for v in scale] + ocs.layout_operation("set_page", str(name), options) + return self[name] + + +def _pt(value): + """A point as [x, y, z] from a tuple, list or {"x", "y", "z"} dict.""" + if isinstance(value, dict): + return [float(value["x"]), float(value["y"]), float(value.get("z", 0.0))] + coords = [float(v) for v in value] + if len(coords) == 2: + coords.append(0.0) + if len(coords) != 3: + raise ValueError("a point needs 2 or 3 coordinates") + return coords + + +def _handle(value): + return value.handle if isinstance(value, _Entity) else int(value) + + +class _Command: + """One running OCS command, driven a step at a time: + + with doc.start_command("OFFSET") as c: + c.text(2); c.entity(line, at=(5, 0, 0)); c.point((5, 4, 0)); c.enter() + + Each step returns the outcome dict (`status`, `prompt`, `accepts`, + `options`, `added`, `error`, ...) and raises `RuntimeError` when the command + reports an error. Leaving the `with` block cancels the command if it is still + waiting. The host runs the real command, refuses to start one while another + is active, and refuses commands that could end the session (QUIT, NEW, OPEN, + SAVE...) or re-enter Python (PY_*).""" + + def __init__(self, name): + self.name = str(name) + self.outcome = None + self._send("start", name=self.name) + + def _send(self, kind, **options): + outcome = ocs.command_step(kind, options) + self.outcome = outcome + if outcome["error"]: + raise RuntimeError("%s: %s" % (self.name, outcome["error"])) + return outcome + + @property + def waiting(self): + return self.outcome is not None and self.outcome["status"] == "waiting_input" + + def point(self, point): + return self._send("point", point=_pt(point)) + + def text(self, value): + return self._send("text", text=str(value)) + + def token(self, keyword): + return self._send("token", text=str(keyword)) + + def entity(self, entity, at): + return self._send("entity", handle=_handle(entity), point=_pt(at)) + + def selection(self): + """Complete an object-selection prompt with the current selection.""" + return self._send("selection") + + def enter(self): + return self._send("enter") + + def cancel(self): + return self._send("cancel") + + def __enter__(self): + return self + + def __exit__(self, *exc): + if self.waiting: + ocs.command_step("cancel", None) + return False + + +def _finished(command, what): + """Require that `command` ended by itself; cancel and raise if it did not.""" + if command.waiting: + prompt = command.outcome["prompt"] + command.cancel() + raise RuntimeError("%s did not finish: still asking %r" % (what, prompt)) + + +class _Modify: + """Modify tools as plain calls. Each drives the real OCS command, so it + behaves exactly as at the command line, and returns the entities it added. + A tool that needs a pick point takes `at=` (a point on or near the entity); + without it the middle of a line, arc or circle is used.""" + + def __init__(self, document): + self._document = document + + def _at(self, entity, at): + if at is not None: + return _pt(at) + samples = ocs.curve_samples(_handle(entity), 4) + if samples is None: + # A polyline: its first vertex lies on it. + data = ocs.entity_descriptor(_handle(entity)) or {} + vertices = data.get("vertices") + if vertices: + first = vertices[0] + where = first.get("location") or first.get("position") if isinstance(first, dict) else None + if where: + return [float(where["x"]), float(where["y"]), float(where.get("z", 0.0))] + raise ValueError("pass at=(x, y, z): the middle of this kind of entity is not known") + middle = samples["vertices"][len(samples["vertices"]) // 2] + return [middle[0], middle[1], samples["elevation"]] + + def _select(self, entities): + ocs.select([_handle(e) for e in entities]) + + def _added(self, before): + before = set(before) + return [self._document.entities[h] for h in ocs.entity_handles() if h not in before] + + def _run(self, name, steps, entities=None): + before = list(ocs.entity_handles()) + if entities is not None: + self._select(entities) + with _Command(name) as command: + for step in steps: + step(command) + _finished(command, name) + return self._added(before) + + def offset(self, entity, distance, side, at=None): + """Offset one entity by `distance` towards the point `side`; returns the new entity.""" + result = self._run("OFFSET", [ + lambda c: c.text(distance), + lambda c: c.entity(entity, self._at(entity, at)), + lambda c: c.point(side), + lambda c: c.enter(), + ]) + if not result: + raise RuntimeError("OFFSET produced nothing") + return result[0] + + def trim(self, target, at): + """Trim `target` at the crossing nearest the click point `at`, against every entity in the drawing.""" + return self._run("TRIM", [lambda c: c.entity(target, at), lambda c: c.enter()]) + + def extend(self, target, at): + """Extend `target` from the end nearest `at` to the next entity in its way.""" + return self._run("EXTEND", [lambda c: c.entity(target, at), lambda c: c.enter()]) + + def fillet(self, first, second, radius=0, at1=None, at2=None): + """Round (or, with radius 0, square) the corner between two entities.""" + return self._run("FILLET", [ + lambda c: c.token("R"), + lambda c: c.text(radius), + lambda c: c.entity(first, self._at(first, at1)), + lambda c: c.entity(second, self._at(second, at2)), + lambda c: c.enter(), + ]) + + def move(self, entities, base, target): + self._run("MOVE", [lambda c: c.point(base), lambda c: c.point(target)], entities) + + def copy(self, entities, base, target): + """Copy the entities once from `base` to `target`; returns the copies.""" + return self._run("COPY", [lambda c: c.point(base), lambda c: c.point(target), lambda c: c.enter()], entities) + + def rotate(self, entities, base, angle): + """Rotate about `base` by `angle` degrees.""" + self._run("ROTATE", [lambda c: c.point(base), lambda c: c.text(angle)], entities) + + def scale(self, entities, base, factor): + self._run("SCALE", [lambda c: c.point(base), lambda c: c.text(factor)], entities) + + def mirror(self, entities, first, second, erase_source=False): + """Mirror about the line through two points; returns the mirrored copies.""" + return self._run("MIRROR", [ + lambda c: c.point(first), + lambda c: c.point(second), + lambda c: c.token("Y" if erase_source else "N"), + ], entities) + + def chamfer(self, first, second, distance1, distance2=None, at1=None, at2=None): + """Cut the corner between two lines; `distance1` is measured along `first` + and `distance2` (default the same) along `second`. Returns the new line.""" + if distance2 is None: + distance2 = distance1 + result = self._run("CHAMFER", [ + lambda c: c.token("D"), + lambda c: c.text(distance1), + lambda c: c.text(distance2), + lambda c: c.entity(first, self._at(first, at1)), + lambda c: c.entity(second, self._at(second, at2)), + ]) + if not result: + raise RuntimeError("CHAMFER produced nothing") + return result[0] + + def array_rect(self, entities, rows, columns, row_spacing, column_spacing): + """Rectangular array of the entities: `rows` by `columns` copies counting + the original, spaced by `row_spacing` and `column_spacing`. Returns the copies.""" + return self._run("ARRAYRECT", [ + lambda c: c.text(rows), + lambda c: c.text(columns), + lambda c: c.text(row_spacing), + lambda c: c.text(column_spacing), + ], entities) + + def array_polar(self, entities, center, count, angle=360): + """Polar array about `center`: `count` items counting the original, spread + over `angle` degrees. Returns the copies.""" + return self._run("ARRAYPOLAR", [ + lambda c: c.point(center), + lambda c: c.text(count), + lambda c: c.text(angle), + ], entities) + + def explode(self, entities): + """Break polylines, blocks and similar entities into their parts; returns the parts.""" + return self._run("EXPLODE", [], entities) + + def join(self, entities): + """Join end-to-end lines, arcs and polylines into one entity; returns it.""" + return self._run("JOIN", [], entities) + + def break_entity(self, entity, first, second): + """Remove the part of `entity` between two points on it; returns the new piece.""" + return self._run("BREAK", [ + lambda c: c.entity(entity, first), + lambda c: c.point(second), + ]) + + def stretch(self, corner1, corner2, base, target): + """Stretch whatever the crossing window (two opposite corners) catches, moving + `base` to `target`; entities fully inside move, ones crossing the edge stretch.""" + self._run("STRETCH", [ + lambda c: c.point(corner1), + lambda c: c.point(corner2), + lambda c: c.enter(), + lambda c: c.point(base), + lambda c: c.point(target), + ]) + + def lengthen(self, entity, delta, at=None): + """Lengthen (or, negative, shorten) `entity` by `delta` at the end nearest `at`.""" + self._run("LENGTHEN", [ + lambda c: c.token("DE"), + lambda c: c.text(delta), + lambda c: c.entity(entity, self._at(entity, at)), + lambda c: c.enter(), + ]) + + def array_path(self, entities, path, count, at=None): + """Array the entities along the curve `path`: `count` items counting the + original. Returns the copies.""" + return self._run("ARRAYPATH", [ + lambda c: c.entity(path, self._at(path, at)), + lambda c: c.text(count), + ], entities) + + def array_3d(self, entities, rows, columns, levels, row_spacing, column_spacing, level_spacing): + """Three-dimensional rectangular array (rows along Y, columns along X, levels + along Z); counts include the original. Returns the copies.""" + return self._run("ARRAY3D", [ + lambda c: c.text(rows), + lambda c: c.text(columns), + lambda c: c.text(levels), + lambda c: c.text(row_spacing), + lambda c: c.text(column_spacing), + lambda c: c.text(level_spacing), + ], entities) + + def _pedit(self, entity, steps, at=None): + """Run PEDIT on one entity: pick it, accept the offer to turn a line or arc + into a polyline, apply `steps`, then exit. Returns the polyline entity.""" + handle = _handle(entity) + before = list(ocs.entity_handles()) + with _Command("PEDIT") as command: + command.entity(handle, self._at(entity, at)) + if "Turn it into one" in command.outcome["prompt"]: + command.token("Y") + for step in steps: + step(command) + if command.waiting: + command.token("X") + _finished(command, "PEDIT") + if handle in set(ocs.entity_handles()): + return self._document.entities[handle] + added = self._added(before) + if not added: + raise RuntimeError("PEDIT left no polyline") + return added[0] + + def polyline_close(self, entity, at=None): + """Close an open polyline (a line or arc is first turned into one).""" + return self._pedit(entity, [lambda c: c.token("C")], at) + + def polyline_open(self, entity, at=None): + """Open a closed polyline.""" + return self._pedit(entity, [lambda c: c.token("O")], at) + + def polyline_width(self, entity, width, at=None): + """Give every segment of a polyline the same width.""" + return self._pedit(entity, [lambda c: c.token("W"), lambda c: c.text(width)], at) + + def polyline_reverse(self, entity, at=None): + """Reverse a polyline's direction.""" + return self._pedit(entity, [lambda c: c.token("R")], at) + + def polyline_join(self, entity, others, at=None): + """Join lines, arcs and polylines that meet the polyline's ends into it.""" + def join(c): + c.token("J") + self._select(others) + c.selection() + if c.waiting and "Join" in c.outcome["prompt"]: + c.enter() + return self._pedit(entity, [join], at) + + def erase(self, entities): + before = len(list(ocs.entity_handles())) + self._select(entities) + with _Command("ERASE") as command: + _finished(command, "ERASE") + return before - len(list(ocs.entity_handles())) + + +class _Document: + def __init__(self): + self._pending = None + self.layers = _Layers() + self.text_styles = _TextStyles() + self.dim_styles = _DimStyles() + self.blocks = _Blocks() + self.linetypes = _Linetypes() + self.layouts = _Layouts() + self.modify = _Modify(self) + self.entities = _Entities(self) + self.solids = _Solids(self) + + def command(self, line): + """Run one whole OCS command line, e.g. `"CIRCLE 5,5 3"`; the tokens after + the name answer the prompts in order and a final Enter finishes. Returns + the outcome dict. If the line leaves the command waiting for more input the + command is cancelled and `RuntimeError` names the prompt; use + `start_command` to answer prompts one at a time.""" + outcome = ocs.command_step("run", {"line": str(line)}) + if outcome["error"]: + raise RuntimeError("%s: %s" % (line, outcome["error"])) + if outcome["status"] != "completed": + prompt = outcome["prompt"] or outcome["blocked_by"] + ocs.command_step("cancel", None) + raise RuntimeError("%r did not finish: still asking %r" % (line, prompt)) + return outcome + + def start_command(self, name): + """Start a command and answer its prompts step by step (see `_Command`).""" + return _Command(name) + + def transaction(self, label): + return _Transaction(self, label) + + def create_entity(self, kind, block=None, **properties): + """Create one mapped entity and return its live document descriptor. + With `block="Name"` the entity is added to that block definition + instead of the drawing (its owner is set by the host).""" + if "kind" in properties or "handle" in properties: + raise ValueError("kind and handle are managed by create_entity") + entity = dict(kind=kind, **_coerce_points(kind, properties)) + if block is not None: + handle = ocs.add_to_block(str(block), entity) + else: + handle = ocs.add(entity) + return self.entities[handle] + + def delete_entity(self, entity): + """Delete an entity by descriptor or handle.""" + handle = entity.handle if isinstance(entity, _Entity) else int(entity) + ocs.remove_entity(handle) + + def embed_picture(self, path, origin=(0, 0, 0), width=1, layer=None): + """Embed a picture file (PNG, JPEG, BMP or another format re-encoded as + PNG) as an OLE frame. `origin` is the bottom-left corner and `width` + the frame width; the height follows the picture's aspect ratio.""" + handle = ocs.embed_picture(str(path), [float(v) for v in origin], float(width), layer) + return self.entities[handle] + + def coverage(self, kind=None): + if kind is not None: + return ocs.entity_coverage(kind) + return {name: ocs.entity_coverage(name) for name in ocs.entity_kinds()} + + def poll_events(self): + return ocs.poll_events(ocs.tab_id()) + + def request_point(self, prompt="Pick a point"): + return ocs.request_input(prompt, False) + + def request_entity(self, prompt="Pick an entity"): + return ocs.request_input(prompt, True) + + def poll_input(self, token): + return ocs.poll_input(token) + + @property + def selection(self): + return [self.entities[handle] for handle in ocs.selection()] + + @selection.setter + def selection(self, entities): + ocs.select([entity.handle if isinstance(entity, _Entity) else int(entity) for entity in entities]) + + +ocs.active_document = _Document() diff --git a/plugins/opencad-python/src/event_cache.rs b/plugins/opencad-python/src/event_cache.rs new file mode 100644 index 000000000..780fba65e --- /dev/null +++ b/plugins/opencad-python/src/event_cache.rs @@ -0,0 +1,223 @@ +//! Bounded, tab-scoped notifications retained between fresh Python runs. + +use std::collections::{HashMap, VecDeque}; +use std::sync::Mutex; + +use ocs_plugin_api::host::HostNotification; + +static EVENTS: Mutex>> = Mutex::new(None); +const CAPACITY_PER_TAB: usize = 256; + +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum Event { + Drawing { + tab_id: u64, + version: u64, + }, + Selection { + tab_id: u64, + handles: Vec, + }, + Command { + tab_id: u64, + command: Option, + }, + Overflow { + tab_id: u64, + dropped: u64, + }, +} + +impl Event { + fn tab_id(&self) -> u64 { + match self { + Event::Drawing { tab_id, .. } + | Event::Selection { tab_id, .. } + | Event::Command { tab_id, .. } + | Event::Overflow { tab_id, .. } => *tab_id, + } + } +} + +#[derive(Default)] +struct TabEvents { + queue: VecDeque, + dropped: u64, +} + +pub(crate) fn observe(notification: &HostNotification) { + if let HostNotification::DocumentTabClosed { tab_id } = notification { + if let Ok(mut lock) = EVENTS.lock() { + if let Some(tabs) = lock.as_mut() { + tabs.remove(tab_id); + } + } + return; + } + let event = match notification { + #[cfg(feature = "experimental-host-model")] + HostNotification::DrawingChanged { tab_id, epoch } => Event::Drawing { + tab_id: *tab_id, + version: *epoch, + }, + HostNotification::SelectionChangedV4 { tab_id, handles } => Event::Selection { + tab_id: *tab_id, + handles: handles.iter().map(|h| h.value()).collect(), + }, + #[cfg(feature = "experimental-host-model")] + HostNotification::CommandStateChanged { tab_id, command } => Event::Command { + tab_id: *tab_id, + command: command.clone(), + }, + _ => return, + }; + if let Ok(mut lock) = EVENTS.lock() { + let tab = lock + .get_or_insert_with(HashMap::new) + .entry(event.tab_id()) + .or_default(); + if tab.queue.len() == CAPACITY_PER_TAB { + tab.queue.pop_front(); + tab.dropped += 1; + } + tab.queue.push_back(event); + } +} + +pub(crate) fn drain_for(tab_id: u64) -> Vec { + let Ok(mut lock) = EVENTS.lock() else { + return Vec::new(); + }; + let Some(tabs) = lock.as_mut() else { + return Vec::new(); + }; + let Some(tab) = tabs.remove(&tab_id) else { + return Vec::new(); + }; + let mut events = Vec::with_capacity(tab.queue.len() + usize::from(tab.dropped > 0)); + if tab.dropped > 0 { + events.push(Event::Overflow { + tab_id, + dropped: tab.dropped, + }); + } + events.extend(tab.queue); + events +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn polling_one_tab_preserves_other_tabs_events() { + observe(&HostNotification::DrawingChanged { + tab_id: 101, + epoch: 1, + }); + observe(&HostNotification::DrawingChanged { + tab_id: 102, + epoch: 2, + }); + assert_eq!( + drain_for(101), + vec![Event::Drawing { + tab_id: 101, + version: 1 + }] + ); + assert_eq!( + drain_for(102), + vec![Event::Drawing { + tab_id: 102, + version: 2 + }] + ); + } + + #[test] + fn noisy_tab_reports_its_own_overflow_without_evicting_another_tab() { + observe(&HostNotification::DrawingChanged { + tab_id: 201, + epoch: 7, + }); + for epoch in 0..=CAPACITY_PER_TAB as u64 { + observe(&HostNotification::DrawingChanged { tab_id: 202, epoch }); + } + assert_eq!( + drain_for(201), + vec![Event::Drawing { + tab_id: 201, + version: 7 + }] + ); + let events = drain_for(202); + assert_eq!(events.len(), CAPACITY_PER_TAB + 1); + assert_eq!( + events[0], + Event::Overflow { + tab_id: 202, + dropped: 1 + } + ); + assert_eq!( + events[1], + Event::Drawing { + tab_id: 202, + version: 1 + } + ); + } + + #[test] + fn closing_tab_discards_its_pending_events() { + observe(&HostNotification::DrawingChanged { + tab_id: 301, + epoch: 3, + }); + observe(&HostNotification::DocumentTabClosed { tab_id: 301 }); + assert!(drain_for(301).is_empty()); + } + + #[test] + fn drawing_selection_and_command_events_keep_delivery_order() { + use ocs_plugin_api::host::Handle; + observe(&HostNotification::DrawingChanged { + tab_id: 401, + epoch: 9, + }); + observe(&HostNotification::SelectionChangedV4 { + tab_id: 401, + handles: vec![Handle::new(17)], + }); + observe(&HostNotification::CommandStateChanged { + tab_id: 401, + command: Some("LINE".into()), + }); + observe(&HostNotification::CommandStateChanged { + tab_id: 401, + command: None, + }); + assert_eq!( + drain_for(401), + vec![ + Event::Drawing { + tab_id: 401, + version: 9 + }, + Event::Selection { + tab_id: 401, + handles: vec![17] + }, + Event::Command { + tab_id: 401, + command: Some("LINE".into()) + }, + Event::Command { + tab_id: 401, + command: None + }, + ] + ); + } +} diff --git a/plugins/opencad-python/src/host_ctx.rs b/plugins/opencad-python/src/host_ctx.rs new file mode 100644 index 000000000..344f36362 --- /dev/null +++ b/plugins/opencad-python/src/host_ctx.rs @@ -0,0 +1,81 @@ +//! Thread-local scratch slot that gives the `ocs` module's native functions +//! access to the `HostApi` for the currently running `PY_EVAL` call. +//! +//! RustPython native functions registered via `#[pymodule]` are plain free +//! functions — `add_native_module` takes a `&'static PyModuleDef`, so there is +//! no per-call closure environment to capture `host: &mut dyn HostApi` in. +//! This is the standard pattern for bridging an embedder's per-call context +//! into embedded callbacks (the same shape as Lua userdata or a C callback's +//! `void *ctx`): stash a pointer for the duration of one interpreter run, and +//! clear it before the run's borrow of `host` ends. + +use std::cell::Cell; + +use ocs_plugin_api::host::HostApi; + +thread_local! { + static CURRENT_HOST: Cell> = const { Cell::new(None) }; + static UNDO_LABEL: Cell<&'static str> = const { Cell::new("") }; + static UNDO_STARTED: Cell = const { Cell::new(false) }; +} + +/// Holds the thread-local host pointer for its lifetime; clears it on drop. +/// Must not outlive the `&mut dyn HostApi` borrow it was constructed from. +pub(crate) struct HostGuard; + +impl HostGuard { + /// `undo_label` is the label a later `ensure_undo_started()` call (from + /// an `ocs` write function) will pass to `host.push_undo` — see its doc + /// comment for why this is one group per script, not one per call. + pub(crate) fn set(host: &mut dyn HostApi, undo_label: &'static str) -> Self { + // SAFETY: erases `host`'s borrow lifetime so the fat pointer fits in a + // `'static` thread_local slot (same layout either way: two words, + // data + vtable). Sound only because `Drop` clears the slot before + // this borrow's real lifetime ends — see `with_host`'s safety note. + let ptr: *mut dyn HostApi = + unsafe { std::mem::transmute::<&mut dyn HostApi, *mut dyn HostApi>(host) }; + CURRENT_HOST.with(|cell| cell.set(Some(ptr))); + UNDO_LABEL.with(|cell| cell.set(undo_label)); + UNDO_STARTED.with(|cell| cell.set(false)); + HostGuard + } +} + +impl Drop for HostGuard { + fn drop(&mut self) { + CURRENT_HOST.with(|cell| cell.set(None)); + } +} + +/// Call from an `ocs` write function (`add_line`, ...) right before it +/// mutates the document. Starts exactly one `host.push_undo` group per +/// `PY_EVAL`/`PY_RUN` call, however many entities the script adds — a script +/// that adds ten lines should undo as one step, not ten. A no-op for a +/// script that never writes, so a pure `PY_EVAL 1 + 1` doesn't clutter the +/// undo stack. +pub(crate) fn ensure_undo_started(host: &mut dyn HostApi) { + UNDO_STARTED.with(|started| { + if !started.get() { + host.push_undo(UNDO_LABEL.with(|label| label.get())); + started.set(true); + } + }); +} + +/// Run `f` with the `HostApi` active for the current `PY_EVAL` call, if any. +/// Returns `None` outside of a `HostGuard`-covered call (a script should never +/// observe this in practice, since the guard spans the whole interpreter run). +/// +/// Not reentrant: `f` must not call `with_host` again itself, since that would +/// materialize two live `&mut dyn HostApi` referring to the same host at once. +/// Fine for Phase 1.2's `selection()`/`get()`, which don't call each other. +pub(crate) fn with_host(f: impl FnOnce(&mut dyn HostApi) -> R) -> Option { + CURRENT_HOST.with(|cell| { + let ptr = cell.get()?; + // SAFETY: `ptr` is only ever set by `HostGuard::set`, which is created + // from a live `&mut dyn HostApi` and cleared (via `Drop`) before that + // borrow's scope ends. The interpreter run this pointer is valid for + // happens entirely on this thread, within that same scope. + Some(f(unsafe { &mut *ptr })) + }) +} diff --git a/plugins/opencad-python/src/input_cache.rs b/plugins/opencad-python/src/input_cache.rs new file mode 100644 index 000000000..dd839835b --- /dev/null +++ b/plugins/opencad-python/src/input_cache.rs @@ -0,0 +1,159 @@ +//! Results of host-driven point/entity picks, retained across Python runs. + +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Mutex; + +use ocs_plugin_api::host::{CommandStep, Handle, InteractiveCommand}; + +static NEXT_ID: AtomicU64 = AtomicU64::new(1); +static RESULTS: Mutex>> = Mutex::new(None); + +#[derive(Clone)] +pub(crate) enum PickResult { + Pending, + Point([f64; 3]), + Entity { handle: u64, point: [f64; 3] }, + Cancelled, +} + +pub(crate) struct PickCommand { + id: u64, + tab_id: u64, + prompt: String, + entity: bool, +} + +pub(crate) fn begin(tab_id: u64, prompt: String, entity: bool) -> (u64, PickCommand) { + let id = NEXT_ID.fetch_add(1, Ordering::Relaxed); + if let Ok(mut lock) = RESULTS.lock() { + let results = lock.get_or_insert_with(HashMap::new); + if results.len() >= 1024 { + if let Some(oldest) = results.keys().min().copied() { + results.remove(&oldest); + } + } + results.insert(id, (tab_id, PickResult::Pending)); + } + ( + id, + PickCommand { + id, + tab_id, + prompt, + entity, + }, + ) +} + +pub(crate) fn take(tab_id: u64, id: u64) -> Option { + let mut lock = RESULTS.lock().ok()?; + let results = lock.as_mut()?; + let (owner, result) = results.get(&id)?; + if *owner != tab_id { + return None; + } + if matches!(result, PickResult::Pending) { + return Some(PickResult::Pending); + } + results.remove(&id).map(|(_, result)| result) +} + +impl PickCommand { + fn finish(&self, result: PickResult) { + if let Ok(mut lock) = RESULTS.lock() { + lock.get_or_insert_with(HashMap::new) + .insert(self.id, (self.tab_id, result)); + } + } +} + +impl Drop for PickCommand { + fn drop(&mut self) { + if let Ok(mut lock) = RESULTS.lock() { + if let Some((owner, PickResult::Pending)) = lock.as_mut().and_then(|r| r.get(&self.id)) + { + if *owner == self.tab_id { + lock.as_mut() + .unwrap() + .insert(self.id, (self.tab_id, PickResult::Cancelled)); + } + } + } + } +} + +impl InteractiveCommand for PickCommand { + fn prompt(&self) -> String { + self.prompt.clone() + } + fn needs_object_pick(&self) -> bool { + self.entity + } + fn on_point(&mut self, point: [f64; 3]) -> CommandStep { + if self.entity { + return CommandStep::NeedPoint; + } + self.finish(PickResult::Point(point)); + CommandStep::Done + } + fn on_object_pick(&mut self, handle: Handle, point: [f64; 3]) -> CommandStep { + if !self.entity { + return CommandStep::NeedPoint; + } + self.finish(PickResult::Entity { + handle: handle.value(), + point, + }); + CommandStep::Done + } + fn on_enter(&mut self) -> CommandStep { + self.finish(PickResult::Cancelled); + CommandStep::Cancel + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn point_and_entity_picks_complete_tokens() { + let (point_token, mut point) = begin(7, "Point".into(), false); + assert!(matches!(take(7, point_token), Some(PickResult::Pending))); + assert!(take(8, point_token).is_none()); + assert!(matches!(point.on_point([1.0, 2.0, 3.0]), CommandStep::Done)); + assert!(take(8, point_token).is_none()); + assert!(matches!( + take(7, point_token), + Some(PickResult::Point([1.0, 2.0, 3.0])) + )); + let (entity_token, mut entity) = begin(8, "Entity".into(), true); + assert!(entity.needs_object_pick()); + assert!(matches!( + entity.on_object_pick(Handle::new(17), [2.0, 0.0, 0.0]), + CommandStep::Done + )); + assert!(matches!( + take(8, entity_token), + Some(PickResult::Entity { handle: 17, .. }) + )); + } + + #[test] + fn abandoned_pick_reports_cancellation() { + let (token, command) = begin(7, "Point".into(), false); + drop(command); + assert!(take(8, token).is_none()); + assert!(matches!(take(7, token), Some(PickResult::Cancelled))); + } + + #[test] + fn enter_cancels_pick_without_returning_a_point() { + let (token, mut command) = begin(9, "Pick".into(), false); + assert!(matches!(command.on_enter(), CommandStep::Cancel)); + assert!(matches!(take(9, token), Some(PickResult::Cancelled))); + drop(command); + assert!(take(9, token).is_none()); + } +} diff --git a/plugins/opencad-python/src/lib.rs b/plugins/opencad-python/src/lib.rs new file mode 100644 index 000000000..1603fbfe0 --- /dev/null +++ b/plugins/opencad-python/src/lib.rs @@ -0,0 +1,321 @@ +//! Python scripting plugin for Open CAD Studio. +//! +//! Phase 1.2 of `ROADMAP.md`: a read-only `ocs` module (`ocs.selection()`, +//! `ocs.get(handle)`) plus `PY_EVAL ` to evaluate one inline Python +//! expression. Phase 1.3: 2D write access (`ocs.add_line`/`ocs.add_circle`) +//! plus `PY_RUN ` to run a script file. The experimental +//! `ocs.command("...")` / `ocs.select(handles)` functions remain disabled in +//! the bundled build because nested command replay can hang. + +#[cfg(feature = "experimental-host-model")] +mod event_cache; +mod host_ctx; +#[cfg(feature = "experimental-host-model")] +mod input_cache; +mod ocs_module; +mod selection_cache; + +use ocs_plugin_api::host::{BuiltinPlugin, HostApi, HostNotification}; +use ocs_plugin_api::manifest::{ApiVersion, PluginManifest}; +use ocs_plugin_api::ribbon::{CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, ToolDef}; + +pub(crate) const PLUGIN_ID: &str = "opencad.python"; + +// Keep these fields in sync with `plugin.toml`. +static MANIFEST: PluginManifest = PluginManifest { + id: PLUGIN_ID, + name: "Python Scripting", + version: env!("CARGO_PKG_VERSION"), + description: "Embedded Python scripting (RustPython) for Open CAD Studio.", + api_version: ApiVersion::CURRENT, + ribbon_order: 60, + xdata_apps: &[], + command_prefixes: &["PY_"], +}; + +/// The ribbon tab. +struct PythonModule; + +impl CadModule for PythonModule { + fn id(&self) -> &'static str { + "opencad_python" + } + fn title(&self) -> &'static str { + "Python Scripting" + } + fn ribbon_groups(&self) -> &[RibbonGroup] { + static GROUPS: std::sync::OnceLock> = std::sync::OnceLock::new(); + GROUPS.get_or_init(|| { + vec![RibbonGroup { + title: "Tools", + tools: vec![ + RibbonItem::LargeTool(ToolDef { + id: "PY_EVAL", + label: "Eval 1+1", + icon: IconKind::Glyph("★"), + event: ModuleEvent::Command("PY_EVAL 1 + 1".to_string()), + }), + RibbonItem::LargeTool(ToolDef { + id: "PY_RUN", + label: "Run Script…", + icon: IconKind::Glyph("▶"), + event: ModuleEvent::PluginFileDialog { + command: "PY_RUN".to_string(), + title: "Run Python Script".to_string(), + filter_name: "Python Scripts".to_string(), + extensions: vec!["py".to_string()], + }, + }), + ], + }] + }) + } +} + +/// The plugin entry point. +struct PythonPlugin; + +impl BuiltinPlugin for PythonPlugin { + fn manifest(&self) -> &'static PluginManifest { + &MANIFEST + } + fn ribbon(&self) -> Box { + Box::new(PythonModule) + } + fn dispatch(&self, host: &mut dyn HostApi, cmd: &str) -> bool { + if let Some(rest) = cmd.strip_prefix("PY_EVAL") { + let source = rest.trim(); + if source.is_empty() { + host.push_error("PY_EVAL: expected a Python expression, e.g. PY_EVAL 1 + 1"); + } else { + run_eval(host, source); + } + return true; + } + if let Some(rest) = cmd.strip_prefix("PY_RUN") { + let path = rest.trim(); + if path.is_empty() { + host.push_error("PY_RUN: expected a file path, e.g. PY_RUN script.py"); + } else { + run_file(host, path); + } + return true; + } + false + } + + /// The runner's own event loop drains `HostApi::try_recv_notification()` + /// *before* any `Dispatch` request reaches `dispatch()` — calling it from + /// there always sees an empty queue. This callback is the only place a + /// V4+ plugin actually observes notifications; see `selection_cache.rs`. + fn on_notification(&mut self, _command_id: Option, notification: HostNotification) { + #[cfg(feature = "experimental-host-model")] + event_cache::observe(¬ification); + match notification { + HostNotification::SelectionChanged { handles } => selection_cache::set(handles), + HostNotification::SelectionChangedV4 { handles, .. } => selection_cache::set(handles), + _ => {} + } + } +} + +/// Evaluate one Python expression against a fresh interpreter (no session +/// persistence yet — that's a Phase 3 open question) and report the result or +/// error via the command line. A script bug must never look like a silent +/// no-op, so both paths always push something. +fn run_eval(host: &mut dyn HostApi, source: &str) { + let guard = host_ctx::HostGuard::set(host, "PY_EVAL"); + + let builder = rustpython_vm::Interpreter::builder(Default::default()); + let ocs_def = ocs_module::module_def(&builder.ctx); + let interp = builder.add_native_module(ocs_def).build(); + + let outcome = interp.enter(|vm| -> Result<(String, String), String> { + let output = install_output_capture(vm)?; + let scope = vm.new_scope_with_builtins(); + // Pre-bind `ocs` in scope so a one-expression `PY_EVAL` can call + // `ocs.selection()` directly — `Mode::Eval` only accepts a single + // expression, so a script has no way to run an `import` statement + // itself. + let ocs = vm + .import("ocs", 0) + .map_err(|exc| exception_to_string(vm, &exc))?; + scope + .globals + .set_item("ocs", ocs, vm) + .map_err(|exc| exception_to_string(vm, &exc))?; + #[cfg(feature = "experimental-host-model")] + install_document_model(vm, &scope)?; + let code_obj = vm + .compile( + source, + rustpython_vm::compiler::Mode::Eval, + "".to_string(), + ) + .map_err(|err| err.to_string())?; + let result = match vm.run_code_obj(code_obj, scope) { + Ok(result) => result + .str(vm) + .map(|s| s.to_string()) + .map_err(|exc| exception_to_string(vm, &exc)), + Err(exc) => Err(exception_to_string(vm, &exc)), + }?; + Ok((result, captured_output(vm, &output)?)) + }); + + drop(guard); + + match outcome { + Ok((text, printed)) => { + if !printed.is_empty() { + host.push_output(&printed); + } + host.push_output(&text); + } + Err(err) => host.push_error(&err), + } +} + +/// Run a `.py` file (`PY_RUN `) — same interpreter setup as `PY_EVAL`, +/// but `Mode::Exec` (statements, not a single expression) since a file can +/// hold a whole script. Reports errors the same way as `PY_EVAL`; on success +/// reports that the script ran rather than a value (`Mode::Exec` doesn't +/// produce one). +fn run_file(host: &mut dyn HostApi, path: &str) { + let source = match std::fs::read_to_string(path) { + Ok(text) => text, + Err(err) => { + host.push_error(&format!("PY_RUN: cannot read {path}: {err}")); + return; + } + }; + + let guard = host_ctx::HostGuard::set(host, "PY_RUN"); + + let builder = rustpython_vm::Interpreter::builder(Default::default()); + let ocs_def = ocs_module::module_def(&builder.ctx); + let interp = builder.add_native_module(ocs_def).build(); + + let outcome = interp.enter(|vm| -> Result { + let output = install_output_capture(vm)?; + let scope = vm.new_scope_with_builtins(); + let ocs = vm + .import("ocs", 0) + .map_err(|exc| exception_to_string(vm, &exc))?; + scope + .globals + .set_item("ocs", ocs, vm) + .map_err(|exc| exception_to_string(vm, &exc))?; + #[cfg(feature = "experimental-host-model")] + install_document_model(vm, &scope)?; + let code_obj = vm + .compile( + &source, + rustpython_vm::compiler::Mode::Exec, + path.to_string(), + ) + .map_err(|err| err.to_string())?; + vm.run_code_obj(code_obj, scope) + .map_err(|exc| exception_to_string(vm, &exc))?; + captured_output(vm, &output) + }); + + drop(guard); + + match outcome { + Ok(printed) => { + if !printed.is_empty() { + host.push_output(&printed); + } + host.push_output(&format!("PY_RUN: {path} completed")); + } + Err(err) => host.push_error(&err), + } +} + +#[cfg(feature = "experimental-host-model")] +fn install_document_model( + vm: &rustpython_vm::VirtualMachine, + scope: &rustpython_vm::scope::Scope, +) -> Result<(), String> { + let code = vm + .compile( + include_str!("document_model.py"), + rustpython_vm::compiler::Mode::Exec, + "".to_owned(), + ) + .map_err(|error| error.to_string())?; + vm.run_code_obj(code, scope.clone()) + .map_err(|exc| exception_to_string(vm, &exc))?; + Ok(()) +} + +#[cfg(all(test, feature = "experimental-host-model"))] +#[test] +fn document_model_bootstraps_in_rustpython() { + let builder = rustpython_vm::Interpreter::builder(Default::default()); + let ocs_def = ocs_module::module_def(&builder.ctx); + let interp = builder.add_native_module(ocs_def).build(); + interp.enter(|vm| { + let scope = vm.new_scope_with_builtins(); + let ocs = vm.import("ocs", 0).unwrap(); + scope.globals.set_item("ocs", ocs, vm).unwrap(); + install_document_model(vm, &scope).unwrap(); + let code = vm + .compile( + "ocs.active_document.transaction('Move line').label", + rustpython_vm::compiler::Mode::Eval, + "".to_owned(), + ) + .unwrap(); + let label = vm.run_code_obj(code, scope).unwrap(); + assert_eq!(label.str(vm).unwrap().to_string(), "Move line"); + }); +} + +/// RustPython is built without its stdio feature, so sys.stdout/stderr are +/// None by default. Give each fresh interpreter a memory stream for print(). +fn install_output_capture( + vm: &rustpython_vm::VirtualMachine, +) -> Result { + let sys = vm + .import("sys", 0) + .map_err(|exc| exception_to_string(vm, &exc))?; + let io = vm + .import("_io", 0) + .map_err(|exc| exception_to_string(vm, &exc))?; + let output = vm + .call_method(&io, "StringIO", ()) + .map_err(|exc| exception_to_string(vm, &exc))?; + sys.set_attr("stdout", output.clone(), vm) + .map_err(|exc| exception_to_string(vm, &exc))?; + sys.set_attr("stderr", output.clone(), vm) + .map_err(|exc| exception_to_string(vm, &exc))?; + Ok(output) +} + +fn captured_output( + vm: &rustpython_vm::VirtualMachine, + output: &rustpython_vm::PyObjectRef, +) -> Result { + vm.call_method(output, "getvalue", ()) + .and_then(|value| value.str(vm)) + .map(|value| value.to_string().trim_end_matches('\n').to_owned()) + .map_err(|exc| exception_to_string(vm, &exc)) +} + +/// `": "`, the same shape `str(exception)` plus its type +/// name produces in CPython — no full traceback yet (Phase 1.2 is minimal; +/// see the roadmap's error-handling note). +fn exception_to_string( + vm: &rustpython_vm::VirtualMachine, + exc: &rustpython_vm::builtins::PyBaseExceptionRef, +) -> String { + let exc_obj: rustpython_vm::PyObjectRef = exc.clone().into(); + let type_name = exc_obj.class().name().to_string(); + let message = exc_obj.str(vm).map(|s| s.to_string()).unwrap_or_default(); + format!("{type_name}: {message}") +} + +// Emit the C-ABI symbols the host loader looks for. +ocs_plugin_api::export_plugin!(PythonPlugin); diff --git a/plugins/opencad-python/src/ocs_module.rs b/plugins/opencad-python/src/ocs_module.rs new file mode 100644 index 000000000..4039d33e1 --- /dev/null +++ b/plugins/opencad-python/src/ocs_module.rs @@ -0,0 +1,5996 @@ +//! The `ocs` Python module: read-only entity access (Phase 1.2) plus 2D write +//! access (Phase 1.3) of `docs/python-scripting-roadmap.md`. + +pub(crate) use ocs::module_def; + +#[rustpython_vm::pymodule] +mod ocs { + use std::io::Write; + + #[cfg(feature = "experimental-host-settings")] + use ocs_plugin_api::host::HostSettingValue; + #[cfg(feature = "experimental-host-settings")] + use rustpython_vm::convert::TryFromObject; + use rustpython_vm::{function::ArgIntoFloat, PyObjectRef, PyResult, VirtualMachine}; + + use acadrust::entities::{ + Arc, LwPolyline, LwVertex, MText, TableBuilder, Text, TextHorizontalAlignment, + TextVerticalAlignment, + }; + use acadrust::objects::ObjectType; + use acadrust::types::{Transform, Vector2, Vector3}; + use ocs_plugin_api::host::{ + acadrust, EntityType, ExtendedDataRecord, Handle, ReaderEntityKind, XDataValue, + }; + + use crate::host_ctx; + + /// Write one new UTF-8 report file. The caller must provide an explicit + /// path; existing files are never overwritten and parent directories are + /// not created. + #[pyfunction] + fn write_new_text_file(path: String, content: String, vm: &VirtualMachine) -> PyResult<()> { + if host_ctx::with_host(|_| ()).is_none() { + return Err(vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned())); + } + if path.is_empty() { + return Err(vm.new_value_error("report path must not be empty".to_owned())); + } + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&path) + .map_err(|error| { + vm.new_runtime_error(format!("cannot create report {path}: {error}")) + })?; + if let Err(error) = file.write_all(content.as_bytes()) { + return Err(vm.new_runtime_error(format!( + "cannot finish report {path}: {error}; a partial file may remain" + ))); + } + Ok(()) + } + + fn top_level_blocks(document: &acadrust::CadDocument) -> std::collections::HashSet { + let mut blocks: std::collections::HashSet = document + .objects + .values() + .filter_map(|object| match object { + ObjectType::Layout(layout) if !layout.block_record.is_null() => { + Some(layout.block_record) + } + _ => None, + }) + .collect(); + if !document.header.model_space_block_handle.is_null() { + blocks.insert(document.header.model_space_block_handle); + } + if let Some(model) = document.block_records.get("*Model_Space") { + blocks.insert(model.handle); + } + blocks + } + + /// Handles of the entities selected as of the last `SelectionChangedV4` + /// notification (see `selection_cache.rs` — a best-effort cache, not a + /// live query). + #[pyfunction] + fn selection(vm: &VirtualMachine) -> PyResult { + #[cfg(feature = "experimental-host-model")] + let handles = host_ctx::with_host(|host| host.selection()).ok_or_else(|| { + vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()) + })?; + #[cfg(not(feature = "experimental-host-model"))] + let handles = crate::selection_cache::get(); + let items = handles + .into_iter() + .map(|h| vm.new_pyobj(h.value())) + .collect(); + Ok(vm.ctx.new_list(items).into()) + } + + /// Drain best-effort drawing, selection, and command-state notifications. + /// Events accumulated since the previous poll, up to a bounded queue. + #[cfg(feature = "experimental-host-model")] + #[pyfunction] + fn poll_events(tab_id: u64, vm: &VirtualMachine) -> PyResult { + use crate::event_cache::Event; + let current_tab = host_ctx::with_host(|host| host.tab_id()).ok_or_else(|| { + vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()) + })?; + if tab_id != current_tab { + return Err( + vm.new_value_error("ocs.poll_events: tab_id is not the active document".to_owned()) + ); + } + let mut items = Vec::new(); + for event in crate::event_cache::drain_for(tab_id) { + let row = vm.ctx.new_dict(); + match event { + Event::Drawing { tab_id, version } => { + row.set_item("type", vm.new_pyobj("drawing"), vm)?; + row.set_item("tab_id", vm.new_pyobj(tab_id), vm)?; + row.set_item("version", vm.new_pyobj(version), vm)?; + } + Event::Selection { tab_id, handles } => { + row.set_item("type", vm.new_pyobj("selection"), vm)?; + row.set_item("tab_id", vm.new_pyobj(tab_id), vm)?; + row.set_item( + "handles", + vm.ctx + .new_list(handles.into_iter().map(|h| vm.new_pyobj(h)).collect()) + .into(), + vm, + )?; + } + Event::Command { tab_id, command } => { + row.set_item("type", vm.new_pyobj("command"), vm)?; + row.set_item("tab_id", vm.new_pyobj(tab_id), vm)?; + row.set_item("command", vm.new_pyobj(command), vm)?; + } + Event::Overflow { tab_id, dropped } => { + row.set_item("type", vm.new_pyobj("overflow"), vm)?; + row.set_item("tab_id", vm.new_pyobj(tab_id), vm)?; + row.set_item("dropped", vm.new_pyobj(dropped), vm)?; + } + } + items.push(row.into()); + } + Ok(vm.ctx.new_list(items).into()) + } + + #[cfg(feature = "experimental-host-model")] + #[pyfunction] + fn tab_id(vm: &VirtualMachine) -> PyResult { + host_ctx::with_host(|host| host.tab_id()) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned())) + } + + /// Start a host-driven point or entity pick. Read the result in a later + /// script invocation with `poll_input(token)`. + #[cfg(feature = "experimental-host-model")] + #[pyfunction] + fn request_input(prompt: String, entity: bool, vm: &VirtualMachine) -> PyResult { + if prompt.trim().is_empty() { + return Err(vm.new_value_error("prompt must not be empty".to_owned())); + } + host_ctx::with_host(|host| { + let (token, command) = crate::input_cache::begin(host.tab_id(), prompt, entity); + host.start_interactive(Box::new(command)); + token + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned())) + } + + #[cfg(feature = "experimental-host-model")] + #[pyfunction] + fn poll_input(token: u64, vm: &VirtualMachine) -> PyResult { + use crate::input_cache::PickResult; + let tab_id = host_ctx::with_host(|host| host.tab_id()).ok_or_else(|| { + vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()) + })?; + let Some(result) = crate::input_cache::take(tab_id, token) else { + return Ok(vm.ctx.none()); + }; + let row = vm.ctx.new_dict(); + match result { + PickResult::Pending => row.set_item("status", vm.new_pyobj("pending"), vm)?, + PickResult::Cancelled => row.set_item("status", vm.new_pyobj("cancelled"), vm)?, + PickResult::Point(point) => { + row.set_item("status", vm.new_pyobj("point"), vm)?; + row.set_item( + "point", + vm.ctx + .new_list(point.into_iter().map(|v| vm.new_pyobj(v)).collect()) + .into(), + vm, + )?; + } + PickResult::Entity { handle, point } => { + row.set_item("status", vm.new_pyobj("entity"), vm)?; + row.set_item("handle", vm.new_pyobj(handle), vm)?; + row.set_item( + "point", + vm.ctx + .new_list(point.into_iter().map(|v| vm.new_pyobj(v)).collect()) + .into(), + vm, + )?; + } + } + Ok(row.into()) + } + + /// Read-only geometry/properties for one entity handle, or `None` if it + /// doesn't exist. Backed by `HostApi::document_reader()` (zero-copy read). + #[pyfunction] + fn get(handle: u64, vm: &VirtualMachine) -> PyResult { + let target = Handle::new(handle); + let found = host_ctx::with_host(|host| { + let reader = host.document_reader(); + let mut found = None; + reader.for_each_entity(&mut |entity| { + if found.is_none() && entity.handle == target { + found = Some((entity.kind, entity.layer_name.to_string(), entity.point)); + } + }); + found + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + + let Some((kind, layer, point)) = found else { + return Ok(vm.ctx.none()); + }; + + let dict = vm.ctx.new_dict(); + dict.set_item("handle", vm.new_pyobj(handle), vm)?; + dict.set_item("kind", vm.new_pyobj(kind_name(kind)), vm)?; + dict.set_item("layer", vm.new_pyobj(layer), vm)?; + let point_obj = match point { + Some(p) => { + let pd = vm.ctx.new_dict(); + pd.set_item("x", vm.new_pyobj(p.x), vm)?; + pd.set_item("y", vm.new_pyobj(p.y), vm)?; + pd.set_item("z", vm.new_pyobj(p.z), vm)?; + pd.into() + } + None => vm.ctx.none(), + }; + dict.set_item("point", point_obj, vm)?; + Ok(dict.into()) + } + + /// Full single-line TEXT geometry from the host's document snapshot. + /// `point` is the active DXF alignment point (10 for left/baseline, + /// otherwise 11), in the entity's OCS coordinate system. + #[pyfunction] + fn get_text(handle: u64, vm: &VirtualMachine) -> PyResult { + let found = + host_ctx::with_host( + |host| match host.document().get_entity(Handle::new(handle)) { + Some(EntityType::Text(text)) => Some(text.clone()), + _ => None, + }, + ) + .ok_or_else(|| { + vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()) + })?; + let Some(text) = found else { + return Ok(vm.ctx.none()); + }; + let point = active_text_point(&text); + let dict = vm.ctx.new_dict(); + dict.set_item("handle", vm.new_pyobj(handle), vm)?; + dict.set_item( + "point", + vm.ctx + .new_list(vec![ + vm.new_pyobj(point.x), + vm.new_pyobj(point.y), + vm.new_pyobj(point.z), + ]) + .into(), + vm, + )?; + dict.set_item( + "normal", + vm.ctx + .new_list(vec![ + vm.new_pyobj(text.normal.x), + vm.new_pyobj(text.normal.y), + vm.new_pyobj(text.normal.z), + ]) + .into(), + vm, + )?; + dict.set_item("rotation", vm.new_pyobj(text.rotation), vm)?; + dict.set_item( + "direction", + vm.ctx + .new_list(vec![ + vm.new_pyobj(text.rotation.cos()), + vm.new_pyobj(text.rotation.sin()), + ]) + .into(), + vm, + )?; + dict.set_item("height", vm.new_pyobj(text.height), vm)?; + dict.set_item("value", vm.new_pyobj(text.value), vm)?; + dict.set_item("style", vm.new_pyobj(text.style), vm)?; + Ok(dict.into()) + } + + /// Approximate XY box of left/baseline TEXT, using height and character count. + #[pyfunction] + fn text_box_vertices( + handle: u64, + padding: ArgIntoFloat, + vm: &VirtualMachine, + ) -> PyResult { + let padding = padding.into_float(); + if !padding.is_finite() || padding < 0.0 { + return Err(vm.new_value_error( + "ocs.text_box_vertices: padding must be finite and nonnegative".to_owned(), + )); + } + let found = host_ctx::with_host(|host| { + let Some(EntityType::Text(text)) = host.document().get_entity(Handle::new(handle)) + else { + return None; + }; + if text.normal != Vector3::UNIT_Z + || text.horizontal_alignment != TextHorizontalAlignment::Left + || text.vertical_alignment != TextVerticalAlignment::Baseline + || text.value.is_empty() + || ![ + text.height, + text.width_factor, + text.rotation, + text.insertion_point.x, + text.insertion_point.y, + text.insertion_point.z, + ] + .iter() + .all(|value| value.is_finite()) + || text.height <= 0.0 + || text.width_factor <= 0.0 + { + return None; + } + let width = text.value.chars().count() as f64 * text.height * 0.6 * text.width_factor; + let local = [ + (-padding, -padding), + (width + padding, -padding), + (width + padding, text.height + padding), + (-padding, text.height + padding), + ]; + let (sin, cos) = text.rotation.sin_cos(); + let vertices = local.map(|(x, y)| { + [ + text.insertion_point.x + x * cos - y * sin, + text.insertion_point.y + x * sin + y * cos, + ] + }); + Some((vertices, text.insertion_point.z)) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + let Some((vertices, elevation)) = found else { + return Ok(vm.ctx.none()); + }; + let dict = vm.ctx.new_dict(); + let points = vertices + .into_iter() + .map(|point| { + vm.ctx + .new_list(vec![vm.new_pyobj(point[0]), vm.new_pyobj(point[1])]) + .into() + }) + .collect(); + dict.set_item("vertices", vm.ctx.new_list(points).into(), vm)?; + dict.set_item("elevation", vm.new_pyobj(elevation), vm)?; + Ok(dict.into()) + } + + /// Add a top-left anchored MTEXT entity in the world XY plane. + #[pyfunction] + fn add_mtext( + value: rustpython_vm::builtins::PyStrRef, + point: Vec, + height: ArgIntoFloat, + width: ArgIntoFloat, + rotation: ArgIntoFloat, + vm: &VirtualMachine, + ) -> PyResult { + let (height, width, rotation) = ( + height.into_float(), + width.into_float(), + rotation.into_float(), + ); + if point.len() != 3 + || point.iter().any(|v| !v.is_finite()) + || ![height, width, rotation].iter().all(|v| v.is_finite()) + || height <= 0.0 + || width <= 0.0 + { + return Err(vm.new_value_error( + "ocs.add_mtext: coordinates must be finite; height and width must be positive" + .to_owned(), + )); + } + let mut text = MText::with_value( + value.to_string(), + Vector3::new(point[0], point[1], point[2]), + ) + .with_height(height) + .with_width(width); + text.rotation = rotation; + host_ctx::with_host(|host| { + host_ctx::ensure_undo_started(host); + host.add_entity(EntityType::MText(text)).value() + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned())) + } + + /// Build a kernel solid. `values` are the primitive's numbers in a fixed + /// order (see `document_model.py`); the host validates and builds it. + #[pyfunction] + fn solid_create( + primitive: String, + values: Vec, + layer: Option, + vm: &VirtualMachine, + ) -> PyResult { + use ocs_plugin_api::host::{SolidOperation, SolidPrimitive}; + let expect = |count: usize| -> PyResult<()> { + if values.len() == count { + Ok(()) + } else { + Err(vm.new_value_error(format!( + "ocs.solid_create: {primitive} takes {count} numbers, got {}", + values.len() + ))) + } + }; + let v3 = |i: usize| [values[i], values[i + 1], values[i + 2]]; + let primitive = match primitive.as_str() { + "box" => { expect(6)?; SolidPrimitive::Box { center: v3(0), size: v3(3) } } + "wedge" => { expect(6)?; SolidPrimitive::Wedge { origin: v3(0), size: v3(3) } } + "cylinder" => { expect(5)?; SolidPrimitive::Cylinder { center: v3(0), radius: values[3], height: values[4] } } + "sphere" => { expect(4)?; SolidPrimitive::Sphere { center: v3(0), radius: values[3] } } + "torus" => { expect(5)?; SolidPrimitive::Torus { center: v3(0), major: values[3], minor: values[4] } } + "pyramid" => { + expect(6)?; + if values[5] < 0.0 || values[5].fract() != 0.0 || values[5] > f64::from(u32::MAX) { + return Err(vm.new_value_error("ocs.solid_create: sides must be a whole number".to_owned())); + } + SolidPrimitive::Pyramid { center: v3(0), radius: values[3], height: values[4], sides: values[5] as u32 } + } + other => return Err(vm.new_value_error(format!("ocs.solid_create: unknown primitive {other:?}"))), + }; + // The host records its own undo step once the operation validates, like + // an entity transaction, so a refused operation leaves no empty step. + let result = host_ctx::with_host(|host| { + host.solid_operation(SolidOperation::Create { primitive, layer }) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + result + .map(|handle| handle.value()) + .map_err(|error| vm.new_runtime_error(format!("ocs.solid_create: {error}"))) + } + + /// Build a planar region from a closed planar profile entity. + #[pyfunction] + fn solid_region(source: u64, layer: Option, delete_source: bool, vm: &VirtualMachine) -> PyResult { + use ocs_plugin_api::host::SolidOperation; + let result = host_ctx::with_host(|host| { + host.solid_operation(SolidOperation::RegionFromProfile { + source: Handle::new(source), + layer, + delete_source, + }) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + result + .map(|handle| handle.value()) + .map_err(|error| vm.new_runtime_error(format!("ocs.solid_region: {error}"))) + } + + /// Embed a picture file as an OLE frame. + #[pyfunction] + fn embed_picture( + path: String, + origin: Vec, + width: f64, + layer: Option, + vm: &VirtualMachine, + ) -> PyResult { + use ocs_plugin_api::host::SolidOperation; + let origin: [f64; 3] = origin.try_into().map_err(|_| { + vm.new_value_error("ocs.embed_picture: the origin needs 3 numbers".to_owned()) + })?; + let result = host_ctx::with_host(|host| { + host.solid_operation(SolidOperation::EmbedPicture { path, origin, width, layer }) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + result + .map(|handle| handle.value()) + .map_err(|error| vm.new_runtime_error(format!("ocs.embed_picture: {error}"))) + } + + /// Combine two solids ("union", "subtract" or "intersect"). + #[pyfunction] + fn solid_boolean( + first: u64, + second: u64, + operation: String, + layer: Option, + keep_operands: bool, + vm: &VirtualMachine, + ) -> PyResult { + use ocs_plugin_api::host::{SolidBoolean, SolidOperation}; + let operation = match operation.as_str() { + "union" => SolidBoolean::Union, + "subtract" => SolidBoolean::Subtract, + "intersect" => SolidBoolean::Intersect, + other => { + return Err(vm.new_value_error(format!("ocs.solid_boolean: unknown operation {other:?}"))) + } + }; + let result = host_ctx::with_host(|host| { + host.solid_operation(SolidOperation::Boolean { + first: Handle::new(first), + second: Handle::new(second), + operation, + layer, + keep_operands, + }) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + result + .map(|handle| handle.value()) + .map_err(|error| vm.new_runtime_error(format!("ocs.solid_boolean: {error}"))) + } + + /// Build a plane surface from a closed planar profile entity. + #[pyfunction] + fn solid_surface(source: u64, layer: Option, delete_source: bool, vm: &VirtualMachine) -> PyResult { + use ocs_plugin_api::host::SolidOperation; + let result = host_ctx::with_host(|host| { + host.solid_operation(SolidOperation::SurfaceFromProfile { + source: Handle::new(source), + layer, + delete_source, + }) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + result + .map(|handle| handle.value()) + .map_err(|error| vm.new_runtime_error(format!("ocs.solid_surface: {error}"))) + } + + /// Extrude a planar profile: a solid if closed, a surface if open. + #[pyfunction] + fn solid_extrude( + source: u64, + direction: Vec, + layer: Option, + delete_source: bool, + vm: &VirtualMachine, + ) -> PyResult { + use ocs_plugin_api::host::SolidOperation; + let direction: [f64; 3] = direction.try_into().map_err(|_| { + vm.new_value_error("ocs.solid_extrude: the direction needs 3 numbers".to_owned()) + })?; + let result = host_ctx::with_host(|host| { + host.solid_operation(SolidOperation::Extrude { + source: Handle::new(source), + direction, + layer, + delete_source, + }) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + result + .map(|handle| handle.value()) + .map_err(|error| vm.new_runtime_error(format!("ocs.solid_extrude: {error}"))) + } + + /// Apply a column-major 4x4 rigid transform to a solid, body or region. + #[pyfunction] + fn solid_transform(handle: u64, matrix: Vec, vm: &VirtualMachine) -> PyResult { + use ocs_plugin_api::host::SolidOperation; + let matrix: [f64; 16] = matrix.try_into().map_err(|_| { + vm.new_value_error("ocs.solid_transform: the matrix needs 16 numbers".to_owned()) + })?; + let result = host_ctx::with_host(|host| { + host.solid_operation(SolidOperation::Transform { handle: Handle::new(handle), matrix }) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + result + .map(|handle| handle.value()) + .map_err(|error| vm.new_runtime_error(format!("ocs.solid_transform: {error}"))) + } + + /// Column-major matrix that turns about a world axis (0=X, 1=Y, 2=Z) + /// through the point `about`. The sandbox has no `math` module. + #[pyfunction] + fn rotation_matrix(axis: usize, angle: f64, about: Vec, vm: &VirtualMachine) -> PyResult { + if axis > 2 || about.len() != 3 || !angle.is_finite() || about.iter().any(|v| !v.is_finite()) { + return Err(vm.new_value_error("ocs.rotation_matrix: axis 0-2, a finite angle and a 3-point".to_owned())); + } + let (sin, cos) = angle.sin_cos(); + let (x, y, z) = match axis { + 0 => ([1.0, 0.0, 0.0], [0.0, cos, sin], [0.0, -sin, cos]), + 1 => ([cos, 0.0, -sin], [0.0, 1.0, 0.0], [sin, 0.0, cos]), + _ => ([cos, sin, 0.0], [-sin, cos, 0.0], [0.0, 0.0, 1.0]), + }; + Ok(matrix_to_py(vm, frame_matrix(x, y, z, &about))) + } + + /// Column-major matrix that reflects across the plane normal to a world + /// axis through the point `about`. + #[pyfunction] + fn mirror_matrix(axis: usize, about: Vec, vm: &VirtualMachine) -> PyResult { + if axis > 2 || about.len() != 3 || about.iter().any(|v| !v.is_finite()) { + return Err(vm.new_value_error("ocs.mirror_matrix: axis 0-2 and a finite 3-point".to_owned())); + } + let mut columns = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]; + columns[axis][axis] = -1.0; + let [x, y, z] = columns; + Ok(matrix_to_py(vm, frame_matrix(x, y, z, &about))) + } + + fn matrix_to_py(vm: &VirtualMachine, matrix: Vec) -> PyObjectRef { + vm.ctx.new_list(matrix.into_iter().map(|value| vm.new_pyobj(value)).collect()).into() + } + + /// A transform about `about` has origin `about - M * about`. + fn frame_matrix(x: [f64; 3], y: [f64; 3], z: [f64; 3], about: &[f64]) -> Vec { + let mut origin = [about[0], about[1], about[2]]; + for axis in 0..3 { + origin[axis] -= x[axis] * about[0] + y[axis] * about[1] + z[axis] * about[2]; + } + vec![ + x[0], x[1], x[2], 0.0, y[0], y[1], y[2], 0.0, z[0], z[1], z[2], 0.0, + origin[0], origin[1], origin[2], 1.0, + ] + } + + /// Delete one entity in the script's shared undo group. + #[pyfunction] + fn remove_entity(handle: u64, vm: &VirtualMachine) -> PyResult<()> { + let removed = host_ctx::with_host(|host| { + if host.document().get_entity(Handle::new(handle)).is_none() { + return false; + } + host_ctx::ensure_undo_started(host); + host.remove_entity(Handle::new(handle)) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + if !removed { + return Err(vm.new_runtime_error(format!( + "ocs.remove_entity: entity {handle} was not deleted" + ))); + } + Ok(()) + } + + /// Read the XDATA record for `app_name` on entity `handle`, or `None` if + /// the entity doesn't exist or carries no such record. Each value is a + /// `{"kind": ..., "value": ...}` dict — see `write_record` for the + /// supported kinds. + /// + /// The `{app_name, values}`/`{kind, value}` shape mirrors + /// `schoeller/ocs_python_repl`'s `ocs.doc.read_record(...)` (its `_ocs` + /// PyO3 extension) so a script's XDATA handling ports between the two + /// plugins unchanged. + #[pyfunction] + fn read_record(handle: u64, app_name: String, vm: &VirtualMachine) -> PyResult { + let record = + host_ctx::with_host(|host| host.read_record(Handle::new(handle), &app_name).cloned()) + .ok_or_else(|| { + vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()) + })?; + let Some(record) = record else { + return Ok(vm.ctx.none()); + }; + xdata_record_to_py(vm, &record) + } + + /// Attach an XDATA record to `handle` for `app_name`, replacing any + /// existing record for the same application. `values` is a list of + /// `{"kind": ..., "value": ...}` dicts; supported kinds: `String`, + /// `ControlString`, `LayerName`, `BinaryData` (Python `bytes`), `Handle` + /// (`int`), `Point3D`/`Position3D`/`Displacement3D`/`Direction3D` + /// (`[x, y, z]`), `Real`/`Distance`/`ScaleFactor` (`float`), and + /// `Integer16`/`Integer32` (`int`). Raises if `handle` does not exist. + #[pyfunction] + fn write_record( + handle: u64, + app_name: String, + values: Vec, + vm: &VirtualMachine, + ) -> PyResult<()> { + let mut record = ExtendedDataRecord::new(app_name); + for value in values { + let dict = value.try_into_value::(vm)?; + record.add_value(py_to_xdata_value(&dict, vm)?); + } + let written = host_ctx::with_host(|host| { + if host.document().get_entity(Handle::new(handle)).is_none() { + return false; + } + host_ctx::ensure_undo_started(host); + host.write_record(Handle::new(handle), record) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + if !written { + return Err( + vm.new_runtime_error(format!("ocs.write_record: entity {handle} does not exist")) + ); + } + Ok(()) + } + + /// Remove the XDATA record for `app_name` from `handle`, if any. Returns + /// `True` if a record was actually removed. + #[pyfunction] + fn remove_record(handle: u64, app_name: String, vm: &VirtualMachine) -> PyResult { + host_ctx::with_host(|host| { + if host.read_record(Handle::new(handle), &app_name).is_none() { + return false; + } + host_ctx::ensure_undo_started(host); + host.remove_record(Handle::new(handle), &app_name) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned())) + } + + fn xdata_record_to_py( + vm: &VirtualMachine, + record: &ExtendedDataRecord, + ) -> PyResult { + let dict = vm.ctx.new_dict(); + dict.set_item( + "app_name", + vm.new_pyobj(record.application_name.clone()), + vm, + )?; + let mut values = Vec::with_capacity(record.values.len()); + for value in &record.values { + values.push(xdata_value_to_py(vm, value)?); + } + dict.set_item("values", vm.ctx.new_list(values).into(), vm)?; + Ok(dict.into()) + } + + fn xdata_value_to_py(vm: &VirtualMachine, value: &XDataValue) -> PyResult { + let dict = vm.ctx.new_dict(); + let (kind, py_value): (&str, PyObjectRef) = match value { + XDataValue::String(s) => ("String", vm.new_pyobj(s.clone())), + XDataValue::ControlString(s) => ("ControlString", vm.new_pyobj(s.clone())), + XDataValue::LayerName(s) => ("LayerName", vm.new_pyobj(s.clone())), + XDataValue::BinaryData(b) => ("BinaryData", vm.ctx.new_bytes(b.clone()).into()), + XDataValue::Handle(h) => ("Handle", vm.new_pyobj(h.value())), + XDataValue::Point3D(v) => ("Point3D", vector3_to_py(vm, v)), + XDataValue::Position3D(v) => ("Position3D", vector3_to_py(vm, v)), + XDataValue::Displacement3D(v) => ("Displacement3D", vector3_to_py(vm, v)), + XDataValue::Direction3D(v) => ("Direction3D", vector3_to_py(vm, v)), + XDataValue::Real(r) => ("Real", vm.new_pyobj(*r)), + XDataValue::Distance(d) => ("Distance", vm.new_pyobj(*d)), + XDataValue::ScaleFactor(s) => ("ScaleFactor", vm.new_pyobj(*s)), + XDataValue::Integer16(i) => ("Integer16", vm.new_pyobj(*i)), + XDataValue::Integer32(i) => ("Integer32", vm.new_pyobj(*i)), + }; + dict.set_item("kind", vm.new_pyobj(kind), vm)?; + dict.set_item("value", py_value, vm)?; + Ok(dict.into()) + } + + fn vector3_to_py(vm: &VirtualMachine, v: &Vector3) -> PyObjectRef { + vm.ctx + .new_list(vec![ + vm.new_pyobj(v.x), + vm.new_pyobj(v.y), + vm.new_pyobj(v.z), + ]) + .into() + } + + fn py_to_xdata_value( + dict: &rustpython_vm::builtins::PyDictRef, + vm: &VirtualMachine, + ) -> PyResult { + let kind = dict.get_item("kind", vm)?.try_into_value::(vm)?; + let value = dict.get_item("value", vm)?; + match kind.as_str() { + "String" => Ok(XDataValue::String(value.try_into_value(vm)?)), + "ControlString" => Ok(XDataValue::ControlString(value.try_into_value(vm)?)), + "LayerName" => Ok(XDataValue::LayerName(value.try_into_value(vm)?)), + "BinaryData" => Ok(XDataValue::BinaryData(value.try_into_value(vm)?)), + "Handle" => Ok(XDataValue::Handle(Handle::new(value.try_into_value(vm)?))), + "Point3D" => Ok(XDataValue::Point3D(py_to_vector3(value, vm)?)), + "Position3D" => Ok(XDataValue::Position3D(py_to_vector3(value, vm)?)), + "Displacement3D" => Ok(XDataValue::Displacement3D(py_to_vector3(value, vm)?)), + "Direction3D" => Ok(XDataValue::Direction3D(py_to_vector3(value, vm)?)), + "Real" => Ok(XDataValue::Real(value.try_into_value(vm)?)), + "Distance" => Ok(XDataValue::Distance(value.try_into_value(vm)?)), + "ScaleFactor" => Ok(XDataValue::ScaleFactor(value.try_into_value(vm)?)), + "Integer16" => Ok(XDataValue::Integer16(value.try_into_value(vm)?)), + "Integer32" => Ok(XDataValue::Integer32(value.try_into_value(vm)?)), + other => Err(vm.new_value_error(format!( + "unsupported XDATA value kind: {other}; supported: String, ControlString, \ + LayerName, BinaryData, Handle, Point3D, Position3D, Displacement3D, \ + Direction3D, Real, Distance, ScaleFactor, Integer16, Integer32" + ))), + } + } + + fn py_to_vector3(value: PyObjectRef, vm: &VirtualMachine) -> PyResult { + let coords: Vec = value.try_into_value(vm)?; + if coords.len() != 3 || coords.iter().any(|v| !v.is_finite()) { + return Err(vm.new_value_error( + "XDATA Point3D/Position3D/Displacement3D/Direction3D value must be \ + [finite x, y, z]" + .to_owned(), + )); + } + Ok(Vector3::new(coords[0], coords[1], coords[2])) + } + + /// All top-level single- and multiline text in the current document. + #[pyfunction] + fn text_entities(vm: &VirtualMachine) -> PyResult { + let items = host_ctx::with_host(|host| { + host.document() + .entities() + .filter_map(|entity| match entity { + EntityType::Text(text) => { + Some((text.common.handle.value(), "TEXT", text.value.clone())) + } + EntityType::MText(text) => { + Some((text.common.handle.value(), "MTEXT", text.value.clone())) + } + _ => None, + }) + .collect::>() + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + let mut output = Vec::with_capacity(items.len()); + for (handle, kind, value) in items { + let dict = vm.ctx.new_dict(); + dict.set_item("handle", vm.new_pyobj(handle), vm)?; + dict.set_item("kind", vm.new_pyobj(kind), vm)?; + dict.set_item("value", vm.new_pyobj(value), vm)?; + output.push(dict.into()); + } + Ok(vm.ctx.new_list(output).into()) + } + + /// All top-level block references in the active document. + #[pyfunction] + fn block_references(vm: &VirtualMachine) -> PyResult { + let items = host_ctx::with_host(|host| { + let document = host.document(); + document + .entities() + .filter_map(|entity| match entity { + EntityType::Insert(insert) => { + let raw_name = insert.block_name.clone(); + let record = document + .block_records + .iter() + .find(|block| block.name == raw_name); + let is_xref = record.is_some_and(|block| block.flags.is_xref); + let dynamic_definition = document + .dynamic_definition_for_insert(insert.common.handle) + .and_then(|handle| { + document + .block_records + .iter() + .find(|block| block.handle == handle) + }); + let effective_name = dynamic_definition + .map_or_else(|| raw_name.clone(), |block| block.name.clone()); + let is_dynamic = effective_name != raw_name + || document + .dynamic_visibility_for_insert(insert.common.handle) + .is_some(); + let visibility = document + .dynamic_visibility_for_insert(insert.common.handle) + .and_then(|(definition, parameter)| { + let def_record = document + .block_records + .iter() + .find(|block| block.handle == definition)?; + let anon_record = record?; + let current: std::collections::HashSet = anon_record + .entity_handles + .iter() + .enumerate() + .filter(|(_, handle)| { + document + .get_entity(**handle) + .is_some_and(|entity| !entity.common().invisible) + }) + .map(|(index, _)| index) + .collect(); + parameter + .states + .iter() + .find(|state| { + let visible: std::collections::HashSet = def_record + .entity_handles + .iter() + .enumerate() + .filter(|(_, handle)| { + state.visible_blocks.contains(handle) + }) + .map(|(index, _)| index) + .collect(); + visible == current + }) + .map(|state| state.name.clone()) + }); + Some(( + insert.common.handle.value(), + raw_name, + effective_name, + insert.row_count, + insert.column_count, + is_xref, + is_dynamic, + visibility, + )) + } + _ => None, + }) + .collect::>() + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + let mut output = Vec::with_capacity(items.len()); + for (handle, name, effective_name, rows, columns, is_xref, is_dynamic, visibility) in items + { + let dict = vm.ctx.new_dict(); + dict.set_item("handle", vm.new_pyobj(handle), vm)?; + dict.set_item("name", vm.new_pyobj(name), vm)?; + dict.set_item("effective_name", vm.new_pyobj(effective_name), vm)?; + dict.set_item("is_xref", vm.new_pyobj(is_xref), vm)?; + dict.set_item("is_dynamic", vm.new_pyobj(is_dynamic), vm)?; + dict.set_item( + "visibility", + visibility.map_or_else(|| vm.ctx.none(), |state| vm.new_pyobj(state)), + vm, + )?; + dict.set_item( + "count", + vm.new_pyobj(u64::from(rows) * u64::from(columns)), + vm, + )?; + output.push(dict.into()); + } + Ok(vm.ctx.new_list(output).into()) + } + + #[cfg(feature = "experimental-host-model")] + fn layer_options( + name: String, + options: &rustpython_vm::builtins::PyDictRef, + vm: &VirtualMachine, + ) -> PyResult { + use acadrust::types::{Color, LineWeight, Transparency}; + ensure_known_entity_keys( + options, + "layer", + &["color", "linetype", "lineweight", "off", "frozen", "locked", "plottable", "transparency", "description"], + vm, + )?; + let present = |key: &str| -> PyResult> { + Ok(options.get_item_opt(key, vm)?.filter(|v| !vm.is_none(v))) + }; + let mut config = ocs_plugin_api::host::LayerConfig { name, ..Default::default() }; + if let Some(value) = present("color")? { + config.color = Some(if let Ok(index) = value.clone().try_into_value::(vm) { + let index = u8::try_from(index) + .ok() + .filter(|i| *i != 0) + .ok_or_else(|| vm.new_value_error("ocs: a layer color index must be 1..=255".to_owned()))?; + Color::Index(index) + } else if let Ok(rgb) = value.clone().try_into_value::>(vm) { + let byte = |v: i64| { + u8::try_from(v).map_err(|_| vm.new_value_error("ocs: RGB components must be 0..=255".to_owned())) + }; + match rgb.as_slice() { + [r, g, b] => Color::Rgb { r: byte(*r)?, g: byte(*g)?, b: byte(*b)? }, + _ => return Err(vm.new_value_error("ocs: an RGB color needs three numbers".to_owned())), + } + } else { + py_to_color_dict(value, vm)? + }); + } + if let Some(value) = present("linetype")? { + config.linetype = Some(value.try_into_value::(vm)?); + } + if let Some(value) = present("lineweight")? { + let weight = value.try_into_value::(vm)?; + let weight = i16::try_from(weight) + .map_err(|_| vm.new_value_error("ocs: lineweight is out of range".to_owned()))?; + config.lineweight = Some(LineWeight::from_value(weight)); + } + for (key, slot) in [ + ("off", &mut config.off), + ("frozen", &mut config.frozen), + ("locked", &mut config.locked), + ("plottable", &mut config.plottable), + ] { + if let Some(value) = present(key)? { + *slot = Some(value.try_into_value::(vm)?); + } + } + if let Some(value) = present("transparency")? { + let percent = py_number_to_f64(value, vm)?; + if !(0.0..=90.0).contains(&percent) { + return Err(vm.new_value_error("ocs: layer transparency is a percentage from 0 to 90".to_owned())); + } + config.transparency = Some(if percent == 0.0 { Transparency::ByLayer } else { Transparency::from_percent(percent) }); + } + if let Some(value) = present("description")? { + config.description = Some(value.try_into_value::(vm)?); + } + Ok(config) + } + + /// Layer-table change through the host: `op` is `create`, `modify`, + /// `rename` (`options={"to": ...}`), `delete` (`options={"erase_objects": bool}`) + /// or `set_current`. Returns the layer's handle. + #[cfg(feature = "experimental-host-model")] + #[pyfunction] + fn layer_operation(op: String, name: String, options: PyObjectRef, vm: &VirtualMachine) -> PyResult { + use ocs_plugin_api::host::TableOperation; + let options = if vm.is_none(&options) { + vm.ctx.new_dict() + } else { + options.try_into_value::(vm)? + }; + let operation = match op.as_str() { + "create" => TableOperation::LayerCreate { config: layer_options(name, &options, vm)? }, + "modify" => TableOperation::LayerModify { config: layer_options(name, &options, vm)? }, + "rename" => { + ensure_known_entity_keys(&options, "layer rename", &["to"], vm)?; + let to = options + .get_item_opt("to", vm)? + .ok_or_else(|| vm.new_value_error("ocs: rename needs the new name".to_owned()))? + .try_into_value::(vm)?; + TableOperation::LayerRename { from: name, to } + } + "delete" => { + ensure_known_entity_keys(&options, "layer delete", &["erase_objects"], vm)?; + let erase_objects = get_opt_bool(&options, "erase_objects", vm)?; + TableOperation::LayerDelete { name, erase_objects } + } + "set_current" => TableOperation::LayerSetCurrent { name }, + other => return Err(vm.new_value_error(format!("ocs.layer_operation: unknown operation {other:?}"))), + }; + let result = host_ctx::with_host(|host| host.table_operation(operation)) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + result + .map(|handle| handle.value()) + .map_err(|error| vm.new_runtime_error(format!("ocs.layer_operation: {error}"))) + } + + /// Convert a Python value (None, bool, int, float, str, list/tuple, dict + /// with string keys) to JSON. + #[cfg(feature = "experimental-host-model")] + fn py_to_json(value: &PyObjectRef, vm: &VirtualMachine) -> PyResult { + use rustpython_vm::builtins::{PyDict, PyFloat, PyInt, PyList, PyStr, PyTuple}; + use rustpython_vm::AsObject; + if vm.is_none(value) { + return Ok(serde_json::Value::Null); + } + if let Ok(flag) = value.clone().try_into_value::(vm) { + if value.class().is(vm.ctx.types.bool_type) { + return Ok(serde_json::Value::Bool(flag)); + } + } + if value.downcast_ref::().is_some() { + return Ok(serde_json::Value::from(value.clone().try_into_value::(vm)?)); + } + if value.downcast_ref::().is_some() { + let number = value.clone().try_into_value::(vm)?; + return serde_json::Number::from_f64(number) + .map(serde_json::Value::Number) + .ok_or_else(|| vm.new_value_error("ocs: numbers must be finite".to_owned())); + } + if value.downcast_ref::().is_some() { + return Ok(serde_json::Value::String(value.clone().try_into_value::(vm)?)); + } + if let Some(list) = value.downcast_ref::() { + let items: Vec = list.borrow_vec().to_vec(); + return items.iter().map(|item| py_to_json(item, vm)).collect::>>().map(serde_json::Value::Array); + } + if let Some(tuple) = value.downcast_ref::() { + return tuple.iter().map(|item| py_to_json(item, vm)).collect::>>().map(serde_json::Value::Array); + } + if let Some(dict) = value.downcast_ref::() { + let mut map = serde_json::Map::new(); + for key in dict.keys_vec() { + let name = key.clone().try_into_value::(vm)?; + let item = dict.get_item(&*key, vm)?; + map.insert(name, py_to_json(&item, vm)?); + } + return Ok(serde_json::Value::Object(map)); + } + Err(vm.new_type_error("ocs: unsupported value in a style property".to_owned())) + } + + #[cfg(feature = "experimental-host-model")] + fn style_kind(kind: &str, vm: &VirtualMachine) -> PyResult { + match kind { + "text" => Ok(ocs_plugin_api::host::TableStyleKind::Text), + "dim" => Ok(ocs_plugin_api::host::TableStyleKind::Dim), + other => Err(vm.new_value_error(format!("ocs.style_operation: unknown style kind {other:?}"))), + } + } + + /// Text/dimension style change through the host. `kind` is `text` or `dim`; + /// `op` is `create`, `modify`, `rename` (`{"to": ...}`), `delete` or + /// `set_current`. Text options: `height`, `width_factor`, `oblique` + /// (degrees), `font`, `big_font`, `backward`, + /// `upside_down`, `vertical`, `annotative`. Dimension options are DimStyle + /// field names (plus `copy_from` on create). Returns the style's handle. + #[cfg(feature = "experimental-host-model")] + #[pyfunction] + fn style_operation(kind: String, op: String, name: String, options: PyObjectRef, vm: &VirtualMachine) -> PyResult { + use ocs_plugin_api::host::{TableOperation, TextStyleConfig}; + let style_kind = style_kind(&kind, vm)?; + let options = if vm.is_none(&options) { + vm.ctx.new_dict() + } else { + options.try_into_value::(vm)? + }; + let operation = match (op.as_str(), kind.as_str()) { + ("rename", _) => { + ensure_known_entity_keys(&options, "style rename", &["to"], vm)?; + let to = options + .get_item_opt("to", vm)? + .ok_or_else(|| vm.new_value_error("ocs: rename needs the new name".to_owned()))? + .try_into_value::(vm)?; + TableOperation::StyleRename { kind: style_kind, from: name, to } + } + ("delete", _) => { + ensure_known_entity_keys(&options, "style delete", &[], vm)?; + TableOperation::StyleDelete { kind: style_kind, name } + } + ("set_current", _) => TableOperation::StyleSetCurrent { kind: style_kind, name }, + ("create" | "modify", "text") => { + ensure_known_entity_keys( + &options, + "text style", + &["height", "width_factor", "oblique", "font", "big_font", "backward", "upside_down", "vertical", "annotative"], + vm, + )?; + let present = |key: &str| -> PyResult> { + Ok(options.get_item_opt(key, vm)?.filter(|v| !vm.is_none(v))) + }; + let number = |key: &str| -> PyResult> { + present(key)?.map(|v| py_number_to_f64(v, vm)).transpose() + }; + let text = |key: &str| -> PyResult> { + present(key)?.map(|v| v.try_into_value::(vm)).transpose() + }; + let flag = |key: &str| -> PyResult> { + present(key)?.map(|v| v.try_into_value::(vm)).transpose() + }; + let config = TextStyleConfig { + name, + height: number("height")?, + width_factor: number("width_factor")?, + oblique_angle: number("oblique")?.map(f64::to_radians), + font_file: text("font")?, + big_font_file: text("big_font")?, + backward: flag("backward")?, + upside_down: flag("upside_down")?, + vertical: flag("vertical")?, + annotative: flag("annotative")?, + ..Default::default() + }; + if op == "create" { + TableOperation::TextStyleCreate { config } + } else { + TableOperation::TextStyleModify { config } + } + } + ("create" | "modify", "dim") => { + let copy_from = if op == "create" { + options.get_item_opt("copy_from", vm)?.filter(|v| !vm.is_none(v)) + .map(|v| v.try_into_value::(vm)).transpose()? + } else { + None + }; + let properties = vm.ctx.new_dict(); + for key in options.keys_vec() { + if key.clone().try_into_value::(vm)? != "copy_from" { + properties.set_item(&*key, options.get_item(&*key, vm)?, vm)?; + } + } + let properties = py_to_json(&PyObjectRef::from(properties), vm)?.to_string(); + if op == "create" { + TableOperation::DimStyleCreate { name, copy_from, properties } + } else { + TableOperation::DimStyleModify { name, properties } + } + } + (other, _) => return Err(vm.new_value_error(format!("ocs.style_operation: unknown operation {other:?}"))), + }; + let result = host_ctx::with_host(|host| host.table_operation(operation)) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + result + .map(|handle| handle.value()) + .map_err(|error| vm.new_runtime_error(format!("ocs.style_operation: {error}"))) + } + + /// Every text style with its properties (`oblique` in degrees). + #[cfg(feature = "experimental-host-model")] + #[pyfunction] + fn text_style_records(vm: &VirtualMachine) -> PyResult { + let rows = host_ctx::with_host(|host| { + let document = host.document(); + let current = document.header.current_text_style_name.to_uppercase(); + document + .text_styles + .iter() + .map(|style| (style.clone(), style.name.to_uppercase() == current)) + .collect::>() + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + let mut output = Vec::with_capacity(rows.len()); + for (style, is_current) in rows { + let dict = vm.ctx.new_dict(); + dict.set_item("handle", vm.new_pyobj(style.handle.value()), vm)?; + dict.set_item("name", vm.new_pyobj(style.name.clone()), vm)?; + dict.set_item("height", vm.new_pyobj(style.height), vm)?; + dict.set_item("width_factor", vm.new_pyobj(style.width_factor), vm)?; + dict.set_item("oblique", vm.new_pyobj(style.oblique_angle.to_degrees()), vm)?; + dict.set_item("font", vm.new_pyobj(style.font_file.clone()), vm)?; + dict.set_item("big_font", vm.new_pyobj(style.big_font_file.clone()), vm)?; + dict.set_item("backward", vm.new_pyobj(style.flags.backward), vm)?; + dict.set_item("upside_down", vm.new_pyobj(style.flags.upside_down), vm)?; + dict.set_item("vertical", vm.new_pyobj(style.is_vertical), vm)?; + dict.set_item("annotative", vm.new_pyobj(style.annotative), vm)?; + dict.set_item("current", vm.new_pyobj(is_current), vm)?; + output.push(dict.into()); + } + Ok(vm.ctx.new_list(output).into()) + } + + /// Every dimension style with all of its DimStyle fields plus `current`. + #[cfg(feature = "experimental-host-model")] + #[pyfunction] + fn dim_style_records(vm: &VirtualMachine) -> PyResult { + let rows = host_ctx::with_host(|host| { + let document = host.document(); + let current = document.header.current_dimstyle_name.to_uppercase(); + document + .dim_styles + .iter() + .map(|style| { + let mut value = serde_json::to_value(style).unwrap_or(serde_json::Value::Null); + if let Some(object) = value.as_object_mut() { + object.insert("current".to_owned(), (style.name.to_uppercase() == current).into()); + } + value + }) + .collect::>() + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + let output = rows + .into_iter() + .map(|value| snapshot_value_to_py(value, vm)) + .collect::>>()?; + Ok(vm.ctx.new_list(output).into()) + } + + /// Block-definition change through the host. `op` is `create` + /// (`entities`, `base_point`, `erase_originals`, `description`), `modify` + /// (`description`, `explodable`, `scale_uniformly`), `rename` (`to`) or + /// `delete`. Returns the block record's handle. + #[cfg(feature = "experimental-host-model")] + #[pyfunction] + fn block_operation(op: String, name: String, options: PyObjectRef, vm: &VirtualMachine) -> PyResult { + use ocs_plugin_api::host::TableOperation; + let options = if vm.is_none(&options) { + vm.ctx.new_dict() + } else { + options.try_into_value::(vm)? + }; + let present = |key: &str| -> PyResult> { + Ok(options.get_item_opt(key, vm)?.filter(|v| !vm.is_none(v))) + }; + let operation = match op.as_str() { + "create" => { + ensure_known_entity_keys(&options, "block", &["entities", "base_point", "erase_originals", "description"], vm)?; + let handles = present("entities")? + .ok_or_else(|| vm.new_value_error("ocs: a block needs entities".to_owned()))? + .try_into_value::>(vm)?; + let base_point = match present("base_point")? { + Some(value) => py_to_f64_array::<3>(value, vm)?, + None => [0.0; 3], + }; + TableOperation::BlockCreate { + name, + entities: handles.into_iter().map(Handle::new).collect(), + base_point, + erase_originals: get_opt_bool(&options, "erase_originals", vm)?, + description: present("description")?.map(|v| v.try_into_value::(vm)).transpose()?, + } + } + "modify" => { + ensure_known_entity_keys(&options, "block", &["description", "explodable", "scale_uniformly"], vm)?; + TableOperation::BlockModify { + name, + description: present("description")?.map(|v| v.try_into_value::(vm)).transpose()?, + explodable: present("explodable")?.map(|v| v.try_into_value::(vm)).transpose()?, + scale_uniformly: present("scale_uniformly")?.map(|v| v.try_into_value::(vm)).transpose()?, + } + } + "rename" => { + ensure_known_entity_keys(&options, "block rename", &["to"], vm)?; + let to = options + .get_item_opt("to", vm)? + .ok_or_else(|| vm.new_value_error("ocs: rename needs the new name".to_owned()))? + .try_into_value::(vm)?; + TableOperation::BlockRename { from: name, to } + } + "delete" => { + ensure_known_entity_keys(&options, "block delete", &[], vm)?; + TableOperation::BlockDelete { name } + } + other => return Err(vm.new_value_error(format!("ocs.block_operation: unknown operation {other:?}"))), + }; + let result = host_ctx::with_host(|host| host.table_operation(operation)) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + result + .map(|handle| handle.value()) + .map_err(|error| vm.new_runtime_error(format!("ocs.block_operation: {error}"))) + } + + /// Every user block definition (layouts and anonymous blocks are omitted). + #[cfg(feature = "experimental-host-model")] + #[pyfunction] + fn block_records(vm: &VirtualMachine) -> PyResult { + let rows = host_ctx::with_host(|host| { + let document = host.document(); + let mut inserts = std::collections::HashMap::::new(); + for entity in document.entities() { + if let acadrust::entities::EntityType::Insert(insert) = entity { + *inserts.entry(insert.block_name.to_uppercase()).or_default() += 1; + } + } + document + .block_records + .iter() + .filter(|record| !record.is_layout() && !record.is_anonymous() && !record.flags.is_xref) + .map(|record| { + let members: Vec<(u64, String)> = record + .entity_handles + .iter() + .filter_map(|handle| document.get_entity(*handle)) + .map(|entity| { + let kind = ocs_plugin_api::entity_coverage::entity_snapshot(entity) + .ok() + .and_then(|value| value.as_object().and_then(|o| o.keys().next().cloned())) + .unwrap_or_else(|| "Unknown".to_owned()); + (entity.common().handle.value(), kind) + }) + .collect(); + ( + record.clone(), + members, + inserts.get(&record.name.to_uppercase()).copied().unwrap_or(0), + ) + }) + .collect::>() + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + let mut output = Vec::with_capacity(rows.len()); + for (record, members, insert_count) in rows { + let dict = vm.ctx.new_dict(); + dict.set_item("handle", vm.new_pyobj(record.handle.value()), vm)?; + dict.set_item("name", vm.new_pyobj(record.name.clone()), vm)?; + dict.set_item("description", vm.new_pyobj(record.description.clone()), vm)?; + dict.set_item("explodable", vm.new_pyobj(record.explodable), vm)?; + dict.set_item("scale_uniformly", vm.new_pyobj(record.scale_uniformly), vm)?; + let base = [record.base_point.x, record.base_point.y, record.base_point.z]; + dict.set_item("base_point", PyObjectRef::from(vm.ctx.new_list(base.iter().map(|v| vm.new_pyobj(*v)).collect())), vm)?; + dict.set_item("insert_count", vm.new_pyobj(insert_count), vm)?; + let entities = members + .into_iter() + .map(|(handle, kind)| { + let member = vm.ctx.new_dict(); + member.set_item("handle", vm.new_pyobj(handle), vm)?; + member.set_item("kind", vm.new_pyobj(kind), vm)?; + Ok(PyObjectRef::from(member)) + }) + .collect::>>()?; + dict.set_item("entities", PyObjectRef::from(vm.ctx.new_list(entities)), vm)?; + output.push(dict.into()); + } + Ok(vm.ctx.new_list(output).into()) + } + + /// Linetype change through the host. `op` is `create` or `modify` + /// (`description`, `pattern`: signed lengths, positive dash, negative gap, + /// zero dot), `rename` (`to`) or `delete`. Returns the linetype's handle. + #[cfg(feature = "experimental-host-model")] + #[pyfunction] + fn linetype_operation(op: String, name: String, options: PyObjectRef, vm: &VirtualMachine) -> PyResult { + use ocs_plugin_api::host::TableOperation; + let options = if vm.is_none(&options) { + vm.ctx.new_dict() + } else { + options.try_into_value::(vm)? + }; + let present = |key: &str| -> PyResult> { + Ok(options.get_item_opt(key, vm)?.filter(|v| !vm.is_none(v))) + }; + let pattern = |value: PyObjectRef| -> PyResult> { + let items: Vec = value.try_into_value(vm)?; + items.into_iter().map(|item| py_number_to_f64(item, vm)).collect() + }; + let operation = match op.as_str() { + "create" => { + ensure_known_entity_keys(&options, "linetype", &["description", "pattern"], vm)?; + TableOperation::LinetypeCreate { + name, + description: get_opt_string(&options, "description", "", vm)?, + pattern: pattern(present("pattern")?.ok_or_else(|| vm.new_value_error("ocs: a linetype needs a pattern".to_owned()))?)?, + } + } + "modify" => { + ensure_known_entity_keys(&options, "linetype", &["description", "pattern"], vm)?; + TableOperation::LinetypeModify { + name, + description: present("description")?.map(|v| v.try_into_value::(vm)).transpose()?, + pattern: present("pattern")?.map(pattern).transpose()?, + } + } + "rename" => { + ensure_known_entity_keys(&options, "linetype rename", &["to"], vm)?; + let to = options + .get_item_opt("to", vm)? + .ok_or_else(|| vm.new_value_error("ocs: rename needs the new name".to_owned()))? + .try_into_value::(vm)?; + TableOperation::LinetypeRename { from: name, to } + } + "delete" => { + ensure_known_entity_keys(&options, "linetype delete", &[], vm)?; + TableOperation::LinetypeDelete { name } + } + other => return Err(vm.new_value_error(format!("ocs.linetype_operation: unknown operation {other:?}"))), + }; + let result = host_ctx::with_host(|host| host.table_operation(operation)) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + result + .map(|handle| handle.value()) + .map_err(|error| vm.new_runtime_error(format!("ocs.linetype_operation: {error}"))) + } + + /// Every linetype: pattern as signed lengths plus how many layers and + /// entities use it. + #[cfg(feature = "experimental-host-model")] + #[pyfunction] + fn linetype_records(vm: &VirtualMachine) -> PyResult { + let rows = host_ctx::with_host(|host| { + let document = host.document(); + let mut uses = std::collections::HashMap::::new(); + for layer in document.layers.iter() { + *uses.entry(layer.line_type.to_uppercase()).or_default() += 1; + } + for entity in document.entities() { + *uses.entry(entity.common().linetype.to_uppercase()).or_default() += 1; + } + document + .line_types + .iter() + .map(|lt| (lt.clone(), uses.get(<.name.to_uppercase()).copied().unwrap_or(0))) + .collect::>() + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + let mut output = Vec::with_capacity(rows.len()); + for (lt, used) in rows { + let dict = vm.ctx.new_dict(); + dict.set_item("handle", vm.new_pyobj(lt.handle.value()), vm)?; + dict.set_item("name", vm.new_pyobj(lt.name.clone()), vm)?; + dict.set_item("description", vm.new_pyobj(lt.description.clone()), vm)?; + let pattern: Vec = lt.elements.iter().map(|e| vm.new_pyobj(e.length)).collect(); + dict.set_item("pattern", PyObjectRef::from(vm.ctx.new_list(pattern)), vm)?; + dict.set_item("pattern_length", vm.new_pyobj(lt.pattern_length), vm)?; + dict.set_item("complex", vm.new_pyobj(lt.elements.iter().any(|e| e.complex.is_some())), vm)?; + dict.set_item("used_by", vm.new_pyobj(used), vm)?; + output.push(dict.into()); + } + Ok(vm.ctx.new_list(output).into()) + } + + /// Layout change through the host. `op` is `create`, `rename` (`to`), + /// `delete`, `set_current` or `set_page` (`paper_size`: (width, height) mm, + /// `rotation`: 0/90/180/270, `scale`: (numerator, denominator)). + #[cfg(feature = "experimental-host-model")] + #[pyfunction] + fn layout_operation(op: String, name: String, options: PyObjectRef, vm: &VirtualMachine) -> PyResult { + use ocs_plugin_api::host::TableOperation; + let options = if vm.is_none(&options) { + vm.ctx.new_dict() + } else { + options.try_into_value::(vm)? + }; + let present = |key: &str| -> PyResult> { + Ok(options.get_item_opt(key, vm)?.filter(|v| !vm.is_none(v))) + }; + let operation = match op.as_str() { + "create" => TableOperation::LayoutCreate { name }, + "delete" => TableOperation::LayoutDelete { name }, + "set_current" => TableOperation::LayoutSetCurrent { name }, + "rename" => { + ensure_known_entity_keys(&options, "layout rename", &["to"], vm)?; + let to = options + .get_item_opt("to", vm)? + .ok_or_else(|| vm.new_value_error("ocs: rename needs the new name".to_owned()))? + .try_into_value::(vm)?; + TableOperation::LayoutRename { from: name, to } + } + "set_page" => { + ensure_known_entity_keys(&options, "layout page", &["paper_size", "rotation", "scale"], vm)?; + TableOperation::LayoutSetPage { + name, + paper_size: present("paper_size")?.map(|v| py_to_f64_array::<2>(v, vm)).transpose()?, + rotation: present("rotation")? + .map(|v| -> PyResult { + let degrees = v.try_into_value::(vm)?; + u16::try_from(degrees).map_err(|_| vm.new_value_error("ocs: the rotation must be 0, 90, 180 or 270".to_owned())) + }) + .transpose()?, + scale: present("scale")?.map(|v| py_to_f64_array::<2>(v, vm)).transpose()?, + } + } + other => return Err(vm.new_value_error(format!("ocs.layout_operation: unknown operation {other:?}"))), + }; + let result = host_ctx::with_host(|host| host.table_operation(operation)) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + result + .map(|handle| handle.value()) + .map_err(|error| vm.new_runtime_error(format!("ocs.layout_operation: {error}"))) + } + + /// `Model` and every paper-space layout in tab order with its page setup. + #[cfg(feature = "experimental-host-model")] + #[pyfunction] + fn layout_records(vm: &VirtualMachine) -> PyResult { + use acadrust::objects::ObjectType; + let rows = host_ctx::with_host(|host| { + let current = match host.system_variable("CTAB") { + Some(ocs_plugin_api::host::HostSettingValue::Text(name)) => name, + _ => "Model".to_owned(), + }; + let document = host.document(); + let mut owned = std::collections::HashMap::::new(); + let mut viewports = std::collections::HashMap::::new(); + for entity in document.entities() { + let owner = entity.common().owner_handle.value(); + *owned.entry(owner).or_default() += 1; + if matches!(entity, acadrust::entities::EntityType::Viewport(_)) { + *viewports.entry(owner).or_default() += 1; + } + } + let mut layouts: Vec<_> = document + .objects + .values() + .filter_map(|object| match object { + ObjectType::Layout(layout) if !layout.block_record.is_null() => Some(layout.clone()), + _ => None, + }) + .collect(); + layouts.sort_by_key(|layout| (layout.name != "Model", layout.tab_order)); + layouts + .into_iter() + .map(|layout| { + let count = owned.get(&layout.block_record.value()).copied().unwrap_or(0); + let vps = viewports.get(&layout.block_record.value()).copied().unwrap_or(0); + let is_current = layout.name == current; + (layout, count, vps, is_current) + }) + .collect::>() + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + let mut output = Vec::with_capacity(rows.len()); + for (layout, count, vps, is_current) in rows { + let dict = vm.ctx.new_dict(); + dict.set_item("handle", vm.new_pyobj(layout.handle.value()), vm)?; + dict.set_item("name", vm.new_pyobj(layout.name.clone()), vm)?; + dict.set_item("current", vm.new_pyobj(is_current), vm)?; + dict.set_item("tab_order", vm.new_pyobj(i64::from(layout.tab_order)), vm)?; + dict.set_item("entity_count", vm.new_pyobj(count), vm)?; + dict.set_item("viewport_count", vm.new_pyobj(vps), vm)?; + dict.set_item("paper_size", PyObjectRef::from(vm.ctx.new_list(vec![vm.new_pyobj(layout.paper_width), vm.new_pyobj(layout.paper_height)])), vm)?; + dict.set_item("paper_name", vm.new_pyobj(layout.paper_size.clone()), vm)?; + let rotation = match layout.plot_rotation { + 1 => 90, + 2 => 180, + 3 => 270, + _ => 0, + }; + dict.set_item("rotation", vm.new_pyobj(rotation), vm)?; + dict.set_item("scale", PyObjectRef::from(vm.ctx.new_list(vec![vm.new_pyobj(layout.plot_scale_numerator), vm.new_pyobj(layout.plot_scale_denominator)])), vm)?; + output.push(dict.into()); + } + Ok(vm.ctx.new_list(output).into()) + } + + /// One step of driving a real OCS command. `kind` is `run` (`line`), + /// `start` (`name`), `point` (`point`), `text` (`text`), `token` (`text`), + /// `entity` (`handle`, `point`), `selection`, `enter` or `cancel`. Returns + /// where the command stands: `status`, `blocked_by`, `command`, `prompt`, + /// `accepts`, `options`, `entities`, `added`, `unconsumed`, `error`. + #[cfg(feature = "experimental-host-model")] + #[pyfunction] + fn command_step(kind: String, options: PyObjectRef, vm: &VirtualMachine) -> PyResult { + use ocs_plugin_api::host::CommandRequest as R; + let options = if vm.is_none(&options) { + vm.ctx.new_dict() + } else { + options.try_into_value::(vm)? + }; + let required = |key: &str| -> PyResult { + options + .get_item_opt(key, vm)? + .ok_or_else(|| vm.new_value_error(format!("ocs.command_step: {kind} needs {key}"))) + }; + let request = match kind.as_str() { + "run" => R::Run { line: required("line")?.try_into_value::(vm)? }, + "start" => R::Start { name: required("name")?.try_into_value::(vm)? }, + "point" => R::Point { point: py_to_f64_array::<3>(required("point")?, vm)? }, + "text" => R::Text { text: required("text")?.try_into_value::(vm)? }, + "token" => R::Token { text: required("text")?.try_into_value::(vm)? }, + "entity" => R::Entity { + handle: Handle::new(required("handle")?.try_into_value::(vm)?), + point: py_to_f64_array::<3>(required("point")?, vm)?, + }, + "selection" => R::Selection, + "enter" => R::Enter, + "cancel" => R::Cancel, + other => return Err(vm.new_value_error(format!("ocs.command_step: unknown step {other:?}"))), + }; + let outcome = host_ctx::with_host(|host| host.run_command(request)) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))? + .map_err(|error| vm.new_runtime_error(format!("ocs.command_step: {error}")))?; + let dict = vm.ctx.new_dict(); + let strings = |items: &[String]| -> PyObjectRef { + PyObjectRef::from(vm.ctx.new_list(items.iter().map(|v| vm.new_pyobj(v.clone())).collect())) + }; + dict.set_item("status", vm.new_pyobj(outcome.status.clone()), vm)?; + dict.set_item("blocked_by", outcome.blocked_by.clone().map_or_else(|| vm.ctx.none(), |v| vm.new_pyobj(v)), vm)?; + dict.set_item("command", vm.new_pyobj(outcome.command.clone()), vm)?; + dict.set_item("prompt", vm.new_pyobj(outcome.prompt.clone()), vm)?; + dict.set_item("accepts", strings(&outcome.accepts), vm)?; + dict.set_item("options", strings(&outcome.options), vm)?; + dict.set_item("entities", vm.new_pyobj(outcome.entities), vm)?; + dict.set_item("added", vm.new_pyobj(outcome.added), vm)?; + dict.set_item("unconsumed", strings(&outcome.unconsumed), vm)?; + dict.set_item("error", outcome.error.clone().map_or_else(|| vm.ctx.none(), |v| vm.new_pyobj(v)), vm)?; + Ok(dict.into()) + } + + /// Every layer with its full properties, in table order. + #[cfg(feature = "experimental-host-model")] + #[pyfunction] + fn layer_records(vm: &VirtualMachine) -> PyResult { + use acadrust::types::{LineWeight, Transparency}; + let rows = host_ctx::with_host(|host| { + let document = host.document(); + let mut counts = std::collections::HashMap::::new(); + for entity in document.entities() { + *counts.entry(entity.common().layer.to_uppercase()).or_default() += 1; + } + let current = document.header.current_layer_name.to_uppercase(); + document + .layers + .iter() + .map(|layer| { + ( + layer.clone(), + counts.get(&layer.name.to_uppercase()).copied().unwrap_or(0), + layer.name.to_uppercase() == current, + ) + }) + .collect::>() + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + let mut output = Vec::with_capacity(rows.len()); + for (layer, count, is_current) in rows { + let dict = vm.ctx.new_dict(); + dict.set_item("handle", vm.new_pyobj(layer.handle.value()), vm)?; + dict.set_item("name", vm.new_pyobj(layer.name.clone()), vm)?; + dict.set_item("color", color_to_py_dict(vm, &layer.color)?, vm)?; + dict.set_item("linetype", vm.new_pyobj(layer.line_type.clone()), vm)?; + let weight = match layer.line_weight { + LineWeight::ByLayer => -1, + LineWeight::ByBlock => -2, + LineWeight::Default => -3, + LineWeight::Value(v) => i64::from(v), + }; + dict.set_item("lineweight", vm.new_pyobj(weight), vm)?; + dict.set_item("off", vm.new_pyobj(layer.flags.off), vm)?; + dict.set_item("frozen", vm.new_pyobj(layer.flags.frozen), vm)?; + dict.set_item("locked", vm.new_pyobj(layer.flags.locked), vm)?; + dict.set_item("plottable", vm.new_pyobj(layer.is_plottable), vm)?; + let transparency = match layer.transparency { + Transparency::Explicit(_) => layer.transparency.as_percent(), + _ => 0.0, + }; + dict.set_item("transparency", vm.new_pyobj(transparency), vm)?; + dict.set_item("description", vm.new_pyobj(layer.description.clone()), vm)?; + dict.set_item("entity_count", vm.new_pyobj(count), vm)?; + dict.set_item("current", vm.new_pyobj(is_current), vm)?; + output.push(dict.into()); + } + Ok(vm.ctx.new_list(output).into()) + } + + /// Layer names, visibility flags, and top-level entity counts. + #[pyfunction] + fn layers(vm: &VirtualMachine) -> PyResult { + let rows = host_ctx::with_host(|host| { + let document = host.document(); + let layout_blocks = top_level_blocks(document); + let mut counts = std::collections::HashMap::::new(); + for entity in document.entities() { + if layout_blocks.contains(&entity.common().owner_handle) { + *counts.entry(entity.common().layer.clone()).or_default() += 1; + } + } + document + .layers + .iter() + .map(|layer| { + ( + layer.name.clone(), + layer.flags.off, + layer.flags.frozen, + layer.flags.locked, + counts.get(&layer.name).copied().unwrap_or(0), + ) + }) + .collect::>() + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + let mut output = Vec::with_capacity(rows.len()); + for (name, off, frozen, locked, count) in rows { + let dict = vm.ctx.new_dict(); + dict.set_item("name", vm.new_pyobj(name), vm)?; + dict.set_item("off", vm.new_pyobj(off), vm)?; + dict.set_item("frozen", vm.new_pyobj(frozen), vm)?; + dict.set_item("locked", vm.new_pyobj(locked), vm)?; + dict.set_item("entity_count", vm.new_pyobj(count), vm)?; + output.push(dict.into()); + } + Ok(vm.ctx.new_list(output).into()) + } + + /// Move a top-level entity to an existing layer without changing its owner. + #[pyfunction] + fn set_entity_layer(handle: u64, layer_name: String, vm: &VirtualMachine) -> PyResult<()> { + let changed = host_ctx::with_host(|host| { + let document = host.document(); + if document.layers.get(&layer_name).is_none() { + return false; + } + let Some(mut entity) = document.get_entity(Handle::new(handle)).cloned() else { + return false; + }; + if !top_level_blocks(document).contains(&entity.common().owner_handle) { + return false; + } + if entity.common().layer == layer_name { + return true; + } + entity.common_mut().layer = layer_name; + host_ctx::ensure_undo_started(host); + host.update_entity(entity) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + if !changed { + return Err(vm.new_value_error(format!( + "ocs.set_entity_layer: top-level entity {handle} or target layer not found" + ))); + } + Ok(()) + } + + /// Export one layer's top-level entities as a new DWG, never overwriting. + #[pyfunction] + fn export_layer_dwg( + layer_name: String, + output_path: String, + vm: &VirtualMachine, + ) -> PyResult { + let path = std::path::Path::new(&output_path); + if path + .extension() + .is_none_or(|extension| !extension.eq_ignore_ascii_case("dwg")) + || path.file_name().is_none() + || path.parent().is_none_or(|parent| !parent.is_dir()) + { + return Err(vm.new_value_error( + "ocs.export_layer_dwg: output must be a .dwg file in an existing directory" + .to_owned(), + )); + } + let prepared = host_ctx::with_host(|host| { + let source = host.document(); + source.layers.get(&layer_name)?; + let layout_blocks = top_level_blocks(source); + let mut snapshot = source.clone(); + let removed: Vec = source + .entities() + .filter(|entity| { + layout_blocks.contains(&entity.common().owner_handle) + && entity.common().layer != layer_name + }) + .map(|entity| entity.common().handle) + .collect(); + for handle in removed { + snapshot.remove_entity(handle); + } + let kept: std::collections::HashSet = snapshot + .entities() + .map(|entity| entity.common().handle) + .collect(); + for record in snapshot.block_records.iter_mut() { + record.entity_handles.retain(|handle| kept.contains(handle)); + } + let count = snapshot + .entities() + .filter(|entity| { + layout_blocks.contains(&entity.common().owner_handle) + && entity.common().layer == layer_name + }) + .count(); + Some((snapshot, count)) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))? + .ok_or_else(|| { + vm.new_value_error(format!( + "ocs.export_layer_dwg: layer {layer_name:?} does not exist" + )) + })?; + let (snapshot, count) = prepared; + if count == 0 { + return Err(vm.new_value_error(format!( + "ocs.export_layer_dwg: layer {layer_name:?} has no top-level entities" + ))); + } + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(path) + .map_err(|error| { + vm.new_runtime_error(format!( + "ocs.export_layer_dwg: cannot create {output_path:?}: {error}" + )) + })?; + if let Err(error) = acadrust::DwgWriter::write_to_writer(&mut file, &snapshot) { + drop(file); + let _ = std::fs::remove_file(path); + return Err( + vm.new_runtime_error(format!("ocs.export_layer_dwg: DWG write failed: {error}")) + ); + } + Ok(count) + } + + /// Export a planned set of layers as new DWGs. If any export fails, + /// remove only files created by this call, leaving pre-existing files + /// untouched. No source drawing changes are made. + #[pyfunction] + fn export_layer_dwgs(exports: Vec>, vm: &VirtualMachine) -> PyResult { + let mut paths = std::collections::HashSet::new(); + if exports + .iter() + .any(|row| row.len() != 2 || !paths.insert(row[1].clone())) + { + return Err(vm.new_value_error( + "ocs.export_layer_dwgs: expected unique [layer, path] pairs".to_owned(), + )); + } + let mut created = Vec::with_capacity(exports.len()); + let mut counts = Vec::with_capacity(exports.len()); + for row in exports { + match export_layer_dwg(row[0].clone(), row[1].clone(), vm) { + Ok(count) => { + created.push(row[1].clone()); + counts.push(count); + } + Err(error) => { + let failed_cleanup: Vec = created + .into_iter() + .filter(|path| std::fs::remove_file(path).is_err()) + .collect(); + if !failed_cleanup.is_empty() { + return Err(vm.new_runtime_error(format!( + "ocs.export_layer_dwgs: export failed ({error:?}); cleanup failed for {failed_cleanup:?}" + ))); + } + return Err(error); + } + } + } + Ok(vm + .ctx + .new_list( + counts + .into_iter() + .map(|count| vm.new_pyobj(count)) + .collect(), + ) + .into()) + } + + /// Name of the active document's current layer (CLAYER). + #[pyfunction] + fn current_layer(vm: &VirtualMachine) -> PyResult { + host_ctx::with_host(|host| host.document().header.current_layer_name.clone()) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned())) + } + + /// Read a host-managed system variable without invoking a CAD command. + #[cfg(feature = "experimental-host-settings")] + #[pyfunction] + fn system_variable(name: String, vm: &VirtualMachine) -> PyResult { + let value = host_ctx::with_host(|host| host.system_variable(&name)).ok_or_else(|| { + vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()) + })?; + Ok(match value { + Some(HostSettingValue::Text(text)) => vm.new_pyobj(text), + Some(HostSettingValue::Number(number)) => vm.new_pyobj(number), + None => vm.ctx.none(), + }) + } + + /// Set a host-managed system variable without nested command dispatch. + #[cfg(feature = "experimental-host-settings")] + #[pyfunction] + fn set_system_variable( + name: String, + value: PyObjectRef, + vm: &VirtualMachine, + ) -> PyResult { + let setting = match value.clone().try_into_value::(vm) { + Ok(text) => HostSettingValue::Text(text), + Err(_) => { + HostSettingValue::Number(ArgIntoFloat::try_from_object(vm, value)?.into_float()) + } + }; + let changed = host_ctx::with_host(|host| host.set_system_variable(&name, setting)) + .ok_or_else(|| { + vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()) + })? + .map_err(|error| vm.new_value_error(error))?; + Ok(match changed { + HostSettingValue::Text(text) => vm.new_pyobj(text), + HostSettingValue::Number(number) => vm.new_pyobj(number), + }) + } + + /// Owning layout of a top-level entity, including sheet order/count. + #[pyfunction] + fn layout_for_entity(handle: u64, vm: &VirtualMachine) -> PyResult { + let found = host_ctx::with_host(|host| { + let document = host.document(); + let entity = document.get_entity(Handle::new(handle))?; + let block = document + .block_records + .iter() + .find(|record| record.handle == entity.common().owner_handle)?; + let ObjectType::Layout(layout) = document.objects.get(&block.layout)? else { + return None; + }; + let sheet_count = document + .objects + .values() + .filter(|object| matches!(object, ObjectType::Layout(item) if item.name != "Model")) + .count(); + Some((layout.name.clone(), layout.tab_order, sheet_count)) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + let Some((name, order, sheet_count)) = found else { + return Ok(vm.ctx.none()); + }; + let dict = vm.ctx.new_dict(); + dict.set_item("name", vm.new_pyobj(name), vm)?; + dict.set_item("order", vm.new_pyobj(order), vm)?; + dict.set_item("sheet_count", vm.new_pyobj(sheet_count), vm)?; + Ok(dict.into()) + } + + /// Tag/value pairs attached to one INSERT, or None for other entities. + #[pyfunction] + fn block_attributes(handle: u64, vm: &VirtualMachine) -> PyResult { + let found = + host_ctx::with_host( + |host| match host.document().get_entity(Handle::new(handle)) { + Some(EntityType::Insert(insert)) => Some( + insert + .attributes + .iter() + .map(|attribute| (attribute.tag.clone(), attribute.value.clone())) + .collect::>(), + ), + _ => None, + }, + ) + .ok_or_else(|| { + vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()) + })?; + let Some(attributes) = found else { + return Ok(vm.ctx.none()); + }; + let mut output = Vec::with_capacity(attributes.len()); + for (tag, value) in attributes { + let dict = vm.ctx.new_dict(); + dict.set_item("tag", vm.new_pyobj(tag), vm)?; + dict.set_item("value", vm.new_pyobj(value), vm)?; + output.push(dict.into()); + } + Ok(vm.ctx.new_list(output).into()) + } + + /// Replace one INSERT attribute's value by tag, preserving other data. + #[pyfunction] + fn set_block_attribute( + handle: u64, + tag: rustpython_vm::builtins::PyStrRef, + value: rustpython_vm::builtins::PyStrRef, + vm: &VirtualMachine, + ) -> PyResult<()> { + let tag = tag.to_string(); + let value = value.to_string(); + let changed = host_ctx::with_host(|host| { + let Some(EntityType::Insert(mut insert)) = + host.document().get_entity(Handle::new(handle)).cloned() + else { + return false; + }; + let Some(attribute) = insert + .attributes + .iter_mut() + .find(|attribute| attribute.tag.eq_ignore_ascii_case(&tag)) + else { + return false; + }; + attribute.value = value; + host_ctx::ensure_undo_started(host); + host.update_entity(EntityType::Insert(insert)) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + if !changed { + return Err(vm.new_runtime_error(format!( + "ocs.set_block_attribute: INSERT {handle} or tag {tag} was not updated" + ))); + } + Ok(()) + } + + /// Replace the content of one top-level TEXT or MTEXT entity in place. + #[pyfunction] + fn set_text_value( + handle: u64, + value: rustpython_vm::builtins::PyStrRef, + vm: &VirtualMachine, + ) -> PyResult<()> { + let value = value.to_string(); + let changed = host_ctx::with_host(|host| { + let Some(entity) = host.document().get_entity(Handle::new(handle)).cloned() else { + return false; + }; + let updated = match entity { + EntityType::Text(mut text) => { + text.value = value; + EntityType::Text(text) + } + EntityType::MText(mut text) => { + text.value = value; + EntityType::MText(text) + } + _ => return false, + }; + host_ctx::ensure_undo_started(host); + host.update_entity(updated) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + if !changed { + return Err(vm.new_runtime_error(format!( + "ocs.set_text_value: TEXT/MTEXT entity {handle} was not updated" + ))); + } + Ok(()) + } + + /// Set an MTEXT background mode (off, mask, or fill) and border scale. + #[pyfunction] + fn set_mtext_mask( + handle: u64, + mode: rustpython_vm::builtins::PyStrRef, + scale: ArgIntoFloat, + vm: &VirtualMachine, + ) -> PyResult<()> { + let scale = scale.into_float(); + let mode = mode.to_string(); + if !scale.is_finite() || scale < 1.0 || !["off", "mask", "fill"].contains(&mode.as_str()) { + return Err(vm.new_value_error( + "ocs.set_mtext_mask: mode must be off/mask/fill and scale at least 1".to_owned(), + )); + } + let changed = host_ctx::with_host(|host| { + let Some(EntityType::MText(mut text)) = + host.document().get_entity(Handle::new(handle)).cloned() + else { + return false; + }; + text.background_fill_flags &= !0x03; + text.background_fill_flags |= match mode.as_str() { + "mask" => 0x02, + "fill" => 0x01, + _ => 0, + }; + text.background_scale = scale; + host_ctx::ensure_undo_started(host); + host.update_entity(EntityType::MText(text)) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + if !changed { + return Err(vm.new_runtime_error(format!( + "ocs.set_mtext_mask: MTEXT entity {handle} was not updated" + ))); + } + Ok(()) + } + + /// Change only the active insertion/alignment point of a TEXT entity, + /// preserving its style, rotation, content and other properties. + #[pyfunction] + fn move_text( + handle: u64, + x: ArgIntoFloat, + y: ArgIntoFloat, + z: ArgIntoFloat, + vm: &VirtualMachine, + ) -> PyResult<()> { + let (x, y, z) = (x.into_float(), y.into_float(), z.into_float()); + if ![x, y, z].iter().all(|value| value.is_finite()) { + return Err(vm.new_value_error("ocs.move_text: coordinates must be finite".to_owned())); + } + let changed = host_ctx::with_host(|host| { + let Some(EntityType::Text(mut text)) = + host.document().get_entity(Handle::new(handle)).cloned() + else { + return false; + }; + let point = Vector3::new(x, y, z); + if uses_text_alignment_point(&text) { + text.alignment_point = Some(point); + } else { + text.insertion_point = point; + } + host_ctx::ensure_undo_started(host); + host.update_entity(EntityType::Text(text)) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + if !changed { + return Err(vm.new_runtime_error(format!( + "ocs.move_text: TEXT entity {handle} was not updated" + ))); + } + Ok(()) + } + + /// Move and rotate one XY TEXT entity, preserving its other properties. + #[pyfunction] + fn set_text_pose( + handle: u64, + x: ArgIntoFloat, + y: ArgIntoFloat, + z: ArgIntoFloat, + rotation: ArgIntoFloat, + vm: &VirtualMachine, + ) -> PyResult<()> { + let (x, y, z, rotation) = ( + x.into_float(), + y.into_float(), + z.into_float(), + rotation.into_float(), + ); + if ![x, y, z, rotation].iter().all(|v| v.is_finite()) { + return Err(vm.new_value_error( + "ocs.set_text_pose: coordinates and rotation must be finite".to_owned(), + )); + } + let changed = host_ctx::with_host(|host| { + let Some(EntityType::Text(mut text)) = + host.document().get_entity(Handle::new(handle)).cloned() + else { + return false; + }; + if text.normal != Vector3::UNIT_Z { + return false; + } + let point = Vector3::new(x, y, z); + if uses_text_alignment_point(&text) { + text.alignment_point = Some(point); + } else { + text.insertion_point = point; + } + text.rotation = rotation; + host_ctx::ensure_undo_started(host); + host.update_entity(EntityType::Text(text)) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + if !changed { + return Err(vm.new_runtime_error(format!( + "ocs.set_text_pose: XY TEXT entity {handle} was not updated" + ))); + } + Ok(()) + } + + fn uses_text_alignment_point(text: &Text) -> bool { + !(text.horizontal_alignment == TextHorizontalAlignment::Left + && text.vertical_alignment == TextVerticalAlignment::Baseline) + } + + fn active_text_point(text: &Text) -> Vector3 { + if uses_text_alignment_point(text) { + text.alignment_point.unwrap_or(text.insertion_point) + } else { + text.insertion_point + } + } + + /// Area and label point for a circle or a straight, closed lightweight + /// polyline in the world XY plane. Unsupported geometry returns None. + #[pyfunction] + fn get_area(handle: u64, vm: &VirtualMachine) -> PyResult { + let found = host_ctx::with_host(|host| { + let document = host.document(); + match document.get_entity(Handle::new(handle)) { + Some(EntityType::Circle(circle)) + if circle.normal == Vector3::UNIT_Z && circle.radius > 0.0 => + { + Some((circle.area(), circle.center)) + } + Some(EntityType::LwPolyline(polyline)) + if polyline.is_closed + && polyline.normal == Vector3::UNIT_Z + && polyline.vertices.len() >= 3 + && polyline.vertices.iter().all(|v| v.bulge == 0.0) => + { + let mut double_area = 0.0; + let mut cx = 0.0; + let mut cy = 0.0; + for index in 0..polyline.vertices.len() { + let a = polyline.vertices[index].location; + let b = polyline.vertices[(index + 1) % polyline.vertices.len()].location; + let cross = a.x * b.y - b.x * a.y; + double_area += cross; + cx += (a.x + b.x) * cross; + cy += (a.y + b.y) * cross; + } + if double_area.abs() <= f64::EPSILON { + None + } else { + Some(( + double_area.abs() / 2.0, + Vector3::new( + cx / (3.0 * double_area), + cy / (3.0 * double_area), + polyline.elevation, + ), + )) + } + } + _ => None, + } + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + let Some((area, point)) = found else { + return Ok(vm.ctx.none()); + }; + let dict = vm.ctx.new_dict(); + dict.set_item("area", vm.new_pyobj(area), vm)?; + dict.set_item( + "point", + vm.ctx + .new_list(vec![ + vm.new_pyobj(point.x), + vm.new_pyobj(point.y), + vm.new_pyobj(point.z), + ]) + .into(), + vm, + )?; + Ok(dict.into()) + } + + /// Length, halfway point, and local tangent for an XY line, circle, arc, + /// or straight-segment lightweight polyline. + #[pyfunction] + fn get_curve_measure(handle: u64, vm: &VirtualMachine) -> PyResult { + let found = host_ctx::with_host(|host| { + let document = host.document(); + match document.get_entity(Handle::new(handle)) { + Some(EntityType::Line(line)) + if line.normal == Vector3::UNIT_Z + && (line.start.z - line.end.z).abs() <= 1e-9 + && line.length() > 0.0 => + { + Some(( + line.length(), + line.midpoint(), + (line.end.y - line.start.y).atan2(line.end.x - line.start.x), + )) + } + Some(EntityType::Circle(circle)) + if circle.normal == Vector3::UNIT_Z && circle.radius > 0.0 => + { + Some(( + 2.0 * std::f64::consts::PI * circle.radius, + Vector3::new( + circle.center.x - circle.radius, + circle.center.y, + circle.center.z, + ), + std::f64::consts::FRAC_PI_2, + )) + } + Some(EntityType::Arc(arc)) + if arc.normal == Vector3::UNIT_Z + && arc.radius > 0.0 + && arc.arc_length() > 0.0 => + { + let angle = arc.start_angle + arc.sweep_angle() / 2.0; + Some(( + arc.arc_length(), + arc.midpoint(), + angle + std::f64::consts::FRAC_PI_2, + )) + } + Some(EntityType::LwPolyline(polyline)) + if polyline.normal == Vector3::UNIT_Z + && polyline.vertices.len() >= 2 + && polyline.vertices.iter().all(|v| v.bulge == 0.0) => + { + let segment_count = if polyline.is_closed { + polyline.vertices.len() + } else { + polyline.vertices.len() - 1 + }; + let mut segments = Vec::with_capacity(segment_count); + let mut total = 0.0; + for index in 0..segment_count { + let a = polyline.vertices[index].location; + let b = polyline.vertices[(index + 1) % polyline.vertices.len()].location; + let length = ((b.x - a.x).powi(2) + (b.y - a.y).powi(2)).sqrt(); + segments.push((a, b, length)); + total += length; + } + if total <= 0.0 { + return None; + } + let mut remaining = total / 2.0; + for (a, b, length) in segments { + if length == 0.0 { + continue; + } + if remaining <= length { + let fraction = remaining / length; + let point = Vector3::new( + a.x + (b.x - a.x) * fraction, + a.y + (b.y - a.y) * fraction, + polyline.elevation, + ); + return Some((total, point, (b.y - a.y).atan2(b.x - a.x))); + } + remaining -= length; + } + None + } + _ => None, + } + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + let Some((length, point, rotation)) = found else { + return Ok(vm.ctx.none()); + }; + let dict = vm.ctx.new_dict(); + dict.set_item("length", vm.new_pyobj(length), vm)?; + dict.set_item( + "point", + vm.ctx + .new_list(vec![ + vm.new_pyobj(point.x), + vm.new_pyobj(point.y), + vm.new_pyobj(point.z), + ]) + .into(), + vm, + )?; + dict.set_item("rotation", vm.new_pyobj(rotation), vm)?; + dict.set_item( + "tangent", + vm.ctx + .new_list(vec![ + vm.new_pyobj(rotation.cos()), + vm.new_pyobj(rotation.sin()), + ]) + .into(), + vm, + )?; + Ok(dict.into()) + } + + /// Start and end points of a nonzero XY line, or None. + #[pyfunction] + fn line_endpoints(handle: u64, vm: &VirtualMachine) -> PyResult { + let found = + host_ctx::with_host( + |host| match host.document().get_entity(Handle::new(handle)) { + Some(EntityType::Line(line)) + if line.normal == Vector3::UNIT_Z + && (line.start.z - line.end.z).abs() <= 1e-9 + && line.length() > 0.0 => + { + Some((line.start, line.end)) + } + _ => None, + }, + ) + .ok_or_else(|| { + vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()) + })?; + let Some((start, end)) = found else { + return Ok(vm.ctx.none()); + }; + let output = vm.ctx.new_list(vec![ + vm.ctx + .new_list(vec![ + vm.new_pyobj(start.x), + vm.new_pyobj(start.y), + vm.new_pyobj(start.z), + ]) + .into(), + vm.ctx + .new_list(vec![ + vm.new_pyobj(end.x), + vm.new_pyobj(end.y), + vm.new_pyobj(end.z), + ]) + .into(), + ]); + Ok(output.into()) + } + + /// Insertion point of one top-level block reference, or None. + #[pyfunction] + fn block_insert_point(handle: u64, vm: &VirtualMachine) -> PyResult { + let found = + host_ctx::with_host( + |host| match host.document().get_entity(Handle::new(handle)) { + Some(EntityType::Insert(insert)) => Some(insert.insert_point), + _ => None, + }, + ) + .ok_or_else(|| { + vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()) + })?; + let Some(point) = found else { + return Ok(vm.ctx.none()); + }; + Ok(vm + .ctx + .new_list(vec![ + vm.new_pyobj(point.x), + vm.new_pyobj(point.y), + vm.new_pyobj(point.z), + ]) + .into()) + } + + /// Axis-aligned XY footprint of a simple unrotated block reference. + /// Rejects nested/unsupported definition entities rather than guessing. + #[pyfunction] + fn block_bounds_xy(handle: u64, vm: &VirtualMachine) -> PyResult { + let found = host_ctx::with_host(|host| { + let document = host.document(); + let Some(EntityType::Insert(insert)) = document.get_entity(Handle::new(handle)) else { + return None; + }; + if insert.normal != Vector3::UNIT_Z + || insert.rotation.abs() > 1e-9 + || insert.row_count != 1 + || insert.column_count != 1 + { + return None; + } + let record = document.block_records.get(&insert.block_name)?; + if record.entity_handles.is_empty() { + return None; + } + let base = record.base_point; + let transform = Transform::from_translation(Vector3::new(-base.x, -base.y, -base.z)) + .then(&Transform::from_scaling(Vector3::new( + insert.x_scale(), + insert.y_scale(), + insert.z_scale(), + ))) + .then(&Transform::from_translation(insert.insert_point)); + let mut xmin = f64::INFINITY; + let mut ymin = f64::INFINITY; + let mut xmax = f64::NEG_INFINITY; + let mut ymax = f64::NEG_INFINITY; + for entity_handle in &record.entity_handles { + let mut entity = document.get_entity(*entity_handle)?.clone(); + if !matches!( + entity, + EntityType::Line(_) + | EntityType::Arc(_) + | EntityType::Circle(_) + | EntityType::LwPolyline(_) + ) { + return None; + } + entity.apply_transform(&transform); + let bounds = entity.as_entity().bounding_box(); + if ![bounds.min.x, bounds.min.y, bounds.max.x, bounds.max.y] + .iter() + .all(|value| value.is_finite()) + { + return None; + } + xmin = xmin.min(bounds.min.x); + ymin = ymin.min(bounds.min.y); + xmax = xmax.max(bounds.max.x); + ymax = ymax.max(bounds.max.y); + } + if xmax - xmin <= 1e-9 || ymax - ymin <= 1e-9 { + return None; + } + Some([xmin, ymin, xmax, ymax]) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + match found { + Some(bounds) => Ok(vm + .ctx + .new_list( + bounds + .into_iter() + .map(|value| vm.new_pyobj(value)) + .collect(), + ) + .into()), + None => Ok(vm.ctx.none()), + } + } + + /// Segment metrics for one XY lightweight polyline, including bulge arcs. + #[pyfunction] + fn polyline_segments(handle: u64, vm: &VirtualMachine) -> PyResult { + let found = host_ctx::with_host(|host| { + let document = host.document(); + let Some(EntityType::LwPolyline(polyline)) = document.get_entity(Handle::new(handle)) + else { + return None; + }; + if polyline.normal != Vector3::UNIT_Z || polyline.vertices.len() < 2 { + return None; + } + let count = if polyline.is_closed { + polyline.vertices.len() + } else { + polyline.vertices.len() - 1 + }; + let mut segments = Vec::with_capacity(count); + for index in 0..count { + let a = polyline.vertices[index]; + let b = polyline.vertices[(index + 1) % polyline.vertices.len()]; + let chord = ((b.location.x - a.location.x).powi(2) + + (b.location.y - a.location.y).powi(2)) + .sqrt(); + let radius = if a.bulge.abs() > f64::EPSILON && chord > 0.0 { + Some(chord * (1.0 + a.bulge * a.bulge) / (4.0 * a.bulge.abs())) + } else { + None + }; + let center = radius.map(|_| { + let offset = (1.0 - a.bulge * a.bulge) / (4.0 * a.bulge); + ( + (a.location.x + b.location.x) / 2.0 + - (b.location.y - a.location.y) * offset, + (a.location.y + b.location.y) / 2.0 + + (b.location.x - a.location.x) * offset, + ) + }); + let length = radius.map_or(chord, |r| r * (4.0 * a.bulge.atan()).abs()); + segments.push(( + a.location, + b.location, + a.start_width, + a.end_width, + length, + center, + radius, + )); + } + Some(segments) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + let Some(segments) = found else { + return Ok(vm.ctx.none()); + }; + let mut output = Vec::with_capacity(segments.len()); + for (start, end, start_width, end_width, length, center, radius) in segments { + let dict = vm.ctx.new_dict(); + dict.set_item( + "start", + vm.ctx + .new_list(vec![vm.new_pyobj(start.x), vm.new_pyobj(start.y)]) + .into(), + vm, + )?; + dict.set_item( + "end", + vm.ctx + .new_list(vec![vm.new_pyobj(end.x), vm.new_pyobj(end.y)]) + .into(), + vm, + )?; + dict.set_item("start_width", vm.new_pyobj(start_width), vm)?; + dict.set_item("end_width", vm.new_pyobj(end_width), vm)?; + dict.set_item("length", vm.new_pyobj(length), vm)?; + dict.set_item( + "center", + center.map_or_else( + || vm.ctx.none(), + |(x, y)| { + vm.ctx + .new_list(vec![vm.new_pyobj(x), vm.new_pyobj(y)]) + .into() + }, + ), + vm, + )?; + dict.set_item( + "radius", + radius.map_or_else(|| vm.ctx.none(), |r| vm.new_pyobj(r)), + vm, + )?; + output.push(dict.into()); + } + Ok(vm.ctx.new_list(output).into()) + } + + /// Equal-distance samples for an XY line, arc, or circle. + #[pyfunction] + fn curve_samples(handle: u64, segments: usize, vm: &VirtualMachine) -> PyResult { + if !(2..=4096).contains(&segments) { + return Err(vm.new_value_error( + "ocs.curve_samples: segments must be between 2 and 4096".to_owned(), + )); + } + let found = + host_ctx::with_host( + |host| match host.document().get_entity(Handle::new(handle)) { + Some(EntityType::Line(line)) + if line.normal == Vector3::UNIT_Z + && (line.start.z - line.end.z).abs() <= 1e-9 + && line.length() > 0.0 => + { + let points = (0..=segments) + .map(|i| { + let t = i as f64 / segments as f64; + Vector2::new( + line.start.x + (line.end.x - line.start.x) * t, + line.start.y + (line.end.y - line.start.y) * t, + ) + }) + .collect::>(); + Some((points, line.start.z, false)) + } + Some(EntityType::Circle(circle)) + if circle.normal == Vector3::UNIT_Z && circle.radius > 0.0 => + { + let points = (0..segments) + .map(|i| { + let angle = 2.0 * std::f64::consts::PI * i as f64 / segments as f64; + Vector2::new( + circle.center.x + circle.radius * angle.cos(), + circle.center.y + circle.radius * angle.sin(), + ) + }) + .collect::>(); + Some((points, circle.center.z, true)) + } + Some(EntityType::Arc(arc)) + if arc.normal == Vector3::UNIT_Z + && arc.radius > 0.0 + && arc.arc_length() > 0.0 => + { + let points = (0..=segments) + .map(|i| { + let angle = arc.start_angle + + arc.sweep_angle() * i as f64 / segments as f64; + Vector2::new( + arc.center.x + arc.radius * angle.cos(), + arc.center.y + arc.radius * angle.sin(), + ) + }) + .collect::>(); + Some((points, arc.center.z, false)) + } + _ => None, + }, + ) + .ok_or_else(|| { + vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()) + })?; + let Some((points, elevation, closed)) = found else { + return Ok(vm.ctx.none()); + }; + let vertices = points + .into_iter() + .map(|point| { + vm.ctx + .new_list(vec![vm.new_pyobj(point.x), vm.new_pyobj(point.y)]) + .into() + }) + .collect(); + let dict = vm.ctx.new_dict(); + dict.set_item("vertices", vm.ctx.new_list(vertices).into(), vm)?; + dict.set_item("elevation", vm.new_pyobj(elevation), vm)?; + dict.set_item("closed", vm.new_pyobj(closed), vm)?; + Ok(dict.into()) + } + + /// Modelspace XY outline of a rectangular, unclipped +Z paperspace viewport. + #[pyfunction] + fn viewport_model_outline(handle: u64, vm: &VirtualMachine) -> PyResult { + let found = host_ctx::with_host(|host| { + let Some(EntityType::Viewport(viewport)) = + host.document().get_entity(Handle::new(handle)) + else { + return None; + }; + if viewport.id == 1 + || !viewport.clip_boundary_handle.is_null() + || viewport.view_direction != Vector3::UNIT_Z + || ![ + viewport.width, + viewport.height, + viewport.view_height, + viewport.twist_angle, + viewport.view_center.x, + viewport.view_center.y, + viewport.view_target.x, + viewport.view_target.y, + viewport.view_target.z, + ] + .iter() + .all(|value| value.is_finite()) + || viewport.width <= 0.0 + || viewport.height <= 0.0 + || viewport.view_height <= 0.0 + { + return None; + } + let scale = viewport.view_height / viewport.height; + let (sin, cos) = (-viewport.twist_angle).sin_cos(); + let corners = [ + (-viewport.width / 2.0, -viewport.height / 2.0), + (viewport.width / 2.0, -viewport.height / 2.0), + (viewport.width / 2.0, viewport.height / 2.0), + (-viewport.width / 2.0, viewport.height / 2.0), + ]; + let points = corners.map(|(x, y)| { + let dx = x * scale + viewport.view_center.x; + let dy = y * scale + viewport.view_center.y; + [ + viewport.view_target.x + dx * cos - dy * sin, + viewport.view_target.y + dx * sin + dy * cos, + ] + }); + Some((points, viewport.view_target.z)) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + let Some((points, elevation)) = found else { + return Ok(vm.ctx.none()); + }; + let dict = vm.ctx.new_dict(); + let vertices = points + .into_iter() + .map(|point| { + vm.ctx + .new_list(vec![vm.new_pyobj(point[0]), vm.new_pyobj(point[1])]) + .into() + }) + .collect(); + dict.set_item("vertices", vm.ctx.new_list(vertices).into(), vm)?; + dict.set_item("elevation", vm.new_pyobj(elevation), vm)?; + Ok(dict.into()) + } + + /// Copy supported modelspace XY curves through a rectangular +Z viewport. + #[pyfunction] + fn copy_model_to_paper( + handles: Vec, + viewport_handle: u64, + vm: &VirtualMachine, + ) -> PyResult { + if handles.is_empty() { + return Ok(vm.ctx.new_list(Vec::::new()).into()); + } + let copies = host_ctx::with_host(|host| { + let document = host.document(); + let Some(EntityType::Viewport(viewport)) = + document.get_entity(Handle::new(viewport_handle)) + else { + return None; + }; + if viewport.id == 1 + || !viewport.clip_boundary_handle.is_null() + || viewport.view_direction != Vector3::UNIT_Z + || ![ + viewport.width, + viewport.height, + viewport.view_height, + viewport.twist_angle, + viewport.center.x, + viewport.center.y, + viewport.view_center.x, + viewport.view_center.y, + viewport.view_target.x, + viewport.view_target.y, + viewport.view_target.z, + ] + .iter() + .all(|value| value.is_finite()) + || viewport.width <= 0.0 + || viewport.height <= 0.0 + || viewport.view_height <= 0.0 + { + return None; + } + let paper_block = viewport.common.owner_handle; + let block = document + .block_records + .iter() + .find(|record| record.handle == paper_block)?; + let ObjectType::Layout(layout) = document.objects.get(&block.layout)? else { + return None; + }; + if layout.name == "Model" { + return None; + } + let scale = viewport.height / viewport.view_height; + let transform = Transform::from_translation(Vector3::new( + -viewport.view_target.x, + -viewport.view_target.y, + -viewport.view_target.z, + )) + .then(&Transform::from_rotation( + Vector3::UNIT_Z, + viewport.twist_angle, + )) + .then(&Transform::from_scaling(Vector3::new(scale, scale, 1.0))) + .then(&Transform::from_translation(Vector3::new( + viewport.center.x - viewport.view_center.x * scale, + viewport.center.y - viewport.view_center.y * scale, + 0.0, + ))); + let mut copies = Vec::with_capacity(handles.len()); + for handle in handles { + let mut entity = document.get_entity(Handle::new(handle))?.clone(); + if entity.common().owner_handle != document.header.model_space_block_handle { + return None; + } + let planar = match &entity { + EntityType::Line(item) => { + item.normal == Vector3::UNIT_Z + && (item.start.z - viewport.view_target.z).abs() <= 1e-9 + && (item.end.z - viewport.view_target.z).abs() <= 1e-9 + } + EntityType::Arc(item) => { + item.normal == Vector3::UNIT_Z + && (item.center.z - viewport.view_target.z).abs() <= 1e-9 + } + EntityType::Circle(item) => { + item.normal == Vector3::UNIT_Z + && (item.center.z - viewport.view_target.z).abs() <= 1e-9 + } + EntityType::LwPolyline(item) => { + item.normal == Vector3::UNIT_Z + && (item.elevation - viewport.view_target.z).abs() <= 1e-9 + } + _ => false, + }; + if !planar { + return None; + } + entity.as_entity_mut().apply_transform(&transform); + entity.common_mut().handle = Handle::NULL; + entity.common_mut().owner_handle = paper_block; + copies.push(entity); + } + Some(copies) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))? + .ok_or_else(|| { + vm.new_value_error( + "ocs.copy_model_to_paper: unsupported viewport or source geometry".to_owned(), + ) + })?; + let new_handles = host_ctx::with_host(|host| { + host_ctx::ensure_undo_started(host); + host.add_entities(copies) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + if new_handles.iter().any(|handle| handle.is_null()) { + return Err(vm.new_runtime_error( + "ocs.copy_model_to_paper: host did not add every entity".to_owned(), + )); + } + Ok(vm + .ctx + .new_list( + new_handles + .into_iter() + .map(|handle| vm.new_pyobj(handle.value())) + .collect(), + ) + .into()) + } + + /// Mirror supported planar curves across an existing XY LINE, optionally + /// removing the originals. The axis itself is never mirrored or removed. + #[pyfunction] + fn mirror_xy_entities( + handles: Vec, + axis_handle: u64, + erase_source: bool, + vm: &VirtualMachine, + ) -> PyResult { + if handles.is_empty() { + return Ok(vm.ctx.new_list(Vec::::new()).into()); + } + let unique: std::collections::HashSet = handles.iter().copied().collect(); + if unique.len() != handles.len() || unique.contains(&axis_handle) { + return Err(vm.new_value_error( + "ocs.mirror_xy_entities: handles must be unique and exclude the axis".to_owned(), + )); + } + let prepared = host_ctx::with_host(|host| { + let document = host.document(); + let Some(EntityType::Line(axis)) = document.get_entity(Handle::new(axis_handle)) else { + return None; + }; + if axis.normal != Vector3::UNIT_Z + || (axis.start.z - axis.end.z).abs() > 1e-9 + || axis.length() <= 0.0 + { + return None; + } + let transform = Transform::from_mirror_line(axis.start, axis.end); + let mut copies = Vec::with_capacity(handles.len()); + for handle in &handles { + let mut entity = document.get_entity(Handle::new(*handle))?.clone(); + let planar = match &entity { + EntityType::Line(item) => { + item.normal == Vector3::UNIT_Z + && (item.start.z - axis.start.z).abs() <= 1e-9 + && (item.end.z - axis.start.z).abs() <= 1e-9 + } + EntityType::Arc(item) => { + item.normal == Vector3::UNIT_Z + && (item.center.z - axis.start.z).abs() <= 1e-9 + } + EntityType::Circle(item) => { + item.normal == Vector3::UNIT_Z + && (item.center.z - axis.start.z).abs() <= 1e-9 + } + EntityType::LwPolyline(item) => { + item.normal == Vector3::UNIT_Z + && (item.elevation - axis.start.z).abs() <= 1e-9 + } + _ => false, + }; + if !planar { + return None; + } + entity.apply_mirror(&transform); + entity.common_mut().handle = Handle::NULL; + copies.push(entity); + } + Some(copies) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))? + .ok_or_else(|| { + vm.new_value_error( + "ocs.mirror_xy_entities: axis or source must be supported planar geometry" + .to_owned(), + ) + })?; + let (added, removed_all) = host_ctx::with_host(|host| { + host_ctx::ensure_undo_started(host); + let added = host.add_entities(prepared); + let mut removed_all = true; + if erase_source && added.iter().all(|handle| !handle.is_null()) { + for handle in &handles { + removed_all &= host.remove_entity(Handle::new(*handle)); + } + } + (added, removed_all) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + if added.iter().any(|handle| handle.is_null()) { + return Err(vm.new_runtime_error( + "ocs.mirror_xy_entities: host did not add every entity".to_owned(), + )); + } + if !removed_all { + return Err(vm.new_runtime_error( + "ocs.mirror_xy_entities: a source could not be removed; inspect the drawing" + .to_owned(), + )); + } + Ok(vm + .ctx + .new_list( + added + .into_iter() + .map(|handle| vm.new_pyobj(handle.value())) + .collect(), + ) + .into()) + } + + /// Mirror planar curves across the XY line through `point` in `tangent` + /// direction. The calling script can derive these from a curve midpoint. + #[pyfunction] + fn mirror_xy_about_line( + handles: Vec, + point: Vec, + tangent: Vec, + erase_source: bool, + vm: &VirtualMachine, + ) -> PyResult { + if point.len() != 3 + || tangent.len() != 2 + || point + .iter() + .chain(tangent.iter()) + .any(|value| !value.is_finite()) + || tangent[0].hypot(tangent[1]) <= 1e-9 + || handles.len() + != handles + .iter() + .copied() + .collect::>() + .len() + { + return Err(vm.new_value_error( + "ocs.mirror_xy_about_line: invalid point, tangent, or duplicate handles".to_owned(), + )); + } + if handles.is_empty() { + return Ok(vm.ctx.new_list(Vec::::new()).into()); + } + let start = Vector3::new(point[0], point[1], point[2]); + let end = Vector3::new(point[0] + tangent[0], point[1] + tangent[1], point[2]); + let transform = Transform::from_mirror_line(start, end); + let prepared = host_ctx::with_host(|host| { + let mut copies = Vec::with_capacity(handles.len()); + for handle in &handles { + let mut entity = host.document().get_entity(Handle::new(*handle))?.clone(); + let planar = match &entity { + EntityType::Line(item) => item.normal == Vector3::UNIT_Z + && (item.start.z - start.z).abs() <= 1e-9 + && (item.end.z - start.z).abs() <= 1e-9, + EntityType::Arc(item) => item.normal == Vector3::UNIT_Z + && (item.center.z - start.z).abs() <= 1e-9, + EntityType::Circle(item) => item.normal == Vector3::UNIT_Z + && (item.center.z - start.z).abs() <= 1e-9, + EntityType::LwPolyline(item) => item.normal == Vector3::UNIT_Z + && (item.elevation - start.z).abs() <= 1e-9, + _ => false, + }; + if !planar { + return None; + } + entity.apply_mirror(&transform); + entity.common_mut().handle = Handle::NULL; + copies.push(entity); + } + Some(copies) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))? + .ok_or_else(|| vm.new_value_error( + "ocs.mirror_xy_about_line: only planar LINE/ARC/CIRCLE/LWPolyline sources are supported".to_owned(), + ))?; + let (added, removed_all) = host_ctx::with_host(|host| { + host_ctx::ensure_undo_started(host); + let added = host.add_entities(prepared); + let mut removed_all = true; + if erase_source && added.iter().all(|handle| !handle.is_null()) { + for handle in &handles { + removed_all &= host.remove_entity(Handle::new(*handle)); + } + } + (added, removed_all) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + if added.iter().any(|handle| handle.is_null()) || !removed_all { + return Err(vm.new_runtime_error( + "ocs.mirror_xy_about_line: host could not mirror every entity; inspect the drawing" + .to_owned(), + )); + } + Ok(vm + .ctx + .new_list( + added + .into_iter() + .map(|handle| vm.new_pyobj(handle.value())) + .collect(), + ) + .into()) + } + + /// Remove the span between two points strictly inside an XY LINE, keeping + /// its first fragment under the original handle and adding the second. + #[pyfunction] + fn break_xy_line( + handle: u64, + first: Vec, + second: Vec, + vm: &VirtualMachine, + ) -> PyResult { + if first.len() != 2 + || second.len() != 2 + || first + .iter() + .chain(second.iter()) + .any(|value| !value.is_finite()) + { + return Err(vm.new_value_error( + "ocs.break_xy_line: break points must be finite XY pairs".to_owned(), + )); + } + let prepared = host_ctx::with_host(|host| { + let Some(EntityType::Line(line)) = host.document().get_entity(Handle::new(handle)) + else { + return None; + }; + if line.normal != Vector3::UNIT_Z || (line.start.z - line.end.z).abs() > 1e-9 { + return None; + } + let dx = line.end.x - line.start.x; + let dy = line.end.y - line.start.y; + let length_sq = dx * dx + dy * dy; + if length_sq <= 1e-18 { + return None; + } + let parameter = |point: &[f64]| -> Option { + let px = point[0] - line.start.x; + let py = point[1] - line.start.y; + let t = (px * dx + py * dy) / length_sq; + let deviation = (px * dy - py * dx).abs() / length_sq.sqrt(); + if t <= 1e-9 || t >= 1.0 - 1e-9 || deviation > 1e-6 { + None + } else { + Some(t) + } + }; + let a = parameter(&first)?; + let b = parameter(&second)?; + let before = a.min(b); + let after = a.max(b); + let at = + |t: f64| Vector3::new(line.start.x + t * dx, line.start.y + t * dy, line.start.z); + let mut initial = line.clone(); + let mut final_part = line.clone(); + initial.end = at(before); + final_part.start = at(after); + final_part.common.handle = Handle::NULL; + Some((EntityType::Line(initial), EntityType::Line(final_part))) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))? + .ok_or_else(|| { + vm.new_value_error( + "ocs.break_xy_line: target must be an XY LINE and both points strictly on it" + .to_owned(), + ) + })?; + let result = host_ctx::with_host(|host| { + host_ctx::ensure_undo_started(host); + let added = host.add_entity(prepared.1); + if added.is_null() { + return None; + } + if !host.update_entity(prepared.0) { + host.remove_entity(added); + return None; + } + Some(added.value()) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))? + .ok_or_else(|| { + vm.new_runtime_error("ocs.break_xy_line: host could not edit the line".to_owned()) + })?; + Ok(result) + } + + /// Remove the angular span between two interior XY points on an ARC, + /// retaining the first fragment's original handle. + #[pyfunction] + fn break_xy_arc( + handle: u64, + first: Vec, + second: Vec, + vm: &VirtualMachine, + ) -> PyResult { + if first.len() != 2 + || second.len() != 2 + || first + .iter() + .chain(second.iter()) + .any(|value| !value.is_finite()) + { + return Err( + vm.new_value_error("ocs.break_xy_arc: points must be finite XY pairs".to_owned()) + ); + } + let prepared = host_ctx::with_host(|host| { + let Some(EntityType::Arc(arc)) = host.document().get_entity(Handle::new(handle)) else { + return None; + }; + if arc.normal != Vector3::UNIT_Z + || arc.radius <= 0.0 + || arc.sweep_angle() <= 1e-9 + || arc.sweep_angle() >= std::f64::consts::TAU - 1e-9 + { + return None; + } + let parameter = |point: &[f64]| -> Option { + let dx = point[0] - arc.center.x; + let dy = point[1] - arc.center.y; + if (dx.hypot(dy) - arc.radius).abs() > 1e-6 { + return None; + } + let angle = dy.atan2(dx); + let t = (angle - arc.start_angle).rem_euclid(std::f64::consts::TAU); + if t <= 1e-9 || t >= arc.sweep_angle() - 1e-9 { + None + } else { + Some(t) + } + }; + let a = parameter(&first)?; + let b = parameter(&second)?; + let mut initial = arc.clone(); + let mut final_part = arc.clone(); + initial.end_angle = arc.start_angle + a.min(b); + final_part.start_angle = arc.start_angle + a.max(b); + final_part.common.handle = Handle::NULL; + Some((EntityType::Arc(initial), EntityType::Arc(final_part))) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))? + .ok_or_else(|| { + vm.new_value_error( + "ocs.break_xy_arc: target must be a nonfull XY ARC with interior points on it" + .to_owned(), + ) + })?; + let result = host_ctx::with_host(|host| { + host_ctx::ensure_undo_started(host); + let added = host.add_entity(prepared.1); + if added.is_null() { + return None; + } + if !host.update_entity(prepared.0) { + host.remove_entity(added); + return None; + } + Some(added.value()) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))? + .ok_or_else(|| { + vm.new_runtime_error("ocs.break_xy_arc: host could not edit the arc".to_owned()) + })?; + Ok(result) + } + + /// Replace an XY CIRCLE with the complementary ARC after removing the + /// counterclockwise span from `first` to `second`. + #[pyfunction] + fn break_xy_circle( + handle: u64, + first: Vec, + second: Vec, + vm: &VirtualMachine, + ) -> PyResult<()> { + if first.len() != 2 + || second.len() != 2 + || first + .iter() + .chain(second.iter()) + .any(|value| !value.is_finite()) + { + return Err(vm.new_value_error( + "ocs.break_xy_circle: points must be finite XY pairs".to_owned(), + )); + } + let prepared = host_ctx::with_host(|host| { + let Some(EntityType::Circle(circle)) = host.document().get_entity(Handle::new(handle)) + else { + return None; + }; + if circle.normal != Vector3::UNIT_Z || circle.radius <= 0.0 { + return None; + } + let angle = |point: &[f64]| -> Option { + let dx = point[0] - circle.center.x; + let dy = point[1] - circle.center.y; + if (dx.hypot(dy) - circle.radius).abs() > 1e-6 { + None + } else { + Some(dy.atan2(dx).rem_euclid(std::f64::consts::TAU)) + } + }; + let start = angle(&first)?; + let end = angle(&second)?; + if (end - start).rem_euclid(std::f64::consts::TAU) <= 1e-9 { + return None; + } + let mut arc = Arc::from_center_radius_angles(circle.center, circle.radius, end, start); + arc.common = circle.common.clone(); + arc.thickness = circle.thickness; + arc.normal = circle.normal; + Some(EntityType::Arc(arc)) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))? + .ok_or_else(|| { + vm.new_value_error( + "ocs.break_xy_circle: target must be an XY CIRCLE with distinct points on it" + .to_owned(), + ) + })?; + let updated = host_ctx::with_host(|host| { + host_ctx::ensure_undo_started(host); + host.update_entity(prepared) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + if !updated { + return Err(vm.new_runtime_error( + "ocs.break_xy_circle: host could not edit the circle".to_owned(), + )); + } + Ok(()) + } + + /// Remove the path interval between two interior points on a straight, + /// zero-width XY lightweight polyline. A closed polyline becomes one open + /// complementary path, under its original handle. + #[pyfunction] + fn break_xy_polyline( + handle: u64, + first: Vec, + second: Vec, + vm: &VirtualMachine, + ) -> PyResult { + if first.len() != 2 + || second.len() != 2 + || first + .iter() + .chain(second.iter()) + .any(|value| !value.is_finite()) + { + return Err(vm.new_value_error( + "ocs.break_xy_polyline: points must be finite XY pairs".to_owned(), + )); + } + let prepared = host_ctx::with_host(|host| { + let Some(EntityType::LwPolyline(polyline)) = host.document().get_entity(Handle::new(handle)) else { + return None; + }; + if polyline.normal != Vector3::UNIT_Z + || polyline.vertices.len() < (if polyline.is_closed { 3 } else { 2 }) + || polyline.vertices.iter().any(|vertex| vertex.bulge != 0.0 + || vertex.start_width != 0.0 || vertex.end_width != 0.0) { + return None; + } + let segment_count = polyline.vertices.len() - if polyline.is_closed { 0 } else { 1 }; + let mut lengths = Vec::with_capacity(segment_count); + let mut total = 0.0; + for index in 0..segment_count { + let a = polyline.vertices[index].location; + let b = polyline.vertices[(index + 1) % polyline.vertices.len()].location; + let length = (b.x - a.x).hypot(b.y - a.y); + lengths.push(length); + total += length; + } + if total <= 1e-9 { + return None; + } + let locate = |point: &[f64]| -> Option<(usize, f64)> { + let mut walked = 0.0; + for (index, length) in lengths.iter().copied().enumerate() { + if length <= 1e-9 { + continue; + } + let a = polyline.vertices[index].location; + let b = polyline.vertices[(index + 1) % polyline.vertices.len()].location; + let dx = b.x - a.x; + let dy = b.y - a.y; + let px = point[0] - a.x; + let py = point[1] - a.y; + let fraction = (px * dx + py * dy) / (length * length); + let deviation = (px * dy - py * dx).abs() / length; + let position = walked + fraction * length; + if fraction > 1e-9 && fraction < 1.0 - 1e-9 + && deviation <= 1e-6 && position > 1e-9 + && position < total - 1e-9 { + return Some((index, position)); + } + walked += length; + } + None + }; + let a = locate(&first)?; + let b = locate(&second)?; + if (first[0] - second[0]).hypot(first[1] - second[1]) <= 1e-9 { + return None; + } + if polyline.is_closed { + let mut complement = polyline.clone(); + let mut vertices = vec![LwVertex::new(Vector2::new(second[0], second[1]))]; + if b.0 != a.0 || b.1 > a.1 { + let mut index = (b.0 + 1) % polyline.vertices.len(); + loop { + vertices.push(polyline.vertices[index]); + if index == a.0 { + break; + } + index = (index + 1) % polyline.vertices.len(); + } + } + vertices.push(LwVertex::new(Vector2::new(first[0], first[1]))); + complement.vertices = vertices; + complement.is_closed = false; + return Some((EntityType::LwPolyline(complement), None)); + } + let ((before_index, before_point), (after_index, after_point)) = + if a.1 <= b.1 { ((a.0, &first), (b.0, &second)) } + else { ((b.0, &second), (a.0, &first)) }; + let mut initial = polyline.clone(); + initial.vertices.truncate(before_index + 1); + initial.vertices.push(LwVertex::new(Vector2::new(before_point[0], before_point[1]))); + let mut final_part = polyline.clone(); + final_part.vertices = vec![LwVertex::new(Vector2::new(after_point[0], after_point[1]))]; + final_part.vertices.extend_from_slice(&polyline.vertices[after_index + 1..]); + final_part.common.handle = Handle::NULL; + Some((EntityType::LwPolyline(initial), Some(EntityType::LwPolyline(final_part)))) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))? + .ok_or_else(|| vm.new_value_error( + "ocs.break_xy_polyline: target must be a straight zero-width XY polyline with distinct interior points".to_owned(), + ))?; + let result = host_ctx::with_host(|host| { + host_ctx::ensure_undo_started(host); + if let Some(final_part) = prepared.1 { + let added = host.add_entity(final_part); + if added.is_null() { + return None; + } + if !host.update_entity(prepared.0) { + host.remove_entity(added); + return None; + } + Some(added.value()) + } else if host.update_entity(prepared.0) { + Some(handle) + } else { + None + } + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))? + .ok_or_else(|| { + vm.new_runtime_error( + "ocs.break_xy_polyline: host could not edit the polyline".to_owned(), + ) + })?; + Ok(result) + } + + /// Rigidly move and rotate planar curves, mapping one source XY point to + /// one destination point. Optionally add transformed copies instead of + /// changing the originals. This intentionally does not scale geometry. + #[pyfunction] + fn align_xy_entities( + handles: Vec, + source: Vec, + destination: Vec, + source_direction: Vec, + target_direction: Vec, + copy_mode: bool, + vm: &VirtualMachine, + ) -> PyResult { + if source.len() != 3 + || destination.len() != 3 + || source_direction.len() != 2 + || target_direction.len() != 2 + || source + .iter() + .chain(destination.iter()) + .chain(source_direction.iter()) + .chain(target_direction.iter()) + .any(|value| !value.is_finite()) + || handles.len() + != handles + .iter() + .copied() + .collect::>() + .len() + { + return Err(vm.new_value_error( + "ocs.align_xy_entities: invalid points, directions, or duplicate handles" + .to_owned(), + )); + } + let source_length = source_direction[0].hypot(source_direction[1]); + let target_length = target_direction[0].hypot(target_direction[1]); + if source_length <= 0.0 || target_length <= 0.0 { + return Err( + vm.new_value_error("ocs.align_xy_entities: directions must be nonzero".to_owned()) + ); + } + if handles.is_empty() { + return Ok(0); + } + let rotation = target_direction[1].atan2(target_direction[0]) + - source_direction[1].atan2(source_direction[0]); + let source = Vector3::new(source[0], source[1], source[2]); + let destination = Vector3::new(destination[0], destination[1], destination[2]); + let transform = Transform::from_translation(Vector3::new(-source.x, -source.y, -source.z)) + .then(&Transform::from_rotation(Vector3::UNIT_Z, rotation)) + .then(&Transform::from_translation(destination)); + let mut prepared = host_ctx::with_host(|host| { + let mut entities = Vec::with_capacity(handles.len()); + for handle in &handles { + let mut entity = host.document().get_entity(Handle::new(*handle))?.clone(); + let planar = match &entity { + EntityType::Line(item) => item.normal == Vector3::UNIT_Z + && (item.start.z - item.end.z).abs() <= 1e-9, + EntityType::Arc(item) => item.normal == Vector3::UNIT_Z, + EntityType::Circle(item) => item.normal == Vector3::UNIT_Z, + EntityType::LwPolyline(item) => item.normal == Vector3::UNIT_Z, + _ => false, + }; + if !planar { + return None; + } + entity.apply_transform(&transform); + entities.push(entity); + } + Some(entities) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))? + .ok_or_else(|| vm.new_value_error( + "ocs.align_xy_entities: only planar LINE/ARC/CIRCLE/LWPolyline sources are supported".to_owned(), + ))?; + if copy_mode { + for entity in &mut prepared { + entity.common_mut().handle = Handle::NULL; + } + } + let updated = host_ctx::with_host(|host| { + host_ctx::ensure_undo_started(host); + if copy_mode { + host.add_entities(prepared) + .iter() + .all(|handle| !handle.is_null()) + } else { + prepared + .into_iter() + .all(|entity| host.update_entity(entity)) + } + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + if !updated { + return Err(vm.new_runtime_error( + "ocs.align_xy_entities: host could not write every entity; inspect the drawing" + .to_owned(), + )); + } + Ok(handles.len()) + } + + /// Replace an XY line, arc, or circle with a lightweight polyline, + /// retaining its handle, layer, and common drawing properties. + #[pyfunction] + fn replace_with_polyline( + handle: u64, + vertices: Vec>, + elevation: ArgIntoFloat, + closed: bool, + vm: &VirtualMachine, + ) -> PyResult<()> { + let elevation = elevation.into_float(); + if vertices.len() < (if closed { 3 } else { 2 }) + || vertices + .iter() + .any(|point| point.len() != 2 || point.iter().any(|value| !value.is_finite())) + || !elevation.is_finite() + { + return Err(vm.new_value_error( + "ocs.replace_with_polyline: invalid vertices or elevation".to_owned(), + )); + } + let changed = host_ctx::with_host(|host| { + let Some(entity) = host.document().get_entity(Handle::new(handle)).cloned() else { + return false; + }; + let supported = match &entity { + EntityType::Line(line) => { + line.normal == Vector3::UNIT_Z && (line.start.z - line.end.z).abs() <= 1e-9 + } + EntityType::Arc(arc) => arc.normal == Vector3::UNIT_Z, + EntityType::Circle(circle) => circle.normal == Vector3::UNIT_Z, + _ => false, + }; + if !supported { + return false; + } + let mut polyline = LwPolyline::new(); + polyline.common = entity.common().clone(); + polyline.elevation = elevation; + polyline.is_closed = closed; + for point in vertices { + polyline + .vertices + .push(LwVertex::new(Vector2::new(point[0], point[1]))); + } + host_ctx::ensure_undo_started(host); + host.update_entity(EntityType::LwPolyline(polyline)) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + if !changed { + return Err(vm.new_runtime_error(format!( + "ocs.replace_with_polyline: entity {handle} was not updated" + ))); + } + Ok(()) + } + + /// Create a native OCS table from a rectangular grid of cell strings. + #[pyfunction] + fn add_table( + rows: Vec>, + x: ArgIntoFloat, + y: ArgIntoFloat, + z: ArgIntoFloat, + row_height: ArgIntoFloat, + column_width: ArgIntoFloat, + vm: &VirtualMachine, + ) -> PyResult { + let (x, y, z, row_height, column_width) = ( + x.into_float(), + y.into_float(), + z.into_float(), + row_height.into_float(), + column_width.into_float(), + ); + let columns = rows.first().map_or(0, Vec::len); + if rows.is_empty() + || columns == 0 + || rows.iter().any(|row| row.len() != columns) + || ![x, y, z, row_height, column_width] + .iter() + .all(|v| v.is_finite()) + || row_height <= 0.0 + || column_width <= 0.0 + { + return Err(vm.new_value_error("ocs.add_table: rows must be rectangular and nonempty; dimensions must be positive and finite".to_owned())); + } + let mut builder = TableBuilder::new(rows.len(), columns) + .at(Vector3::new(x, y, z)) + .row_height(row_height) + .column_width(column_width); + for (row_index, row) in rows.iter().enumerate() { + for (column_index, value) in row.iter().enumerate() { + builder = builder.cell_text(row_index, column_index, value); + } + } + host_ctx::with_host(|host| { + host_ctx::ensure_undo_started(host); + host.add_entity(EntityType::Table(builder.build())).value() + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned())) + } + + /// Add a 2D line from `(x1, y1)` to `(x2, y2)` on layer `"0"`, returning + /// its handle. Starts the script's shared undo group (see + /// `host_ctx::ensure_undo_started`) if this is the first write this call. + /// + /// Coordinates take `ArgIntoFloat` rather than a bare `f64` so plain + /// Python ints work too (`ocs.add_line(0, 0, 10, 10)`) — a bare `f64` + /// parameter in RustPython, unlike CPython's C-function argument parsing, + /// does *not* implicitly coerce an `int`, and rejects it with a `TypeError`. + #[pyfunction] + fn add_line( + x1: ArgIntoFloat, + y1: ArgIntoFloat, + x2: ArgIntoFloat, + y2: ArgIntoFloat, + vm: &VirtualMachine, + ) -> PyResult { + let line = acadrust::Line::from_coords( + x1.into_float(), + y1.into_float(), + 0.0, + x2.into_float(), + y2.into_float(), + 0.0, + ); + host_ctx::with_host(|host| { + host_ctx::ensure_undo_started(host); + host.add_entity(EntityType::Line(line)).value() + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned())) + } + + /// Add a 2D circle centered at `(x, y)` with the given `radius`, on layer + /// `"0"`, returning its handle. Same undo-grouping and int/float argument + /// handling as `add_line`. + #[pyfunction] + fn add_circle( + x: ArgIntoFloat, + y: ArgIntoFloat, + radius: ArgIntoFloat, + vm: &VirtualMachine, + ) -> PyResult { + let circle = + acadrust::Circle::from_coords(x.into_float(), y.into_float(), 0.0, radius.into_float()); + host_ctx::with_host(|host| { + host_ctx::ensure_undo_started(host); + host.add_entity(EntityType::Circle(circle)).value() + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned())) + } + + /// Add a 2D arc centered at `(x, y)`, from `start_deg` to `end_deg` + /// (degrees, counterclockwise from the +X axis — matching how OCS's own + /// `ARC`/angle fields are conventionally read), on layer `"0"`. Returns + /// its handle. Same undo-grouping and int/float argument handling as + /// `add_line`. + #[pyfunction] + fn add_arc( + x: ArgIntoFloat, + y: ArgIntoFloat, + radius: ArgIntoFloat, + start_deg: ArgIntoFloat, + end_deg: ArgIntoFloat, + vm: &VirtualMachine, + ) -> PyResult { + let center = acadrust::Vector3::new(x.into_float(), y.into_float(), 0.0); + let arc = acadrust::Arc::from_center_radius_angles( + center, + radius.into_float(), + start_deg.into_float().to_radians(), + end_deg.into_float().to_radians(), + ); + host_ctx::with_host(|host| { + host_ctx::ensure_undo_started(host); + host.add_entity(EntityType::Arc(arc)).value() + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned())) + } + + /// Add an open 2D lightweight polyline. Each vertex is + /// [x, y, start_width, end_width]; widths belong to the segment starting + /// at that vertex. The polyline uses a single explicit elevation. + #[pyfunction] + fn add_polyline( + vertices: Vec>, + elevation: ArgIntoFloat, + vm: &VirtualMachine, + ) -> PyResult { + let elevation = elevation.into_float(); + if vertices.len() < 2 || !elevation.is_finite() { + return Err(vm.new_value_error( + "ocs.add_polyline: need at least two vertices and a finite elevation".to_owned(), + )); + } + let mut polyline = LwPolyline::new(); + polyline.elevation = elevation; + for vertex in vertices { + if vertex.len() != 4 + || vertex.iter().any(|value| !value.is_finite()) + || vertex[2] < 0.0 + || vertex[3] < 0.0 + { + return Err(vm.new_value_error("ocs.add_polyline: each vertex must be [finite x, y, nonnegative start_width, end_width]".to_owned())); + } + let mut item = LwVertex::new(Vector2::new(vertex[0], vertex[1])); + item.start_width = vertex[2]; + item.end_width = vertex[3]; + polyline.vertices.push(item); + } + host_ctx::with_host(|host| { + host_ctx::ensure_undo_started(host); + host.add_entity(EntityType::LwPolyline(polyline)).value() + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned())) + } + + /// Add a closed XY lightweight polyline with vertices `[x, y]`. + #[pyfunction] + fn add_closed_polyline( + vertices: Vec>, + elevation: ArgIntoFloat, + vm: &VirtualMachine, + ) -> PyResult { + let elevation = elevation.into_float(); + if vertices.len() < 3 + || !elevation.is_finite() + || vertices + .iter() + .any(|point| point.len() != 2 || point.iter().any(|v| !v.is_finite())) + { + return Err(vm.new_value_error("ocs.add_closed_polyline: need at least three finite [x, y] vertices and finite elevation".to_owned())); + } + let mut polyline = LwPolyline::new(); + polyline.elevation = elevation; + polyline.is_closed = true; + for point in vertices { + polyline + .vertices + .push(LwVertex::new(Vector2::new(point[0], point[1]))); + } + host_ctx::with_host(|host| { + host_ctx::ensure_undo_started(host); + host.add_entity(EntityType::LwPolyline(polyline)).value() + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned())) + } + + /// Replace a closed lightweight polyline's shape, preserving handle/properties/XDATA. + #[pyfunction] + fn update_closed_polyline( + handle: u64, + vertices: Vec>, + elevation: ArgIntoFloat, + vm: &VirtualMachine, + ) -> PyResult<()> { + let elevation = elevation.into_float(); + if vertices.len() < 3 + || !elevation.is_finite() + || vertices + .iter() + .any(|point| point.len() != 2 || point.iter().any(|v| !v.is_finite())) + { + return Err(vm.new_value_error("ocs.update_closed_polyline: need at least three finite [x, y] vertices and finite elevation".to_owned())); + } + let changed = host_ctx::with_host(|host| { + let Some(EntityType::LwPolyline(mut polyline)) = + host.document().get_entity(Handle::new(handle)).cloned() + else { + return false; + }; + if !polyline.is_closed { + return false; + } + polyline.elevation = elevation; + polyline.vertices = vertices + .into_iter() + .map(|point| LwVertex::new(Vector2::new(point[0], point[1]))) + .collect(); + host_ctx::ensure_undo_started(host); + host.update_entity(EntityType::LwPolyline(polyline)) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + if !changed { + return Err(vm.new_runtime_error(format!("ocs.update_closed_polyline: entity {handle} is not a writable closed lightweight polyline"))); + } + Ok(()) + } + + /// Add many 3D points in one host round trip, each `[x, y, z]`. Returns + /// the new handles in the same order as `points`. + /// + /// Ported from `schoeller/ocs_python_repl`'s `ocs.doc.add_many(...)`, + /// which batches thousands of points into a single request instead of + /// one per point. That plugin is out-of-process (real round-trip cost + /// per call); this one is in-process, so the win here is different but + /// still real: `HostApi::add_entities` lets the host coalesce the + /// undo/dirty/geometry-rebuild bookkeeping across the whole batch + /// instead of repeating it once per point (its default implementation + /// merely loops `add_entity`, but hosts are expected to override it for + /// batch efficiency — see its doc comment in `ocs_plugin_api`). + #[pyfunction] + fn add_points(points: Vec>, vm: &VirtualMachine) -> PyResult { + if points.is_empty() { + return Err(vm.new_value_error("ocs.add_points: need at least one point".to_owned())); + } + let mut entities = Vec::with_capacity(points.len()); + for point in &points { + if point.len() != 3 || point.iter().any(|value| !value.is_finite()) { + return Err(vm.new_value_error( + "ocs.add_points: each point must be [finite x, y, z]".to_owned(), + )); + } + entities.push(EntityType::Point(acadrust::Point::from_coords( + point[0], point[1], point[2], + ))); + } + let handles = host_ctx::with_host(|host| { + host_ctx::ensure_undo_started(host); + host.add_entities(entities) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + let items = handles + .into_iter() + .map(|h| vm.new_pyobj(h.value())) + .collect(); + Ok(vm.ctx.new_list(items).into()) + } + + /// Add one left/baseline-aligned single-line TEXT entity. + /// Its insertion point is the supplied point, which also makes it + /// eligible for `text_box_vertices` and the associated-textbox port. + #[pyfunction] + fn add_text_left( + value: rustpython_vm::builtins::PyStrRef, + x: ArgIntoFloat, + y: ArgIntoFloat, + z: ArgIntoFloat, + height: ArgIntoFloat, + rotation_radians: ArgIntoFloat, + vm: &VirtualMachine, + ) -> PyResult { + let (x, y, z, height, rotation_radians) = ( + x.into_float(), + y.into_float(), + z.into_float(), + height.into_float(), + rotation_radians.into_float(), + ); + if ![x, y, z, height, rotation_radians] + .iter() + .all(|v| v.is_finite()) + || height <= 0.0 + { + return Err(vm.new_value_error("ocs.add_text_left: coordinates and rotation must be finite; height must be positive".to_owned())); + } + let mut text = Text::with_value(value.to_string(), Vector3::new(x, y, z)) + .with_height(height) + .with_rotation(rotation_radians); + text.horizontal_alignment = TextHorizontalAlignment::Left; + text.vertical_alignment = TextVerticalAlignment::Baseline; + text.alignment_point = None; + host_ctx::with_host(|host| { + host_ctx::ensure_undo_started(host); + host.add_entity(EntityType::Text(text)).value() + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned())) + } + + /// Add one centered, middle-aligned single-line TEXT entity. + #[pyfunction] + fn add_text_centered( + value: rustpython_vm::builtins::PyStrRef, + x: ArgIntoFloat, + y: ArgIntoFloat, + z: ArgIntoFloat, + height: ArgIntoFloat, + rotation_radians: ArgIntoFloat, + vm: &VirtualMachine, + ) -> PyResult { + let (x, y, z, height, rotation_radians) = ( + x.into_float(), + y.into_float(), + z.into_float(), + height.into_float(), + rotation_radians.into_float(), + ); + if ![x, y, z, height, rotation_radians] + .iter() + .all(|v| v.is_finite()) + || height <= 0.0 + { + return Err(vm.new_value_error("ocs.add_text_centered: coordinates and rotation must be finite; height must be positive".to_owned())); + } + let point = Vector3::new(x, y, z); + let mut text = Text::with_value(value.to_string(), point) + .with_height(height) + .with_rotation(rotation_radians); + text.alignment_point = Some(point); + text.horizontal_alignment = TextHorizontalAlignment::Center; + text.vertical_alignment = TextVerticalAlignment::Middle; + host_ctx::with_host(|host| { + host_ctx::ensure_undo_started(host); + host.add_entity(EntityType::Text(text)).value() + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned())) + } + + /// Run a full command line (built-in or plugin) to completion, exactly + /// as if typed — including any inline point/keyword/handle tokens an + /// interactive command needs (e.g. `ocs.command("LINE 0,0 10,10")`, or + /// `ocs.command(f"PCONSTRAINT {h1:X} {h2:X}")` with two entity handles + /// for a constraint command's object picks). Raises a Python exception + /// on failure — including on hosts that predate `HostApi::run_command` + /// (Phase 1, `ocs.command()` — see `docs/python-scripting-roadmap.md` + /// in the OpenCADStudio repo for what this can and can't do). + #[cfg(feature = "experimental-command-replay")] + #[pyfunction] + fn command(cmd: rustpython_vm::builtins::PyStrRef, vm: &VirtualMachine) -> PyResult<()> { + let cmd = cmd.to_string(); + let result = host_ctx::with_host(|host| host.run_command(&cmd)).ok_or_else(|| { + vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()) + })?; + result.map_err(|e| vm.new_runtime_error(format!("ocs.command({cmd:?}): {e}"))) + } + + /// Set the document's selection to exactly these entity handles + /// (clearing any existing selection first). The missing piece for + /// driving a constraint command via `ocs.command()`: those read a prior + /// selection rather than picks fed as command-line tokens. Errors, + /// without partially applying, if any handle doesn't exist. + #[cfg(any( + feature = "experimental-command-replay", + feature = "experimental-host-model" + ))] + #[pyfunction] + fn select(handles: Vec, vm: &VirtualMachine) -> PyResult<()> { + let handles: Vec = handles.into_iter().map(Handle::new).collect(); + let result = host_ctx::with_host(|host| host.set_selection(&handles)).ok_or_else(|| { + vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()) + })?; + result.map_err(|e| vm.new_runtime_error(format!("ocs.select(...): {e}"))) + } + + fn kind_name(kind: ReaderEntityKind) -> &'static str { + match kind { + ReaderEntityKind::Point => "point", + ReaderEntityKind::Line => "line", + ReaderEntityKind::Circle => "circle", + ReaderEntityKind::Arc => "arc", + ReaderEntityKind::Polyline => "polyline", + ReaderEntityKind::Text => "text", + ReaderEntityKind::Other => "other", + } + } + + // ════════════════════════════════════════════════════════════════════ + // Generic entity CRUD (Phase 0 — see this session's + // `project_opencad_python_generic_entity_crud_plan.md` memory). + // + // `dict_to_entity`/`entity_to_dict` below are generated at build time + // (`build.rs` + `build/generate.rs`, driven by `entity_manifest.json` + // plus the type registry `ocs_plugin_api` embeds) from the registry's + // 12 currently-traced entity kinds, wired into `ocs.add` / `ocs.update` + // and the feature-gated document model. Round trips are exercised by + // `entity_crud_tests` below. + // + // A handful of leaf conversions the generated code calls into are + // hand-written here rather than generated, matching how `vector3_to_py` + // /`py_to_vector3` above are already hand-written for XDATA: `Vector3`/ + // `Vector2`/`Handle` have no registry-driven struct shape worth + // generating (they're represented as `{"x":..,"y":..,"z":..}` / + // `{"x":..,"y":..}` dicts, matching `get()`'s existing `point` dict — + // not the `[x, y, z]` list XDATA uses), and `get_opt_*` are tiny, + // reused by every generated setter. + + fn vector2_to_py(vm: &VirtualMachine, v: &Vector2) -> PyResult { + let dict = vm.ctx.new_dict(); + dict.set_item("x", vm.new_pyobj(v.x), vm)?; + dict.set_item("y", vm.new_pyobj(v.y), vm)?; + Ok(dict.into()) + } + + fn py_to_vector2(value: PyObjectRef, vm: &VirtualMachine) -> PyResult { + let dict = value.try_into_value::(vm)?; + let x = get_opt_f64(&dict, "x", vm)?; + let y = get_opt_f64(&dict, "y", vm)?; + Ok(Vector2::new(x, y)) + } + + fn vector3_to_py_dict(vm: &VirtualMachine, v: &Vector3) -> PyResult { + let dict = vm.ctx.new_dict(); + dict.set_item("x", vm.new_pyobj(v.x), vm)?; + dict.set_item("y", vm.new_pyobj(v.y), vm)?; + dict.set_item("z", vm.new_pyobj(v.z), vm)?; + Ok(dict.into()) + } + + fn py_to_vector3_dict(value: PyObjectRef, vm: &VirtualMachine) -> PyResult { + let dict = value.try_into_value::(vm)?; + let x = get_opt_f64(&dict, "x", vm)?; + let y = get_opt_f64(&dict, "y", vm)?; + let z = get_opt_f64(&dict, "z", vm)?; + Ok(Vector3::new(x, y, z)) + } + + fn get_opt_f64( + dict: &rustpython_vm::builtins::PyDictRef, + key: &str, + vm: &VirtualMachine, + ) -> PyResult { + match dict.get_item_opt(key, vm)? { + None => Ok(0.0), + Some(v) if vm.is_none(&v) => Ok(0.0), + Some(v) => py_number_to_f64(v, vm), + } + } + + /// `Color` as `{"kind": "ByLayer" | "None" | "ByBlock"}`, + /// `{"kind": "Index", "value": n}` or + /// `{"kind": "Rgb", "value": {"r": .., "g": .., "b": ..}}`. + fn color_to_py_dict(vm: &VirtualMachine, color: &acadrust::types::Color) -> PyResult { + use acadrust::types::Color; + let dict = vm.ctx.new_dict(); + match color { + Color::ByLayer => dict.set_item("kind", vm.new_pyobj("ByLayer"), vm)?, + Color::None => dict.set_item("kind", vm.new_pyobj("None"), vm)?, + Color::ByBlock => dict.set_item("kind", vm.new_pyobj("ByBlock"), vm)?, + Color::Index(index) => { + dict.set_item("kind", vm.new_pyobj("Index"), vm)?; + dict.set_item("value", vm.new_pyobj(i64::from(*index)), vm)?; + } + Color::Rgb { r, g, b } => { + dict.set_item("kind", vm.new_pyobj("Rgb"), vm)?; + let rgb = vm.ctx.new_dict(); + rgb.set_item("r", vm.new_pyobj(i64::from(*r)), vm)?; + rgb.set_item("g", vm.new_pyobj(i64::from(*g)), vm)?; + rgb.set_item("b", vm.new_pyobj(i64::from(*b)), vm)?; + dict.set_item("value", PyObjectRef::from(rgb), vm)?; + } + } + Ok(dict.into()) + } + + fn py_to_color_dict(value: PyObjectRef, vm: &VirtualMachine) -> PyResult { + use acadrust::types::Color; + let dict = value.try_into_value::(vm)?; + let kind = dict.get_item("kind", vm)?.try_into_value::(vm)?; + let byte = |v: PyObjectRef, what: &str| -> PyResult { + let n = v.try_into_value::(vm)?; + u8::try_from(n).map_err(|_| vm.new_value_error(format!("Color {what} must be 0..=255"))) + }; + Ok(match kind.as_str() { + "ByLayer" => Color::ByLayer, + "None" => Color::None, + "ByBlock" => Color::ByBlock, + "Index" => { + let index = byte(dict.get_item("value", vm)?, "index")?; + if index == 0 { + return Err(vm.new_value_error("Color index must be 1..=255; use ByBlock for 0".to_owned())); + } + Color::Index(index) + } + "Rgb" => { + let rgb = dict.get_item("value", vm)?.try_into_value::(vm)?; + Color::Rgb { + r: byte(rgb.get_item("r", vm)?, "r")?, + g: byte(rgb.get_item("g", vm)?, "g")?, + b: byte(rgb.get_item("b", vm)?, "b")?, + } + } + other => return Err(vm.new_value_error(format!("ocs: unsupported Color kind: {other}"))), + }) + } + + fn py_to_f64_array(value: PyObjectRef, vm: &VirtualMachine) -> PyResult<[f64; N]> { + let items: Vec = value.try_into_value(vm)?; + if items.len() != N { + return Err(vm.new_value_error(format!("expected {N} numbers, got {}", items.len()))); + } + let mut out = [0.0; N]; + for (slot, item) in out.iter_mut().zip(items) { + *slot = py_number_to_f64(item, vm)?; + } + Ok(out) + } + + /// Accept a Python int or float; `try_into_value::` rejects ints. + fn py_number_to_f64(value: PyObjectRef, vm: &VirtualMachine) -> PyResult { + Ok(value.try_float(vm)?.to_f64()) + } + + fn get_opt_bool( + dict: &rustpython_vm::builtins::PyDictRef, + key: &str, + vm: &VirtualMachine, + ) -> PyResult { + match dict.get_item_opt(key, vm)? { + None => Ok(false), + Some(v) if vm.is_none(&v) => Ok(false), + Some(v) => v.try_into_value::(vm), + } + } + + fn get_opt_u64( + dict: &rustpython_vm::builtins::PyDictRef, + key: &str, + vm: &VirtualMachine, + ) -> PyResult { + match dict.get_item_opt(key, vm)? { + None => Ok(0), + Some(v) if vm.is_none(&v) => Ok(0), + Some(v) => v.try_into_value::(vm), + } + } + + fn get_opt_string( + dict: &rustpython_vm::builtins::PyDictRef, + key: &str, + default: &str, + vm: &VirtualMachine, + ) -> PyResult { + match dict.get_item_opt(key, vm)? { + None => Ok(default.to_owned()), + Some(v) if vm.is_none(&v) => Ok(default.to_owned()), + Some(v) => v.try_into_value::(vm), + } + } + + fn ensure_known_entity_keys( + dict: &rustpython_vm::builtins::PyDictRef, + kind: &str, + allowed: &[&str], + vm: &VirtualMachine, + ) -> PyResult<()> { + for key in dict.keys_vec() { + let key = key.try_into_value::(vm)?; + if !allowed.contains(&key.as_str()) { + return Err(vm.new_value_error(format!( + "ocs: {kind} property {key:?} is outside the editable schema" + ))); + } + } + Ok(()) + } + + include!(concat!(env!("OUT_DIR"), "/entity_crud.rs")); + include!("dimension_model.rs.inc"); + include!("patch_helpers.rs.inc"); + + // ════════════════════════════════════════════════════════════════════ + // Phase 1 — wiring the generic conversion functions above into `ocs`. + // + // `get_entity` is deliberately a *new*, separate function rather than a + // change to `get()` above: existing scripts already depend on `get()`'s + // small `{handle, kind, layer, point}` shape (Phase 1 review feedback — + // don't break that compatibility to hand out the fuller schema). + + /// Full entity schema for `handle` (every field the type registry knows + /// about, not just `get()`'s small `{handle, kind, layer, point}`), or + /// `None` if the entity doesn't exist or isn't one of the kinds this + /// generic API covers yet. + #[pyfunction] + fn get_entity(handle: u64, vm: &VirtualMachine) -> PyResult { + let found = + host_ctx::with_host(|host| host.document().get_entity(Handle::new(handle)).cloned()) + .ok_or_else(|| { + vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()) + })?; + match found { + Some(entity) => entity_to_dict(vm, &entity), + None => Ok(vm.ctx.none()), + } + } + + /// API v7 document-model view: every entity has identity, kind and layer; + /// only manifest-covered kinds also expose editable geometry properties. + #[cfg(feature = "experimental-host-model")] + #[pyfunction] + fn entity_descriptor(handle: u64, vm: &VirtualMachine) -> PyResult { + let found = + host_ctx::with_host(|host| host.document().get_entity(Handle::new(handle)).cloned()) + .ok_or_else(|| { + vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()) + })?; + let Some(entity) = found else { + return Ok(vm.ctx.none()); + }; + if let Ok(full) = entity_to_dict(vm, &entity) { + let dict = full + .clone() + .try_into_value::(vm)?; + dict.set_item("_editable", vm.new_pyobj(true), vm)?; + return Ok(full); + } + let dict = vm.ctx.new_dict(); + dict.set_item("handle", vm.new_pyobj(handle), vm)?; + // `entity_type()` is the DXF name (for example `HATCH`), while the + // coverage catalog uses the Rust `EntityType` variant (`Hatch`). + let kind = ocs_plugin_api::entity_coverage::entity_snapshot(&entity) + .ok() + .and_then(|value| { + value + .as_object() + .and_then(|object| object.keys().next().cloned()) + }) + .ok_or_else(|| { + vm.new_runtime_error("ocs: cannot identify entity variant".to_owned()) + })?; + dict.set_item("kind", vm.new_pyobj(kind), vm)?; + dict.set_item("layer", vm.new_pyobj(entity.common().layer.clone()), vm)?; + dict.set_item( + "owner_handle", + vm.new_pyobj(entity.common().owner_handle.value()), + vm, + )?; + dict.set_item("_editable", vm.new_pyobj(false), vm)?; + Ok(dict.into()) + } + + #[cfg(feature = "experimental-host-model")] + fn snapshot_value_to_py( + value: serde_json::Value, + vm: &VirtualMachine, + ) -> PyResult { + Ok(match value { + serde_json::Value::Null => vm.ctx.none(), + serde_json::Value::Bool(value) => vm.new_pyobj(value), + serde_json::Value::Number(value) => { + if let Some(value) = value.as_i64() { + vm.new_pyobj(value) + } else if let Some(value) = value.as_u64() { + vm.new_pyobj(value) + } else { + vm.new_pyobj(value.as_f64().expect("JSON number")) + } + } + serde_json::Value::String(value) => vm.new_pyobj(value), + serde_json::Value::Array(values) => vm + .ctx + .new_list( + values + .into_iter() + .map(|value| snapshot_value_to_py(value, vm)) + .collect::>>()?, + ) + .into(), + serde_json::Value::Object(values) => { + let dict = vm.ctx.new_dict(); + for (key, value) in values { + dict.set_item(&key, snapshot_value_to_py(value, vm)?, vm)?; + } + dict.into() + } + }) + } + + /// Copy every serializable field of an entity, including kinds outside + /// the editable Python schema. The result is detached and read-only with + /// respect to the drawing; mutations to the returned dict do not commit. + #[cfg(feature = "experimental-host-model")] + #[pyfunction] + fn entity_snapshot(handle: u64, vm: &VirtualMachine) -> PyResult { + let found = + host_ctx::with_host(|host| host.document().get_entity(Handle::new(handle)).cloned()) + .ok_or_else(|| { + vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()) + })?; + let Some(entity) = found else { + return Ok(vm.ctx.none()); + }; + let value = ocs_plugin_api::entity_coverage::entity_snapshot(&entity).map_err(|error| { + vm.new_runtime_error(format!("ocs: entity snapshot failed: {error}")) + })?; + snapshot_value_to_py(value, vm) + } + + /// Host-owned coverage for a variant. `unmapped` properties exist in the + /// typed snapshot but have no Python document-model getter or setter. + #[cfg(feature = "experimental-host-model")] + #[pyfunction] + fn entity_coverage(kind: String, vm: &VirtualMachine) -> PyResult { + use ocs_plugin_api::entity_coverage::{EntityCoverageCatalog, EntityScope, ModelAccess}; + let catalog: EntityCoverageCatalog = + serde_json::from_str(ocs_plugin_api::get_embedded_entity_coverage_json()) + .map_err(|error| vm.new_runtime_error(error.to_string()))?; + let Some(entry) = catalog + .entity_kinds + .into_iter() + .find(|entry| entry.kind == kind) + else { + return Ok(vm.ctx.none()); + }; + let row = vm.ctx.new_dict(); + row.set_item("kind", vm.new_pyobj(entry.kind), vm)?; + let scope = match entry.scope { + EntityScope::Canvas => "canvas", + EntityScope::Internal => "internal", + EntityScope::Opaque => "opaque", + }; + row.set_item("scope", vm.new_pyobj(scope), vm)?; + let mut readable = Vec::new(); + let mut editable = Vec::new(); + let mut unmapped = Vec::new(); + let mut properties = Vec::new(); + for property in entry.properties { + let access = match property.model_access { + ModelAccess::ReadWrite => { + readable.push(property.name.clone()); + editable.push(property.name.clone()); + "read_write" + } + ModelAccess::ReadOnly => { + readable.push(property.name.clone()); + "read_only" + } + ModelAccess::Unmapped => { + unmapped.push(property.name.clone()); + "unmapped" + } + }; + let item = vm.ctx.new_dict(); + item.set_item("name", vm.new_pyobj(property.name), vm)?; + item.set_item("source_path", vm.new_pyobj(property.source_path), vm)?; + item.set_item("type", vm.new_pyobj(property.type_id), vm)?; + item.set_item("optional", vm.new_pyobj(property.optional), vm)?; + item.set_item("sequence", vm.new_pyobj(property.is_sequence), vm)?; + item.set_item( + "snapshot_readable", + vm.new_pyobj(property.snapshot_readable), + vm, + )?; + item.set_item("access", vm.new_pyobj(access), vm)?; + item.set_item("validation", vm.new_pyobj(property.validation), vm)?; + properties.push(item.into()); + } + let list = |values: Vec| { + vm.ctx.new_list( + values + .into_iter() + .map(|value| vm.new_pyobj(value)) + .collect(), + ) + }; + row.set_item("readable", list(readable).into(), vm)?; + row.set_item("editable", list(editable).into(), vm)?; + row.set_item("unmapped", list(unmapped).into(), vm)?; + row.set_item("properties", vm.ctx.new_list(properties).into(), vm)?; + Ok(row.into()) + } + + /// Enumerate every kind in the host-owned coverage catalog. + #[cfg(feature = "experimental-host-model")] + #[pyfunction] + fn entity_kinds(vm: &VirtualMachine) -> PyResult { + use ocs_plugin_api::entity_coverage::EntityCoverageCatalog; + let catalog: EntityCoverageCatalog = + serde_json::from_str(ocs_plugin_api::get_embedded_entity_coverage_json()) + .map_err(|error| vm.new_runtime_error(error.to_string()))?; + Ok(vm + .ctx + .new_list( + catalog + .entity_kinds + .into_iter() + .map(|entry| vm.new_pyobj(entry.kind)) + .collect(), + ) + .into()) + } + + #[cfg(feature = "experimental-host-model")] + #[pyfunction] + fn entity_handles(vm: &VirtualMachine) -> PyResult { + let handles = host_ctx::with_host(|host| { + host.document() + .entities() + .map(|entity| entity.common().handle.value()) + .collect::>() + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + Ok(vm + .ctx + .new_list( + handles + .into_iter() + .map(|handle| vm.new_pyobj(handle)) + .collect(), + ) + .into()) + } + + /// Add a new entity from a dict with a `"kind"` key (see `entity_to_dict` + /// for the shape `get_entity`/this function share) — a generic + /// alternative to `add_line`/`add_circle`/etc. covering every entity kind + /// the type registry currently traces. Returns the new handle. Errors if + /// `entity` is missing the geometry that actually defines the shape (a + /// line's `start`/`end`, a circle's `center`/`radius`, ...) rather than + /// silently defaulting it to zero. + #[pyfunction] + fn add(entity: PyObjectRef, vm: &VirtualMachine) -> PyResult { + let dict = entity.try_into_value::(vm)?; + let built = dict_to_entity(&dict, vm)?; + #[cfg(feature = "experimental-host-model")] + ocs_plugin_api::entity_coverage::validate_new_canvas_entity(&built) + .map_err(|error| vm.new_value_error(error))?; + host_ctx::with_host(|host| -> PyResult { + #[allow(unused_mut)] + let mut built = built; + #[cfg(feature = "experimental-host-model")] + { + ocs_plugin_api::entity_coverage::bind_canvas_entity_references( + host.document(), + &mut built, + ) + .map_err(|error| vm.new_value_error(error))?; + } + host_ctx::ensure_undo_started(host); + Ok(host.add_entity(built).value()) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))? + } + + /// Add a new entity, built from `entity` like `add`, to the block definition + /// `block`. Returns the new entity's handle. + #[cfg(feature = "experimental-host-model")] + #[pyfunction] + fn add_to_block(block: String, entity: PyObjectRef, vm: &VirtualMachine) -> PyResult { + let dict = entity.try_into_value::(vm)?; + let mut built = dict_to_entity(&dict, vm)?; + let result = host_ctx::with_host(|host| -> PyResult> { + let owner = host + .document() + .block_records + .get(block.trim()) + .map(|record| record.handle) + .ok_or_else(|| vm.new_runtime_error(format!("ocs.add_to_block: block {block:?} does not exist")))?; + built.common_mut().owner_handle = owner; + ocs_plugin_api::entity_coverage::validate_new_canvas_entity(&built) + .map_err(|error| vm.new_value_error(error))?; + Ok(host.table_operation(ocs_plugin_api::host::TableOperation::BlockEntityAdd { block, entity: built })) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))??; + result + .map(|handle| handle.value()) + .map_err(|error| vm.new_runtime_error(format!("ocs.add_to_block: {error}"))) + } + + /// Update `handle` by merging `entity`'s keys onto its *current* value — + /// a key `entity` doesn't mention is left exactly as it was, not reset to + /// a type default (Phase 1 review feedback: reconstructing from a partial + /// dict could silently drop properties this generic API can't represent + /// at all, e.g. color/line-weight/XDATA). Errors if `handle` doesn't + /// exist or isn't a supported kind. + #[pyfunction] + fn update(handle: u64, entity: PyObjectRef, vm: &VirtualMachine) -> PyResult<()> { + let dict = entity.try_into_value::(vm)?; + let changed = host_ctx::with_host(|host| -> PyResult { + let Some(existing) = host.document().get_entity(Handle::new(handle)).cloned() else { + return Ok(false); + }; + #[allow(unused_mut)] + let mut updated = apply_dict_to_entity(&existing, &dict, vm)?; + #[cfg(feature = "experimental-host-model")] + { + ocs_plugin_api::entity_coverage::bind_canvas_entity_references( + host.document(), + &mut updated, + ) + .map_err(|error| vm.new_value_error(error))?; + ocs_plugin_api::entity_coverage::validate_entity_mutation(&existing, &updated) + .map_err(|error| vm.new_value_error(error))?; + } + host_ctx::ensure_undo_started(host); + Ok(host.update_entity(updated)) + }) + .ok_or_else(|| { + vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()) + })??; + if !changed { + return Err(vm.new_runtime_error(format!("ocs.update: entity {handle} does not exist"))); + } + Ok(()) + } + + /// API v7: merge a list of property dictionaries and commit one undo step. + /// The host validates the entire batch before changing the drawing. + #[cfg(feature = "experimental-host-model")] + fn apply_layer_only_patch( + existing: &EntityType, + dict: &rustpython_vm::builtins::PyDictRef, + vm: &VirtualMachine, + ) -> PyResult { + if dict.keys_vec().len() != 2 || dict.get_item_opt("layer", vm)?.is_none() { + return Err(vm.new_value_error( + "ocs.update_many: this kind supports only a layer patch".to_owned(), + )); + } + let layer = dict.get_item("layer", vm)?.try_into_value::(vm)?; + ocs_plugin_api::entity_coverage::patch_canvas_layer(existing, &layer) + .map_err(|error| vm.new_value_error(format!("ocs.update_many: {error}"))) + } + + #[cfg(feature = "experimental-host-model")] + #[pyfunction] + fn update_many(label: String, updates: Vec, vm: &VirtualMachine) -> PyResult<()> { + let result = host_ctx::with_host(|host| -> PyResult> { + let mut entities = Vec::with_capacity(updates.len()); + for patch in updates { + let dict = patch.try_into_value::(vm)?; + let handle = dict.get_item("handle", vm)?.try_into_value::(vm)?; + let existing = host + .document() + .get_entity(Handle::new(handle)) + .cloned() + .ok_or_else(|| { + vm.new_runtime_error(format!("entity {handle} does not exist")) + })?; + let changed = if entity_to_dict(vm, &existing).is_ok() { + apply_dict_to_entity(&existing, &dict, vm)? + } else { + apply_layer_only_patch(&existing, &dict, vm)? + }; + entities.push(changed); + } + Ok(host.update_entities_transaction(&label, entities)) + }) + .ok_or_else(|| { + vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()) + })??; + result.map_err(|error| vm.new_runtime_error(format!("ocs.update_many: {error}"))) + } + + #[cfg(test)] + mod entity_crud_tests { + use super::*; + #[cfg(feature = "experimental-host-model")] + use rustpython_vm::AsObject; + + #[cfg(feature = "experimental-host-model")] + #[test] + fn snapshot_conversion_preserves_nested_values() { + with_vm(|vm| { + let source = serde_json::json!({"Hatch": {"common": {"layer": "0"}, + "angles": [1.5, 2.0], "optional": null, "enabled": true}}); + let converted = snapshot_value_to_py(source, vm).unwrap(); + let dict = converted + .try_into_value::(vm) + .unwrap(); + let hatch = dict + .get_item("Hatch", vm) + .unwrap() + .try_into_value::(vm) + .unwrap(); + let common = hatch + .get_item("common", vm) + .unwrap() + .try_into_value::(vm) + .unwrap(); + assert_eq!( + common + .get_item("layer", vm) + .unwrap() + .try_into_value::(vm) + .unwrap(), + "0" + ); + assert!(hatch.get_item("optional", vm).unwrap().is(&vm.ctx.none())); + assert!(hatch + .get_item("enabled", vm) + .unwrap() + .try_into_value::(vm) + .unwrap()); + }); + } + + #[cfg(feature = "experimental-host-model")] + #[test] + fn unmapped_canvas_kind_accepts_only_layer_patch() { + with_vm(|vm| { + let entity = EntityType::MLine(acadrust::entities::MLine::default()); + let patch = vm.ctx.new_dict(); + patch.set_item("handle", vm.new_pyobj(1_u64), vm).unwrap(); + patch + .set_item("layer", vm.new_pyobj("HATCHES"), vm) + .unwrap(); + let changed = apply_layer_only_patch(&entity, &patch, vm).unwrap(); + assert_eq!(changed.common().layer, "HATCHES"); + patch.set_item("scale", vm.new_pyobj(2.0), vm).unwrap(); + assert!(apply_layer_only_patch(&entity, &patch, vm).is_err()); + }); + } + + fn with_vm(f: impl FnOnce(&VirtualMachine) -> R) -> R { + let interp = rustpython_vm::Interpreter::without_stdlib(Default::default()); + interp.enter(f) + } + + fn set_vector3( + vm: &VirtualMachine, + dict: &rustpython_vm::builtins::PyDictRef, + key: &str, + x: f64, + y: f64, + z: f64, + ) { + let v = vm.ctx.new_dict(); + v.set_item("x", vm.new_pyobj(x), vm).unwrap(); + v.set_item("y", vm.new_pyobj(y), vm).unwrap(); + v.set_item("z", vm.new_pyobj(z), vm).unwrap(); + dict.set_item(key, v.into(), vm).unwrap(); + } + + #[cfg(feature = "experimental-host-model")] + fn vector2(vm: &VirtualMachine, x: f64, y: f64) -> PyObjectRef { + let value = vm.ctx.new_dict(); + value.set_item("x", vm.new_pyobj(x), vm).unwrap(); + value.set_item("y", vm.new_pyobj(y), vm).unwrap(); + value.into() + } + + #[cfg(feature = "experimental-host-model")] + fn hatch_boundary(vm: &VirtualMachine, size: f64) -> PyObjectRef { + let edges = [ + (0.0, 0.0, size, 0.0), + (size, 0.0, size, size), + (size, size, 0.0, size), + (0.0, size, 0.0, 0.0), + ] + .into_iter() + .map(|(x1, y1, x2, y2)| { + let line = vm.ctx.new_dict(); + line.set_item("start", vector2(vm, x1, y1), vm).unwrap(); + line.set_item("end", vector2(vm, x2, y2), vm).unwrap(); + let edge = vm.ctx.new_dict(); + edge.set_item("kind", vm.new_pyobj("Line"), vm).unwrap(); + edge.set_item("value", line.into(), vm).unwrap(); + edge.into() + }) + .collect(); + let path = vm.ctx.new_dict(); + path.set_item("flags", vm.new_pyobj(1_i64), vm).unwrap(); + path.set_item("edges", vm.ctx.new_list(edges).into(), vm) + .unwrap(); + path.set_item("boundary_handles", vm.ctx.new_list(Vec::new()).into(), vm) + .unwrap(); + path.into() + } + + #[cfg(feature = "experimental-host-model")] + #[test] + fn phase_seven_kinds_create_and_preserve_unmentioned_properties() { + with_vm(|vm| { + for kind in ["Ray", "XLine", "Solid", "Face3D"] { + let input = vm.ctx.new_dict(); + input.set_item("kind", vm.new_pyobj(kind), vm).unwrap(); + input + .set_item("layer", vm.new_pyobj("Geometry"), vm) + .unwrap(); + match kind { + "Ray" | "XLine" => { + set_vector3(vm, &input, "base_point", 1.0, 2.0, 0.0); + set_vector3(vm, &input, "direction", 1.0, 0.0, 0.0); + } + _ => { + for (name, x, y) in [ + ("first_corner", 0.0, 0.0), + ("second_corner", 1.0, 0.0), + ("third_corner", 0.0, 1.0), + ("fourth_corner", 1.0, 1.0), + ] { + set_vector3(vm, &input, name, x, y, 0.0); + } + } + } + if kind == "Solid" { + input.set_item("is_trace", vm.new_pyobj(true), vm).unwrap(); + } + if kind == "Face3D" { + input + .set_item("invisible_edges", vm.new_pyobj(5_i64), vm) + .unwrap(); + } + let original = dict_to_entity(&input, vm).unwrap(); + ocs_plugin_api::entity_coverage::validate_new_canvas_entity(&original).unwrap(); + let rendered = entity_to_dict(vm, &original) + .unwrap() + .try_into_value::(vm) + .unwrap(); + assert_eq!( + rendered + .get_item("kind", vm) + .unwrap() + .try_into_value::(vm) + .unwrap(), + kind + ); + let patch = vm.ctx.new_dict(); + let changed_field = if matches!(kind, "Ray" | "XLine") { + "base_point" + } else { + "first_corner" + }; + set_vector3(vm, &patch, changed_field, 7.0, 8.0, 0.0); + let updated = apply_dict_to_entity(&original, &patch, vm).unwrap(); + ocs_plugin_api::entity_coverage::validate_entity_mutation(&original, &updated) + .unwrap(); + let updated_dict = entity_to_dict(vm, &updated) + .unwrap() + .try_into_value::(vm) + .unwrap(); + assert_eq!( + updated_dict + .get_item("layer", vm) + .unwrap() + .try_into_value::(vm) + .unwrap(), + "Geometry" + ); + if kind == "Solid" { + assert!(updated_dict + .get_item("is_trace", vm) + .unwrap() + .try_into_value::(vm) + .unwrap()); + } + if kind == "Face3D" { + assert_eq!( + updated_dict + .get_item("invisible_edges", vm) + .unwrap() + .try_into_value::(vm) + .unwrap(), + 5 + ); + } + } + }); + } + + #[cfg(feature = "experimental-host-model")] + #[test] + fn face3d_flags_reject_integer_overflow() { + with_vm(|vm| { + let input = vm.ctx.new_dict(); + input.set_item("kind", vm.new_pyobj("Face3D"), vm).unwrap(); + for name in [ + "first_corner", + "second_corner", + "third_corner", + "fourth_corner", + ] { + set_vector3(vm, &input, name, 0.0, 0.0, 0.0); + } + input + .set_item("invisible_edges", vm.new_pyobj(256_i64), vm) + .unwrap(); + assert!(dict_to_entity(&input, vm).is_err()); + }); + } + + #[cfg(feature = "experimental-host-model")] + #[test] + fn insert_is_created_by_block_name_and_preserves_block_identity() { + with_vm(|vm| { + let mut insert = + acadrust::entities::Insert::new("DOOR", Vector3::new(1.0, 2.0, 0.0)); + insert.set_x_scale(2.0); + let original = EntityType::Insert(insert); + let rendered = entity_to_dict(vm, &original) + .unwrap() + .try_into_value::(vm) + .unwrap(); + assert_eq!( + rendered + .get_item("block_name", vm) + .unwrap() + .try_into_value::(vm) + .unwrap(), + "DOOR" + ); + assert_eq!( + rendered + .get_item("x_scale", vm) + .unwrap() + .try_into_value::(vm) + .unwrap(), + 2.0 + ); + // A round-tripped dict names an existing block, so it creates one. + let recreated = dict_to_entity(&rendered, vm).unwrap(); + assert!(matches!(&recreated, EntityType::Insert(value) + if value.block_name == "DOOR" && value.x_scale() == 2.0)); + // Creation must name the block. + let unnamed = vm.ctx.new_dict(); + unnamed.set_item("kind", vm.new_pyobj("Insert"), vm).unwrap(); + assert!(dict_to_entity(&unnamed, vm).is_err()); + + let patch = vm.ctx.new_dict(); + set_vector3(vm, &patch, "insert_point", 4.0, 5.0, 0.0); + patch.set_item("y_scale", vm.new_pyobj(3.0), vm).unwrap(); + let updated = apply_dict_to_entity(&original, &patch, vm).unwrap(); + ocs_plugin_api::entity_coverage::validate_entity_mutation(&original, &updated) + .unwrap(); + let EntityType::Insert(updated) = updated else { + panic!("expected Insert"); + }; + assert_eq!(updated.block_name, "DOOR"); + assert_eq!(updated.insert_point, Vector3::new(4.0, 5.0, 0.0)); + assert_eq!(updated.x_scale(), 2.0); + assert_eq!(updated.y_scale(), 3.0); + + patch + .set_item("block_name", vm.new_pyobj("OTHER"), vm) + .unwrap(); + // The adapter only converts; the host mutation check refuses a + // different block, so an edit cannot swap block identity. + let renamed = apply_dict_to_entity(&original, &patch, vm).unwrap(); + assert!(ocs_plugin_api::entity_coverage::validate_entity_mutation(&original, &renamed).is_err()); + patch.del_item("block_name", vm).unwrap(); + patch.set_item("z_scale", vm.new_pyobj(0.0), vm).unwrap(); + assert!(apply_dict_to_entity(&original, &patch, vm).is_err()); + }); + } + + #[cfg(feature = "experimental-host-model")] + #[test] + fn tolerance_converts_and_style_change_clears_stale_handle() { + with_vm(|vm| { + let input = vm.ctx.new_dict(); + input + .set_item("kind", vm.new_pyobj("Tolerance"), vm) + .unwrap(); + set_vector3(vm, &input, "insertion_point", 1.0, 2.0, 0.0); + set_vector3(vm, &input, "direction", 1.0, 0.0, 0.0); + input + .set_item("text", vm.new_pyobj("{\\Fgdt;p}%%v0.1"), vm) + .unwrap(); + input + .set_item("dimension_style_name", vm.new_pyobj("Standard"), vm) + .unwrap(); + let original = dict_to_entity(&input, vm).unwrap(); + ocs_plugin_api::entity_coverage::validate_new_canvas_entity(&original).unwrap(); + let EntityType::Tolerance(mut with_handle) = original.clone() else { + panic!("expected Tolerance"); + }; + with_handle.dimension_style_handle = Some(Handle::new(42)); + let original = EntityType::Tolerance(with_handle); + let patch = vm.ctx.new_dict(); + patch + .set_item("text", vm.new_pyobj("{\\Fgdt;p}%%v0.2"), vm) + .unwrap(); + patch + .set_item("dimension_style_name", vm.new_pyobj("ISO-25"), vm) + .unwrap(); + let updated = apply_dict_to_entity(&original, &patch, vm).unwrap(); + let EntityType::Tolerance(updated) = updated else { + panic!("expected Tolerance"); + }; + assert_eq!(updated.text, "{\\Fgdt;p}%%v0.2"); + assert_eq!(updated.dimension_style_name, "ISO-25"); + assert_eq!(updated.dimension_style_handle, None); + assert_eq!(updated.insertion_point, Vector3::new(1.0, 2.0, 0.0)); + let rendered = entity_to_dict(vm, &EntityType::Tolerance(updated)) + .unwrap() + .try_into_value::(vm) + .unwrap(); + assert!(rendered + .get_item_opt("dimension_style_handle", vm) + .unwrap() + .is_some()); + assert!(rendered + .get_item_opt("dwg_unknown_short", vm) + .unwrap() + .is_none()); + }); + } + + #[cfg(feature = "experimental-host-model")] + #[test] + fn shape_converts_and_style_change_clears_stale_handle() { + with_vm(|vm| { + let input = vm.ctx.new_dict(); + input.set_item("kind", vm.new_pyobj("Shape"), vm).unwrap(); + set_vector3(vm, &input, "insertion_point", 1.0, 2.0, 0.0); + input.set_item("size", vm.new_pyobj(2.0), vm).unwrap(); + input + .set_item("shape_name", vm.new_pyobj("ARROW"), vm) + .unwrap(); + input + .set_item("shape_number", vm.new_pyobj(1_i32), vm) + .unwrap(); + input + .set_item("style_name", vm.new_pyobj("Symbols"), vm) + .unwrap(); + let original = dict_to_entity(&input, vm).unwrap(); + ocs_plugin_api::entity_coverage::validate_new_canvas_entity(&original).unwrap(); + let EntityType::Shape(mut with_handle) = original.clone() else { + panic!("expected Shape"); + }; + with_handle.style_handle = Some(Handle::new(42)); + let original = EntityType::Shape(with_handle); + let patch = vm.ctx.new_dict(); + patch.set_item("size", vm.new_pyobj(3.0), vm).unwrap(); + patch + .set_item("style_name", vm.new_pyobj("OtherSymbols"), vm) + .unwrap(); + let updated = apply_dict_to_entity(&original, &patch, vm).unwrap(); + let EntityType::Shape(updated) = updated else { + panic!("expected Shape"); + }; + assert_eq!(updated.size, 3.0); + assert_eq!(updated.shape_name, "ARROW"); + assert_eq!(updated.shape_number, 1); + assert_eq!(updated.style_name, "OtherSymbols"); + assert_eq!(updated.style_handle, None); + assert_eq!(updated.insertion_point, Vector3::new(1.0, 2.0, 0.0)); + }); + } + + #[cfg(feature = "experimental-host-model")] + #[test] + fn attribute_definition_converts_owner_and_preserves_embedded_mtext() { + with_vm(|vm| { + let input = vm.ctx.new_dict(); + input + .set_item("kind", vm.new_pyobj("AttributeDefinition"), vm) + .unwrap(); + input + .set_item("owner_handle", vm.new_pyobj(42_u64), vm) + .unwrap(); + input.set_item("tag", vm.new_pyobj("PART_NO"), vm).unwrap(); + input + .set_item("prompt", vm.new_pyobj("Part number"), vm) + .unwrap(); + input + .set_item("default_value", vm.new_pyobj("PN-001"), vm) + .unwrap(); + set_vector3(vm, &input, "insertion_point", 1.0, 2.0, 0.0); + input.set_item("height", vm.new_pyobj(2.5), vm).unwrap(); + let entity = dict_to_entity(&input, vm).unwrap(); + let EntityType::AttributeDefinition(mut definition) = entity else { + panic!("expected AttributeDefinition"); + }; + assert_eq!(definition.common.owner_handle, Handle::new(42)); + assert_eq!(definition.tag, "PART_NO"); + assert_eq!(definition.text_style, "Standard"); + + let mut embedded = acadrust::entities::MText::default(); + embedded.value = "first\\Psecond".into(); + definition.embedded_mtext = Some(Box::new(embedded)); + let entity = EntityType::AttributeDefinition(definition); + let patch = vm.ctx.new_dict(); + patch + .set_item("default_value", vm.new_pyobj("PN-002"), vm) + .unwrap(); + set_vector3(vm, &patch, "insertion_point", 4.0, 5.0, 0.0); + let updated = apply_dict_to_entity(&entity, &patch, vm).unwrap(); + let EntityType::AttributeDefinition(updated) = updated else { + panic!("expected AttributeDefinition"); + }; + assert_eq!(updated.default_value, "PN-002"); + assert_eq!(updated.insertion_point, Vector3::new(4.0, 5.0, 0.0)); + assert_eq!( + updated.embedded_mtext.as_ref().unwrap().value, + "first\\Psecond" + ); + + let rendered = entity_to_dict(vm, &EntityType::AttributeDefinition(updated)) + .unwrap() + .try_into_value::(vm) + .unwrap(); + assert_eq!( + rendered + .get_item("owner_handle", vm) + .unwrap() + .try_into_value::(vm) + .unwrap(), + 42 + ); + assert!(rendered + .get_item_opt("embedded_mtext", vm) + .unwrap() + .is_none()); + + let owner_patch = vm.ctx.new_dict(); + owner_patch + .set_item("owner_handle", vm.new_pyobj(43_u64), vm) + .unwrap(); + assert!(apply_dict_to_entity(&entity, &owner_patch, vm) + .unwrap_err() + .args() + .as_slice()[0] + .clone() + .try_into_value::(vm) + .unwrap() + .contains("read-only after creation")); + }); + } + + #[cfg(feature = "experimental-host-model")] + #[test] + fn attribute_entity_converts_nested_identity_and_preserves_embedded_mtext() { + with_vm(|vm| { + let input = vm.ctx.new_dict(); + input + .set_item("kind", vm.new_pyobj("AttributeEntity"), vm) + .unwrap(); + input + .set_item("owner_handle", vm.new_pyobj(50_u64), vm) + .unwrap(); + input.set_item("tag", vm.new_pyobj("PART_NO"), vm).unwrap(); + input.set_item("value", vm.new_pyobj("PN-101"), vm).unwrap(); + set_vector3(vm, &input, "insertion_point", 10.0, 2.0, 0.0); + input.set_item("height", vm.new_pyobj(2.5), vm).unwrap(); + let entity = dict_to_entity(&input, vm).unwrap(); + let EntityType::AttributeEntity(mut attribute) = entity else { + panic!("expected AttributeEntity"); + }; + assert_eq!(attribute.common.owner_handle, Handle::new(50)); + attribute.attdef_handle = Handle::new(42); + let mut embedded = acadrust::entities::MText::default(); + embedded.value = "first\\Psecond".into(); + attribute.embedded_mtext = Some(Box::new(embedded)); + let entity = EntityType::AttributeEntity(attribute); + + let patch = vm.ctx.new_dict(); + patch.set_item("value", vm.new_pyobj("PN-102"), vm).unwrap(); + set_vector3(vm, &patch, "insertion_point", 12.0, 3.0, 0.0); + let updated_entity = apply_dict_to_entity(&entity, &patch, vm).unwrap(); + let EntityType::AttributeEntity(updated) = &updated_entity else { + unreachable!() + }; + assert_eq!(updated.value, "PN-102"); + assert_eq!(updated.attdef_handle, Handle::new(42)); + assert_eq!( + updated.embedded_mtext.as_ref().unwrap().value, + "first\\Psecond" + ); + + let tag_patch = vm.ctx.new_dict(); + tag_patch + .set_item("tag", vm.new_pyobj("SERIAL"), vm) + .unwrap(); + let retagged = apply_dict_to_entity(&updated_entity, &tag_patch, vm).unwrap(); + assert!(matches!(retagged, EntityType::AttributeEntity(value) + if value.tag == "SERIAL" && value.attdef_handle == Handle::new(42))); + let rendered = entity_to_dict(vm, &updated_entity) + .unwrap() + .try_into_value::(vm) + .unwrap(); + assert_eq!( + rendered + .get_item("attdef_handle", vm) + .unwrap() + .try_into_value::(vm) + .unwrap(), + 42 + ); + assert!(rendered + .get_item_opt("embedded_mtext", vm) + .unwrap() + .is_none()); + }); + } + + #[cfg(feature = "experimental-host-model")] + #[test] + fn hatch_converts_tagged_boundaries_patterns_and_preserves_gradient() { + with_vm(|vm| { + let input = vm.ctx.new_dict(); + input.set_item("kind", vm.new_pyobj("Hatch"), vm).unwrap(); + input + .set_item( + "paths", + vm.ctx.new_list(vec![hatch_boundary(vm, 10.0)]).into(), + vm, + ) + .unwrap(); + let entity = dict_to_entity(&input, vm).unwrap(); + ocs_plugin_api::entity_coverage::validate_new_canvas_entity(&entity).unwrap(); + let EntityType::Hatch(mut hatch) = entity else { + panic!("expected Hatch") + }; + hatch.gradient_color.enabled = true; + hatch.gradient_color.name = "LINEAR".into(); + let entity = EntityType::Hatch(hatch); + + let pattern_line = vm.ctx.new_dict(); + pattern_line + .set_item("angle", vm.new_pyobj(0.0), vm) + .unwrap(); + pattern_line + .set_item("base_point", vector2(vm, 0.0, 0.0), vm) + .unwrap(); + pattern_line + .set_item("offset", vector2(vm, 0.0, 2.0), vm) + .unwrap(); + pattern_line + .set_item( + "dash_lengths", + vm.ctx + .new_list(vec![vm.new_pyobj(1.0), vm.new_pyobj(-1.0)]) + .into(), + vm, + ) + .unwrap(); + let pattern = vm.ctx.new_dict(); + pattern.set_item("name", vm.new_pyobj("TEST"), vm).unwrap(); + pattern + .set_item("description", vm.new_pyobj("test pattern"), vm) + .unwrap(); + pattern + .set_item( + "lines", + vm.ctx.new_list(vec![pattern_line.into()]).into(), + vm, + ) + .unwrap(); + let patch = vm.ctx.new_dict(); + patch.set_item("is_solid", vm.new_pyobj(false), vm).unwrap(); + patch.set_item("pattern", pattern.into(), vm).unwrap(); + patch + .set_item("pattern_scale", vm.new_pyobj(2.0), vm) + .unwrap(); + patch + .set_item( + "paths", + vm.ctx.new_list(vec![hatch_boundary(vm, 12.0)]).into(), + vm, + ) + .unwrap(); + let updated = apply_dict_to_entity(&entity, &patch, vm).unwrap(); + ocs_plugin_api::entity_coverage::validate_entity_mutation(&entity, &updated) + .unwrap(); + let EntityType::Hatch(updated) = updated else { + unreachable!() + }; + assert!(!updated.is_solid); + assert_eq!(updated.pattern.name, "TEST"); + assert_eq!(updated.pattern.lines.len(), 1); + assert_eq!(updated.paths[0].edges.len(), 4); + assert!(updated.gradient_color.enabled); + let rendered = entity_to_dict(vm, &EntityType::Hatch(updated)) + .unwrap() + .try_into_value::(vm) + .unwrap(); + assert!(rendered + .get_item_opt("gradient_color", vm) + .unwrap() + .is_none()); + assert_eq!( + rendered + .get_item("paths", vm) + .unwrap() + .try_into_value::>(vm) + .unwrap() + .len(), + 1 + ); + let forbidden = vm.ctx.new_dict(); + forbidden + .set_item("gradient_color", vm.ctx.new_dict().into(), vm) + .unwrap(); + assert!(apply_dict_to_entity(&entity, &forbidden, vm).is_err()); + }); + } + + #[cfg(feature = "experimental-host-model")] + #[test] + fn phase_seven_kinds_survive_dwg_round_trip() { + with_vm(|vm| { + let mut doc = acadrust::CadDocument::new(); + let mut handles = Vec::new(); + for kind in ["Ray", "XLine", "Solid", "Face3D"] { + let input = vm.ctx.new_dict(); + input.set_item("kind", vm.new_pyobj(kind), vm).unwrap(); + if matches!(kind, "Ray" | "XLine") { + set_vector3(vm, &input, "base_point", 2.0, 3.0, 0.0); + set_vector3(vm, &input, "direction", 1.0, 0.0, 0.0); + } else { + for (name, x, y) in [ + ("first_corner", 0.0, 0.0), + ("second_corner", 1.0, 0.0), + ("third_corner", 0.0, 1.0), + ("fourth_corner", 1.0, 1.0), + ] { + set_vector3(vm, &input, name, x, y, 0.0); + } + } + let entity = dict_to_entity(&input, vm).unwrap(); + ocs_plugin_api::entity_coverage::validate_new_canvas_entity(&entity).unwrap(); + handles.push((kind, doc.add_entity(entity).unwrap())); + } + let path = std::env::temp_dir() + .join(format!("ocs_phase_seven_{}.dwg", std::process::id())); + acadrust::DwgWriter::write_to_file(&path, &doc).unwrap(); + let reopened = acadrust::DwgReader::from_file(&path) + .unwrap() + .read() + .unwrap(); + let _ = std::fs::remove_file(&path); + for (kind, handle) in handles { + let entity = reopened.get_entity(handle).expect("entity survives reopen"); + let rendered = entity_to_dict(vm, entity) + .unwrap() + .try_into_value::(vm) + .unwrap(); + assert_eq!( + rendered + .get_item("kind", vm) + .unwrap() + .try_into_value::(vm) + .unwrap(), + kind + ); + } + }); + } + + #[test] + fn text_is_in_editable_schema_and_keeps_unmentioned_fields() { + with_vm(|vm| { + let mut text = acadrust::entities::Text::new(); + text.value = "Before".into(); + text.height = 3.5; + text.insertion_point = Vector3::new(1.0, 2.0, 0.0); + let entity = EntityType::Text(text); + let data = entity_to_dict(vm, &entity).unwrap(); + let data = data + .try_into_value::(vm) + .unwrap(); + assert_eq!( + data.get_item("text", vm) + .unwrap() + .try_into_value::(vm) + .unwrap(), + "Before" + ); + let patch = vm.ctx.new_dict(); + patch.set_item("text", vm.new_pyobj("After"), vm).unwrap(); + let updated = apply_dict_to_entity(&entity, &patch, vm).unwrap(); + let EntityType::Text(updated) = updated else { + panic!("expected text"); + }; + assert_eq!(updated.value, "After"); + assert_eq!(updated.height, 3.5); + assert_eq!(updated.insertion_point, Vector3::new(1.0, 2.0, 0.0)); + }); + } + + #[test] + fn circle_round_trips_through_dict_to_entity_and_back() { + with_vm(|vm| { + let dict = vm.ctx.new_dict(); + dict.set_item("kind", vm.new_pyobj("Circle"), vm).unwrap(); + dict.set_item("layer", vm.new_pyobj("Walls"), vm).unwrap(); + set_vector3(vm, &dict, "center", 1.0, 2.0, 0.0); + dict.set_item("radius", vm.new_pyobj(5.0), vm).unwrap(); + + let entity = dict_to_entity(&dict, vm).expect("dict_to_entity"); + let acadrust::EntityType::Circle(circle) = &entity else { + panic!("expected Circle, got {entity:?}"); + }; + assert_eq!(circle.common.layer, "Walls"); + assert_eq!(circle.center, Vector3::new(1.0, 2.0, 0.0)); + assert_eq!(circle.radius, 5.0); + + let round_tripped = entity_to_dict(vm, &entity).expect("entity_to_dict"); + let round_tripped = round_tripped + .try_into_value::(vm) + .unwrap(); + assert_eq!( + round_tripped + .get_item("kind", vm) + .unwrap() + .try_into_value::(vm) + .unwrap(), + "Circle" + ); + assert_eq!( + round_tripped + .get_item("radius", vm) + .unwrap() + .try_into_value::(vm) + .unwrap(), + 5.0 + ); + }); + } + + #[test] + fn add_rejects_missing_required_geometry_instead_of_defaulting_to_zero() { + with_vm(|vm| { + // Phase 1 review feedback: a Circle with no center/radius must + // error, not silently become a zero-radius circle at the origin. + let dict = vm.ctx.new_dict(); + dict.set_item("kind", vm.new_pyobj("Circle"), vm).unwrap(); + let err = dict_to_entity(&dict, vm).expect_err("missing radius/center must error"); + let message = err.args().as_slice()[0] + .clone() + .try_into_value::(vm) + .unwrap(); + assert!( + message.contains("requires"), + "unexpected message: {message}" + ); + + let dict = vm.ctx.new_dict(); + dict.set_item("kind", vm.new_pyobj("Polyline2D"), vm).unwrap(); + let err = dict_to_entity(&dict, vm).expect_err("missing vertices must error"); + let message = err.args().as_slice()[0] + .clone() + .try_into_value::(vm) + .unwrap(); + assert!( + message.contains("at least one"), + "unexpected message: {message}" + ); + + // An explicitly empty vertex list is exactly as invalid as an + // absent one — both parse to the same empty Vec. + let dict = vm.ctx.new_dict(); + dict.set_item("kind", vm.new_pyobj("Polyline2D"), vm).unwrap(); + dict.set_item("vertices", vm.ctx.new_list(vec![]).into(), vm) + .unwrap(); + assert!(dict_to_entity(&dict, vm).is_err()); + }); + } + + #[test] + fn legacy_polyline_creation_is_refused_with_a_hint() { + with_vm(|vm| { + let dict = vm.ctx.new_dict(); + dict.set_item("kind", vm.new_pyobj("Polyline"), vm).unwrap(); + let err = dict_to_entity(&dict, vm).expect_err("legacy Polyline is update-only"); + let message = err.args().as_slice()[0] + .clone() + .try_into_value::(vm) + .unwrap(); + assert!(message.contains("Polyline2D or Polyline3D"), "{message}"); + }); + } + + #[test] + fn polyline_flags_override_exposes_closed_as_a_plain_bool() { + with_vm(|vm| { + let dict = vm.ctx.new_dict(); + dict.set_item("kind", vm.new_pyobj("Polyline2D"), vm).unwrap(); + dict.set_item("closed", vm.new_pyobj(true), vm).unwrap(); + let vertex = vm.ctx.new_dict(); + let location = vm.ctx.new_dict(); + location.set_item("x", vm.new_pyobj(0.0), vm).unwrap(); + location.set_item("y", vm.new_pyobj(0.0), vm).unwrap(); + location.set_item("z", vm.new_pyobj(0.0), vm).unwrap(); + vertex.set_item("location", location.into(), vm).unwrap(); + dict.set_item("vertices", vm.ctx.new_list(vec![vertex.into()]).into(), vm) + .unwrap(); + + let entity = dict_to_entity(&dict, vm).expect("dict_to_entity"); + let acadrust::EntityType::Polyline2D(polyline) = &entity else { + panic!("expected Polyline2D, got {entity:?}"); + }; + assert!(polyline.flags.is_closed()); + assert_eq!(polyline.vertices.len(), 1); + + let round_tripped = entity_to_dict(vm, &entity).expect("entity_to_dict"); + let round_tripped = round_tripped + .try_into_value::(vm) + .unwrap(); + assert!(round_tripped + .get_item("closed", vm) + .unwrap() + .try_into_value::(vm) + .unwrap()); + }); + } + + #[test] + fn mtext_round_trips_unit_enum_and_excluded_fields() { + with_vm(|vm| { + let dict = vm.ctx.new_dict(); + dict.set_item("kind", vm.new_pyobj("MText"), vm).unwrap(); + dict.set_item("text", vm.new_pyobj("hello"), vm).unwrap(); + set_vector3(vm, &dict, "insertion", 0.0, 0.0, 0.0); + dict.set_item("attachment_point", vm.new_pyobj("MiddleCenter"), vm) + .unwrap(); + + let entity = dict_to_entity(&dict, vm).expect("dict_to_entity"); + let acadrust::EntityType::MText(text) = &entity else { + panic!("expected MText, got {entity:?}"); + }; + assert_eq!(text.value, "hello"); + assert_eq!( + text.attachment_point, + acadrust::entities::AttachmentPoint::MiddleCenter + ); + + let round_tripped = entity_to_dict(vm, &entity).expect("entity_to_dict"); + let round_tripped = round_tripped + .try_into_value::(vm) + .unwrap(); + assert_eq!( + round_tripped + .get_item("attachment_point", vm) + .unwrap() + .try_into_value::(vm) + .unwrap(), + "MiddleCenter" + ); + assert!(round_tripped + .get_item_opt("background_color", vm) + .unwrap() + .is_none()); + }); + } + + #[test] + fn update_preserves_fields_the_dict_does_not_mention() { + with_vm(|vm| { + let add_dict = vm.ctx.new_dict(); + add_dict.set_item("kind", vm.new_pyobj("Line"), vm).unwrap(); + add_dict + .set_item("layer", vm.new_pyobj("Geometry"), vm) + .unwrap(); + set_vector3(vm, &add_dict, "start", 0.0, 0.0, 0.0); + set_vector3(vm, &add_dict, "end", 10.0, 0.0, 0.0); + let existing = dict_to_entity(&add_dict, vm).expect("dict_to_entity"); + + // Only "end" is mentioned — "start" and "layer" must survive + // untouched, unlike a fresh `dict_to_entity` from this same + // partial dict (which would reset them to their type defaults). + let update_dict = vm.ctx.new_dict(); + set_vector3(vm, &update_dict, "end", 20.0, 5.0, 0.0); + let updated = apply_dict_to_entity(&existing, &update_dict, vm) + .expect("apply_dict_to_entity"); + + let acadrust::EntityType::Line(line) = &updated else { + panic!("expected Line, got {updated:?}"); + }; + assert_eq!(line.common.layer, "Geometry", "layer must be preserved"); + assert_eq!( + line.start, + Vector3::new(0.0, 0.0, 0.0), + "start must be preserved" + ); + assert_eq!( + line.end, + Vector3::new(20.0, 5.0, 0.0), + "end must be updated" + ); + + // A second update, this time touching the layer, must not + // disturb the geometry it doesn't mention either. + let update_dict = vm.ctx.new_dict(); + update_dict + .set_item("layer", vm.new_pyobj("Renamed"), vm) + .unwrap(); + let updated = + apply_dict_to_entity(&updated, &update_dict, vm).expect("apply_dict_to_entity"); + let acadrust::EntityType::Line(line) = &updated else { + panic!("expected Line, got {updated:?}"); + }; + assert_eq!(line.common.layer, "Renamed"); + assert_eq!(line.start, Vector3::new(0.0, 0.0, 0.0)); + assert_eq!(line.end, Vector3::new(20.0, 5.0, 0.0)); + }); + } + + /// The concrete Phase 1 acceptance test: create, read, + /// edit, delete, save, and reopen a Line and an Ellipse, with common + /// properties preserved — exercised against a real + /// `acadrust::CadDocument` and a real DWG round trip (not a fake + /// `HostApi`; `dict_to_entity`/`apply_dict_to_entity`/`entity_to_dict` + /// are exactly what a live `HostApi`-backed `ocs.add`/`ocs.update`/ + /// `ocs.get_entity` call into, so this covers the same logic without + /// needing a from-scratch test double for the whole `HostApi` trait). + #[test] + fn line_and_ellipse_survive_create_read_edit_delete_save_reopen() { + with_vm(|vm| { + let mut doc = acadrust::CadDocument::new(); + // A layer must exist in the document's layer table before an + // entity can reference it — same requirement `set_entity_layer` + // above already enforces for a live document. Without this, + // the DWG writer silently falls back the entity to layer "0" + // instead of erroring, which this test caught the first time + // around (it used "Geometry" without registering it first). + doc.layers.add(acadrust::Layer::new("Geometry")).unwrap(); + + // --- CREATE --- + let add_line = vm.ctx.new_dict(); + add_line.set_item("kind", vm.new_pyobj("Line"), vm).unwrap(); + add_line + .set_item("layer", vm.new_pyobj("Geometry"), vm) + .unwrap(); + set_vector3(vm, &add_line, "start", 0.0, 0.0, 0.0); + set_vector3(vm, &add_line, "end", 10.0, 5.0, 0.0); + let line_handle = doc + .add_entity(dict_to_entity(&add_line, vm).expect("dict_to_entity Line")) + .expect("doc.add_entity Line"); + + let add_ellipse = vm.ctx.new_dict(); + add_ellipse + .set_item("kind", vm.new_pyobj("Ellipse"), vm) + .unwrap(); + add_ellipse + .set_item("layer", vm.new_pyobj("Geometry"), vm) + .unwrap(); + set_vector3(vm, &add_ellipse, "center", 1.0, 2.0, 0.0); + set_vector3(vm, &add_ellipse, "major_axis", 5.0, 0.0, 0.0); + add_ellipse + .set_item("minor_axis_ratio", vm.new_pyobj(0.5), vm) + .unwrap(); + let ellipse_handle = doc + .add_entity(dict_to_entity(&add_ellipse, vm).expect("dict_to_entity Ellipse")) + .expect("doc.add_entity Ellipse"); + + // --- READ --- + let line_dict = + entity_to_dict(vm, doc.get_entity(line_handle).expect("line exists")) + .expect("entity_to_dict Line"); + assert_eq!( + line_dict + .clone() + .try_into_value::(vm) + .unwrap() + .get_item("layer", vm) + .unwrap() + .try_into_value::(vm) + .unwrap(), + "Geometry" + ); + + // --- EDIT (update-merge: only "end" changes) --- + let existing = doc.get_entity(line_handle).unwrap().clone(); + let edit_dict = vm.ctx.new_dict(); + set_vector3(vm, &edit_dict, "end", 20.0, 5.0, 0.0); + let updated = apply_dict_to_entity(&existing, &edit_dict, vm) + .expect("apply_dict_to_entity Line"); + *doc.get_entity_mut(line_handle).unwrap() = updated; + + let acadrust::EntityType::Line(line) = doc.get_entity(line_handle).unwrap() else { + panic!("expected Line"); + }; + assert_eq!( + line.common.layer, "Geometry", + "layer survives an unrelated edit" + ); + assert_eq!( + line.start, + Vector3::new(0.0, 0.0, 0.0), + "start survives an unrelated edit" + ); + assert_eq!( + line.end, + Vector3::new(20.0, 5.0, 0.0), + "end reflects the edit" + ); + + // --- DELETE --- + assert!(doc.remove_entity(ellipse_handle).is_some()); + assert!(doc.get_entity(ellipse_handle).is_none()); + + // --- SAVE / REOPEN --- + let path = std::env::temp_dir().join(format!( + "ocs_entity_crud_acceptance_test_{}.dwg", + std::process::id() + )); + acadrust::DwgWriter::write_to_file(&path, &doc).expect("write dwg"); + let reopened = acadrust::DwgReader::from_file(&path) + .expect("open dwg") + .read() + .expect("read dwg"); + let _ = std::fs::remove_file(&path); + + let acadrust::EntityType::Line(reopened_line) = reopened + .get_entity(line_handle) + .expect("line survives save/reopen") + else { + panic!("expected Line"); + }; + assert_eq!( + reopened_line.common.layer, "Geometry", + "layer survives a real DWG round trip" + ); + assert_eq!( + reopened_line.start, + Vector3::new(0.0, 0.0, 0.0), + "start survives a real DWG round trip" + ); + assert_eq!( + reopened_line.end, + Vector3::new(20.0, 5.0, 0.0), + "the edit survives a real DWG round trip" + ); + assert!( + reopened.get_entity(ellipse_handle).is_none(), + "the deleted ellipse must not reappear after reopen" + ); + }); + } + } +} + +/// These run a real RustPython interpreter with the `ocs` module registered +/// (same setup as `lib.rs`'s `run_eval`), but with no `HostGuard` active — so +/// `host_ctx::with_host` always returns `None`. `add_points` and +/// `write_record` both finish all of their own argument/dict parsing +/// *before* calling `host_ctx::with_host`, so getting exactly the sentinel +/// "not running inside a PY_ command" `RuntimeError` (rather than a +/// `TypeError`/`ValueError` from the parsing itself) confirms the new +/// RustPython glue code — `Vec` extraction, `PyDictRef` +/// downcasting, `try_into_value` for every XDATA value kind — actually works +/// against the real interpreter, without needing a live `HostApi`. +#[cfg(test)] +mod tests { + use super::module_def; + + #[cfg(feature = "experimental-host-model")] + #[test] + fn host_coverage_matches_generated_python_keys() { + use ocs_plugin_api::entity_coverage::{EntityCoverageCatalog, ModelAccess}; + use std::collections::{BTreeMap, BTreeSet}; + + let catalog: EntityCoverageCatalog = + serde_json::from_str(ocs_plugin_api::get_embedded_entity_coverage_json()).unwrap(); + let generated = include_str!(concat!(env!("OUT_DIR"), "/entity_crud.rs")); + let converter = generated + .split("pub(crate) fn entity_to_dict") + .nth(1) + .unwrap() + .split("pub(crate) fn dict_to_entity") + .next() + .unwrap(); + let mut emitted: BTreeMap> = BTreeMap::new(); + let mut current = None; + for line in converter.lines() { + let line = line.trim(); + if let Some(arm) = line.strip_prefix("acadrust::EntityType::") { + if let Some((kind, _)) = arm.split_once("(value) => {") { + current = Some(kind.to_owned()); + emitted.entry(kind.to_owned()).or_default(); + } + } + if let (Some(kind), Some(key)) = (¤t, line.strip_prefix("dict.set_item(\"")) { + let key = key.split('"').next().unwrap(); + emitted.get_mut(kind).unwrap().insert(key.to_owned()); + } + if line == "}" { + current = None; + } + } + // Hand-written kinds: keys come from the include file's dict writes + // and per-subtype field table. + let manual = include_str!("dimension_model.rs.inc"); + let dimension_keys = emitted.entry("Dimension".to_owned()).or_default(); + for line in manual.lines() { + let line = line.trim(); + let key = line + .strip_prefix("dict.set_item(\"") + .or_else(|| { + (line.contains("\", V(") || line.contains("\", F(") || line.contains("\", B(")) + .then(|| line.strip_prefix("(\"")) + .flatten() + }); + if let Some(key) = key { + dimension_keys.insert(key.split('"').next().unwrap().to_owned()); + } + } + for entry in &catalog.entity_kinds { + let expected: BTreeSet<_> = entry + .properties + .iter() + .filter(|property| property.model_access != ModelAccess::Unmapped) + .map(|property| property.name.clone()) + .collect(); + if let Some(actual) = emitted.get(&entry.kind) { + assert_eq!(&expected, actual, "{}", entry.kind); + } else { + assert_eq!( + expected, + BTreeSet::from([ + "handle".to_owned(), + "kind".to_owned(), + "layer".to_owned(), + "owner_handle".to_owned() + ]), + "{}", + entry.kind + ); + } + } + assert_eq!(emitted.len(), 43); + } + + #[cfg(feature = "experimental-host-model")] + #[test] + fn entity_snapshot_uses_catalog_variant_name() { + use ocs_plugin_api::host::acadrust; + let hatch = acadrust::EntityType::Hatch(acadrust::entities::Hatch::default()); + let value = serde_json::to_value(hatch).unwrap(); + let kind = value.as_object().unwrap().keys().next().unwrap(); + assert_eq!(kind, "Hatch"); + assert_ne!(kind, "HATCH"); + } + + #[cfg(feature = "experimental-host-model")] + #[test] + fn python_can_query_host_coverage_without_document() { + assert_eq!(eval("len(ocs.entity_kinds())"), Ok("48".to_owned())); + assert_eq!( + eval("'paths' in ocs.entity_coverage('Hatch')['editable']"), + Ok("True".to_owned()) + ); + assert_eq!( + eval("ocs.entity_coverage('Line')['scope']"), + Ok("canvas".to_owned()) + ); + } + + #[cfg(feature = "experimental-host-model")] + #[test] + fn update_many_binding_parses_python_batch() { + let error = eval("ocs.update_many('Move', [{'handle': 1, 'layer': '0'}])").unwrap_err(); + assert!( + error.contains("not running inside a PY_ command"), + "{error}" + ); + } + + // Runs `source` (a hardcoded literal in each test below, never external + // input) as a single expression inside a fresh, stdlib-free RustPython + // interpreter with only the `ocs` module registered — the same sandboxed + // `Mode::Eval` setup `lib.rs`'s `run_eval` uses for real `PY_EVAL` + // commands. Not a generic "eval untrusted string" helper. + fn eval(source: &str) -> Result { + let builder = rustpython_vm::Interpreter::builder(Default::default()); + let ocs_def = module_def(&builder.ctx); + let interp = builder.add_native_module(ocs_def).build(); + interp.enter(|vm| { + let scope = vm.new_scope_with_builtins(); + let ocs = vm.import("ocs", 0).map_err(|exc| exc_string(vm, &exc))?; + scope + .globals + .set_item("ocs", ocs, vm) + .map_err(|exc| exc_string(vm, &exc))?; + let code_obj = vm + .compile( + source, + rustpython_vm::compiler::Mode::Eval, + "".to_string(), + ) + .map_err(|err| err.to_string())?; + match vm.run_code_obj(code_obj, scope) { + Ok(result) => result + .str(vm) + .map(|s| s.to_string()) + .map_err(|exc| exc_string(vm, &exc)), + Err(exc) => Err(exc_string(vm, &exc)), + } + }) + } + + fn exc_string( + vm: &rustpython_vm::VirtualMachine, + exc: &rustpython_vm::builtins::PyBaseExceptionRef, + ) -> String { + let obj: rustpython_vm::PyObjectRef = exc.clone().into(); + let name = obj.class().name().to_string(); + let message = obj.str(vm).map(|s| s.to_string()).unwrap_or_default(); + format!("{name}: {message}") + } + + const NO_HOST: &str = "RuntimeError: ocs: not running inside a PY_ command"; + + #[test] + fn add_points_parses_before_reaching_the_host() { + let result = eval("ocs.add_points([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]])"); + assert_eq!(result, Err(NO_HOST.to_string())); + } + + #[test] + fn add_points_rejects_wrong_point_shape() { + let result = eval("ocs.add_points([[1.0, 2.0]])"); + assert!(matches!(result, Err(ref msg) if msg.starts_with("ValueError: ocs.add_points"))); + } + + #[test] + fn add_points_rejects_empty_list() { + let result = eval("ocs.add_points([])"); + assert!(matches!(result, Err(ref msg) if msg.starts_with("ValueError: ocs.add_points"))); + } + + #[test] + fn write_record_parses_every_xdata_kind_before_reaching_the_host() { + let result = eval( + r#"ocs.write_record(1, "TEST", [ + {"kind": "String", "value": "hi"}, + {"kind": "ControlString", "value": "{"}, + {"kind": "LayerName", "value": "0"}, + {"kind": "BinaryData", "value": b"\x01\x02"}, + {"kind": "Handle", "value": 7}, + {"kind": "Point3D", "value": [1.0, 2.0, 3.0]}, + {"kind": "Position3D", "value": [1.0, 2.0, 3.0]}, + {"kind": "Displacement3D", "value": [1.0, 2.0, 3.0]}, + {"kind": "Direction3D", "value": [1.0, 2.0, 3.0]}, + {"kind": "Real", "value": 1.5}, + {"kind": "Distance", "value": 2.5}, + {"kind": "ScaleFactor", "value": 1.0}, + {"kind": "Integer16", "value": 16}, + {"kind": "Integer32", "value": 32}, + ])"#, + ); + assert_eq!(result, Err(NO_HOST.to_string())); + } + + #[test] + fn write_record_rejects_unknown_kind() { + let result = eval(r#"ocs.write_record(1, "TEST", [{"kind": "Nope", "value": 1}])"#); + assert!(matches!( + result, + Err(ref msg) if msg.starts_with("ValueError: unsupported XDATA value kind: Nope") + )); + } + + #[test] + fn write_record_rejects_malformed_point_value() { + let result = + eval(r#"ocs.write_record(1, "TEST", [{"kind": "Point3D", "value": [1.0, 2.0]}])"#); + assert!(matches!( + result, + Err(ref msg) if msg.starts_with("ValueError: XDATA Point3D") + )); + } + + #[test] + fn read_record_and_remove_record_parse_before_reaching_the_host() { + assert_eq!( + eval(r#"ocs.read_record(1, "TEST")"#), + Err(NO_HOST.to_string()) + ); + assert_eq!( + eval(r#"ocs.remove_record(1, "TEST")"#), + Err(NO_HOST.to_string()) + ); + } + + #[test] + fn add_parses_before_reaching_the_host() { + let result = eval( + r#"ocs.add({"kind": "Line", "start": {"x": 0.0, "y": 0.0, "z": 0.0}, "end": {"x": 1.0, "y": 1.0, "z": 0.0}})"#, + ); + assert_eq!(result, Err(NO_HOST.to_string())); + } + + #[test] + fn add_rejects_missing_required_geometry_before_reaching_the_host() { + let result = eval(r#"ocs.add({"kind": "Circle"})"#); + assert!( + matches!(result, Err(ref msg) if msg.starts_with("ValueError: ocs.add: Circle requires")), + "unexpected result: {result:?}" + ); + } + + #[test] + fn add_rejects_unknown_kind() { + let result = eval(r#"ocs.add({"kind": "Nope"})"#); + assert!(matches!( + result, + Err(ref msg) if msg.starts_with("ValueError: ocs: unsupported entity kind: Nope") + )); + } + + #[test] + fn update_and_get_entity_hit_the_host_sentinel() { + assert_eq!( + eval(r#"ocs.update(1, {"layer": "X"})"#), + Err(NO_HOST.to_string()) + ); + assert_eq!(eval("ocs.get_entity(1)"), Err(NO_HOST.to_string())); + } + + #[test] + fn add_text_left_validates_before_reaching_host() { + assert_eq!( + eval(r#"ocs.add_text_left("Note", 0.0, 0.0, 0.0, 2.0, 0.0)"#), + Err(NO_HOST.to_string()) + ); + let bad = eval(r#"ocs.add_text_left("Note", 0.0, 0.0, 0.0, 0.0, 0.0)"#); + assert!(matches!(bad, Err(ref msg) if msg.starts_with("ValueError: ocs.add_text_left:"))); + } +} diff --git a/plugins/opencad-python/src/patch_helpers.rs.inc b/plugins/opencad-python/src/patch_helpers.rs.inc new file mode 100644 index 000000000..21a054803 --- /dev/null +++ b/plugins/opencad-python/src/patch_helpers.rs.inc @@ -0,0 +1,23 @@ +// Merge helpers for nested structs that scripts edit as a partial patch. + +/// Apply `patch` (a dict of context fields) onto `existing`, so a script can +/// change one context field without resetting the rest. Unknown keys error. +fn merge_multileader_context( + existing: &acadrust::entities::MultiLeaderAnnotContext, + patch: PyObjectRef, + vm: &VirtualMachine, +) -> PyResult { + let patch = patch.try_into_value::(vm)?; + let merged = multi_leader_annot_context_to_dict(vm, existing)? + .try_into_value::(vm)?; + for key in patch.keys_vec() { + let name = key.clone().try_into_value::(vm)?; + if merged.get_item_opt(name.as_str(), vm)?.is_none() { + return Err(vm.new_value_error(format!( + "ocs: MultiLeader.context property {name:?} is outside the editable schema" + ))); + } + merged.set_item(name.as_str(), patch.get_item(name.as_str(), vm)?, vm)?; + } + dict_to_multi_leader_annot_context(merged.into(), vm) +} diff --git a/plugins/opencad-python/src/selection_cache.rs b/plugins/opencad-python/src/selection_cache.rs new file mode 100644 index 000000000..8a6313462 --- /dev/null +++ b/plugins/opencad-python/src/selection_cache.rs @@ -0,0 +1,33 @@ +//! Process-wide cache of the most recently observed selection. +//! +//! `HostApi` has no synchronous "give me the current selection" call — only +//! the best-effort `SelectionChanged` / `SelectionChangedV4` notification. +//! Naively calling `HostApi::try_recv_notification()` from inside `dispatch` +//! doesn't work: `ocs_plugin_api::runner`'s own V4 event loop +//! (`run_v4` in `runner.rs`) drains that queue itself on every iteration, +//! forwarding each notification to `BuiltinPlugin::on_notification` *before* +//! a `Dispatch` request ever reaches `dispatch()` — so by the time `dispatch` +//! runs, the queue is always empty. The only way to observe notifications is +//! to override `on_notification` and stash what's needed here; `dispatch` +//! (and the `ocs.selection()` pyfunction) then just reads this cache. +//! +//! A plain process-wide static is fine here: one plugin process hosts exactly +//! one `PythonPlugin`, so this is equivalent to an instance field, but a +//! static is far simpler to reach from the `#[pymodule]` native functions in +//! `ocs_module.rs`, which have no path back to the `BuiltinPlugin` instance. + +use std::sync::Mutex; + +use ocs_plugin_api::host::Handle; + +static CACHE: Mutex> = Mutex::new(Vec::new()); + +pub(crate) fn set(handles: Vec) { + if let Ok(mut cache) = CACHE.lock() { + *cache = handles; + } +} + +pub(crate) fn get() -> Vec { + CACHE.lock().map(|cache| cache.clone()).unwrap_or_default() +} diff --git a/plugins/opencad-python/tests/test_document_model.py b/plugins/opencad-python/tests/test_document_model.py new file mode 100644 index 000000000..881014d83 --- /dev/null +++ b/plugins/opencad-python/tests/test_document_model.py @@ -0,0 +1,310 @@ +import pathlib +import types +import unittest + + +MODEL = pathlib.Path(__file__).resolve().parents[1] / "src" / "document_model.py" + + +class DocumentModelTests(unittest.TestCase): + def setUp(self): + self.records = { + 1: {"handle": 1, "kind": "Line", "layer": "0", "start": {"x": 1, "y": 2, "z": 3}, + "end": {"x": 4, "y": 5, "z": 6}, "_editable": True}, + 2: {"handle": 2, "kind": "Hatch", "layer": "0", "_editable": False}, + } + self.calls = [] + self.ocs = types.SimpleNamespace( + entity_descriptor=lambda handle: self.records.get(handle), + entity_coverage=lambda kind: {"kind": kind, "scope": "canvas", + "editable": ["start", "end", "layer"] if kind == "Line" else ["layer"], + "readable": ["handle", "kind", "layer"], "unmapped": []}, + entity_kinds=lambda: ["Line", "Hatch"], + entity_snapshot=lambda handle: {self.records[handle]["kind"]: { + "common": {"layer": self.records[handle]["layer"]}, + "pattern_name": "SOLID" if handle == 2 else None, + }} if handle in self.records else None, + entity_handles=lambda: list(self.records), + update_many=lambda label, updates: self.calls.append((label, updates)), + add=lambda entity: self.calls.append(("add", entity)) or 1, + remove_entity=lambda handle: self.calls.append(("remove", handle)), + selection=lambda: [1], + select=lambda handles: self.calls.append(("selection", handles)), + poll_events=lambda tab: [{"type": "drawing", "tab_id": tab}], + tab_id=lambda: 7, + request_input=lambda prompt, entity: (prompt, entity), + poll_input=lambda token: {"status": "point", "point": [1, 2, 3]}, + layer_records=lambda: [{"name": "0", "current": True}, {"name": "Walls", "current": False}], + layer_operation=lambda op, name, options: self.calls.append((op, name, options)) or 9, + text_style_records=lambda: [{"name": n, "current": n == "Standard"} for n in ("Standard", "Title", "Notes")], + dim_style_records=lambda: [{"name": n, "current": n == "Standard"} for n in ("Standard", "Metric")], + style_operation=lambda kind, op, name, options: self.calls.append((kind, op, name, options)) or 9, + block_records=lambda: [{"name": n, "entities": []} for n in ("Widget", "Gadget")], + block_operation=lambda op, name, options: self.calls.append(("block", op, name, options)) or 9, + add_to_block=lambda block, entity: self.calls.append(("add_to_block", block, entity)) or 1, + linetype_records=lambda: [{"name": n} for n in ("Continuous", "Bracket")], + linetype_operation=lambda op, name, options: self.calls.append(("linetype", op, name, options)) or 9, + layout_records=lambda: [{"name": n, "current": n == "Model"} for n in ("Model", "Sheet1")], + layout_operation=lambda op, name, options: self.calls.append(("layout", op, name, options)) or 9, + command_step=self._command_step, + curve_samples=lambda handle, n: {"vertices": [[0, 0], [1, 0], [2, 0]], "elevation": 5.0, "closed": False}, + ) + namespace = {"ocs": self.ocs} + exec(MODEL.read_text(), namespace) + self.doc = self.ocs.active_document + + def _command_step(self, kind, options): + self.calls.append(("step", kind, options)) + outcome = {"status": "completed", "blocked_by": None, "command": "", "prompt": "", "accepts": [], + "options": [], "entities": 0, "added": 0, "unconsumed": [], "error": None} + outcome.update(self.step_outcome.get(kind, {})) + if kind == "token" and options.get("text") == "X": + outcome["status"] = "completed" + if kind in ("enter", "entity"): + new = max(self.records) + 1 + self.records[new] = {"handle": new, "kind": "Line", "layer": "0", "_editable": True} + return outcome + + def test_command_runner(self): + self.step_outcome = {} + out = self.doc.command("CIRCLE 0,0 5") + self.assertEqual(self.calls[-1], ("step", "run", {"line": "CIRCLE 0,0 5"})) + self.assertEqual(out["status"], "completed") + self.step_outcome = {"run": {"status": "waiting_input", "prompt": "Select object"}} + with self.assertRaises(RuntimeError): + self.doc.command("OFFSET") + self.assertEqual(self.calls[-1], ("step", "cancel", None)) + self.step_outcome = {"run": {"error": "bad"}} + with self.assertRaises(RuntimeError): + self.doc.command("X") + waiting = {"status": "waiting_input"} + self.step_outcome = {"start": waiting, "point": waiting, "entity": waiting} + with self.doc.start_command("OFFSET") as command: + command.point((1, 2)) + self.assertEqual(self.calls[-1], ("step", "point", {"point": [1.0, 2.0, 0.0]})) + command.entity(self.doc.entities[1], at=(0, 0, 0)) + self.assertEqual(self.calls[-1][2]["handle"], 1) + self.assertEqual(self.calls[-1], ("step", "cancel", None)) + + def test_modify_wrappers(self): + self.step_outcome = {} + self.doc.modify.trim(1, (3, 0, 0)) + steps = [c for c in self.calls if c[0] == "step"] + self.assertEqual([c[1] for c in steps], ["start", "entity", "enter"]) + self.calls.clear() + self.doc.modify.move([1, 2], (0, 0, 0), (5, 5, 0)) + self.assertEqual(self.calls[0], ("selection", [1, 2])) + self.assertEqual([c[1] for c in self.calls if c[0] == "step"], ["start", "point", "point"]) + self.calls.clear() + self.doc.modify.offset(1, 2, (0, 5)) + entity_step = [c for c in self.calls if c[0] == "step" and c[1] == "entity"][0] + self.assertEqual(entity_step[2]["point"], [1, 0, 5.0]) + + def test_more_modify_wrappers(self): + self.step_outcome = {} + self.doc.modify.chamfer(1, 2, 2, 3, at1=(0, 0, 0), at2=(1, 1, 0)) + texts = [c[2]["text"] for c in self.calls if c[0] == "step" and c[1] == "text"] + self.assertEqual(texts, ["2", "3"]) + self.calls.clear() + self.doc.modify.chamfer(1, 2, 4, at1=(0, 0, 0), at2=(1, 1, 0)) + texts = [c[2]["text"] for c in self.calls if c[0] == "step" and c[1] == "text"] + self.assertEqual(texts, ["4", "4"]) + self.calls.clear() + self.doc.modify.array_rect([1], 2, 3, 10, 20) + self.assertEqual([c[2]["text"] for c in self.calls if c[0] == "step" and c[1] == "text"], ["2", "3", "10", "20"]) + self.calls.clear() + self.doc.modify.stretch((0, 0), (1, 1), (0, 0), (5, 0)) + self.assertEqual([c[1] for c in self.calls if c[0] == "step"], ["start", "point", "point", "enter", "point", "point"]) + + def test_pedit_and_array_wrappers(self): + self.step_outcome = {"start": {"status": "waiting_input"}, "entity": {"status": "waiting_input", "prompt": "Turn it into one? [Yes/No]"}, + "token": {"status": "waiting_input"}} + self.doc.modify.polyline_close(1) + tokens = [c[2]["text"] for c in self.calls if c[0] == "step" and c[1] == "token"] + self.assertEqual(tokens, ["Y", "C", "X"]) + self.calls.clear() + self.step_outcome = {} + self.doc.modify.array_3d([1], 2, 2, 2, 10, 20, 30) + self.assertEqual([c[2]["text"] for c in self.calls if c[0] == "step" and c[1] == "text"], ["2", "2", "2", "10", "20", "30"]) + self.calls.clear() + self.doc.modify.array_path([1], 2, 5, at=(0, 0, 0)) + self.assertEqual([c[1] for c in self.calls if c[0] == "step"], ["start", "entity", "text"]) + + def test_batch_edit_and_rollback(self): + line = self.doc.entities[1] + self.assertEqual(line.start, (1, 2, 3)) + with self.doc.transaction("Move line"): + line.start = (0, 0, 0) + line.end = (10, 0, 0) + self.assertEqual(line.end, (10, 0, 0)) + self.assertEqual(self.calls, [("Move line", [{"handle": 1, + "start": {"x": 0, "y": 0, "z": 0}, "end": {"x": 10, "y": 0, "z": 0}}])]) + with self.assertRaises(ValueError): + with self.doc.transaction("Abort"): + line.layer = "A" + raise ValueError() + self.assertEqual(len(self.calls), 1) + + def test_layers_lookup_and_options(self): + layers = self.doc.layers + self.assertIn("WALLS", layers) + self.assertEqual(layers["walls"]["name"], "Walls") + self.assertEqual(layers.current["name"], "0") + self.assertEqual(layers.names(), ["0", "Walls"]) + with self.assertRaises(KeyError): + layers["Nope"] + layers.modify("Walls", color=3, linetype=None, off=False) + self.assertEqual(self.calls[-1], ("modify", "Walls", {"color": 3, "off": False})) + layers.delete("Walls", erase_objects=1) + self.assertEqual(self.calls[-1], ("delete", "Walls", {"erase_objects": True})) + with self.assertRaises(TypeError): + layers.create("New", colour=1) + self.assertEqual(len(self.calls), 2) + + def test_text_and_dim_styles(self): + text, dim = self.doc.text_styles, self.doc.dim_styles + self.assertEqual(text.current["name"], "Standard") + self.assertEqual(text["TITLE"]["name"], "Title") + text.create("Notes", height=2.5, font=None, oblique=10) + self.assertEqual(self.calls[-1], ("text", "create", "Notes", {"height": 2.5, "oblique": 10})) + with self.assertRaises(TypeError): + text.create("Bad", true_type_font="Arial") + with self.assertRaises(TypeError): + text.modify("Title", fnt="x") + dim.create("Metric", copy_from="Standard", dimscale=2) + self.assertEqual(self.calls[-1], ("dim", "create", "Metric", {"dimscale": 2, "copy_from": "Standard"})) + dim.modify("Standard", dimtxt=3) + self.assertEqual(self.calls[-1], ("dim", "modify", "Standard", {"dimtxt": 3})) + dim.delete("Metric") + self.assertEqual(self.calls[-1], ("dim", "delete", "Metric", None)) + dim.set_current("Standard") + self.assertEqual(self.calls[-1], ("dim", "set_current", "Standard", None)) + + def test_create_entity_in_block(self): + self.doc.create_entity("Line", block="Widget", start={"x": 0, "y": 0, "z": 0}, end={"x": 1, "y": 0, "z": 0}) + self.assertEqual(self.calls[-1][:2], ("add_to_block", "Widget")) + self.assertEqual(self.calls[-1][2]["kind"], "Line") + self.assertNotIn("block", self.calls[-1][2]) + self.doc.create_entity("Line", start={"x": 0, "y": 0, "z": 0}, end={"x": 1, "y": 0, "z": 0}) + self.assertEqual(self.calls[-1][0], "add") + + def test_linetypes_and_layouts(self): + lt, ly = self.doc.linetypes, self.doc.layouts + self.assertIn("bracket", lt) + lt.create("Bracket", [12, -3], "test") + self.assertEqual(self.calls[-1], ("linetype", "create", "Bracket", {"pattern": [12.0, -3.0], "description": "test"})) + lt.modify("Bracket", pattern=[1, -1]) + self.assertEqual(self.calls[-1], ("linetype", "modify", "Bracket", {"pattern": [1.0, -1.0]})) + lt.rename("Bracket", "Bracket") + lt.delete("Bracket") + self.assertEqual(self.calls[-1], ("linetype", "delete", "Bracket", None)) + self.assertEqual(ly.current["name"], "Model") + ly.create("Sheet1") + ly.set_page("Sheet1", paper_size=(420, 297), rotation=0, scale=(1, 50)) + self.assertEqual(self.calls[-1], ("layout", "set_page", "Sheet1", + {"paper_size": [420.0, 297.0], "rotation": 0, "scale": [1.0, 50.0]})) + ly.current = "Sheet1" + self.assertEqual(self.calls[-1], ("layout", "set_current", "Sheet1", None)) + ly.delete("Sheet1") + self.assertEqual(self.calls[-1], ("layout", "delete", "Sheet1", None)) + with self.assertRaises(KeyError): + ly["Nope"] + + def test_blocks(self): + blocks = self.doc.blocks + self.assertIn("WIDGET", blocks) + self.assertEqual(blocks.names(), ["Widget", "Gadget"]) + blocks.create("Gadget", [self.doc.entities[1], 2], base_point=(1, 2, 3), erase_originals=True) + self.assertEqual(self.calls[-1], ("block", "create", "Gadget", + {"entities": [1, 2], "base_point": [1.0, 2.0, 3.0], "erase_originals": True})) + blocks.modify("Widget", explodable=False) + self.assertEqual(self.calls[-1], ("block", "modify", "Widget", {"explodable": False})) + blocks.rename("Widget", "Gadget") + self.assertEqual(self.calls[-1], ("block", "rename", "Widget", {"to": "Gadget"})) + blocks.delete("Widget") + self.assertEqual(self.calls[-1], ("block", "delete", "Widget", None)) + with self.assertRaises(KeyError): + blocks["Nope"] + + def test_create_and_delete_use_document_model(self): + line = self.doc.create_entity("Line", start={"x": 0, "y": 0, "z": 0}, + end={"x": 10, "y": 0, "z": 0}) + self.assertEqual(line.handle, 1) + self.assertEqual(self.calls[-1], ("add", {"kind": "Line", + "start": {"x": 0, "y": 0, "z": 0}, "end": {"x": 10, "y": 0, "z": 0}})) + self.doc.delete_entity(line) + self.assertEqual(self.calls[-1], ("remove", 1)) + with self.assertRaises(ValueError): + self.doc.create_entity("Line", handle=4) + + def test_create_entity_accepts_coordinate_tuples_for_point_properties(self): + self.ocs.entity_coverage = lambda kind: {"kind": kind, "scope": "canvas", + "editable": [], "readable": [], "unmapped": [], "properties": [ + {"name": "start", "type": "Vector3", "sequence": False}, + {"name": "end", "type": "Vector3", "sequence": False}, + {"name": "corners", "type": "Vector2", "sequence": True}, + {"name": "knots", "type": "f64", "sequence": True}]} + self.doc.create_entity("Line", start=(0, 0, 0), end=(10, 5, 0), + corners=[(1, 2), (3, 4)], knots=[0, 0, 1]) + self.assertEqual(self.calls[-1], ("add", {"kind": "Line", + "start": {"x": 0.0, "y": 0.0, "z": 0.0}, "end": {"x": 10.0, "y": 5.0, "z": 0.0}, + "corners": [{"x": 1.0, "y": 2.0}, {"x": 3.0, "y": 4.0}], + "knots": [0, 0, 1]})) + + def test_coverage_and_read_only_entity(self): + self.assertEqual(len(self.doc.entities), 2) + self.assertEqual([entity.handle for entity in self.doc.entities], [1, 2]) + self.assertIn("start", self.doc.entities[1].coverage["editable"]) + self.assertEqual(self.doc.entities[2].coverage["editable"], ["layer"]) + self.assertEqual(set(self.doc.coverage()), {"Line", "Hatch"}) + self.assertEqual(self.doc.coverage("Hatch")["editable"], ["layer"]) + snapshot = self.doc.entities[2].snapshot + self.assertEqual(snapshot["pattern_name"], "SOLID") + snapshot["pattern_name"] = "OTHER" + self.assertEqual(self.doc.entities[2].snapshot["pattern_name"], "SOLID") + with self.doc.transaction("Move hatch"): + self.doc.entities[2].layer = "HATCHES" + self.assertEqual(self.calls[-1], ("Move hatch", [{"handle": 2, "layer": "HATCHES"}])) + with self.doc.transaction("Unsupported geometry"): + with self.assertRaises(AttributeError): + self.doc.entities[2].pattern_name = "OTHER" + + def test_rejected_host_transaction_leaves_python_model_ready(self): + def reject(label, updates): + raise ValueError("Line.start must contain finite coordinates") + self.ocs.update_many = reject + with self.assertRaisesRegex(ValueError, "finite coordinates"): + with self.doc.transaction("Invalid"): + self.doc.entities[1].start = (float("nan"), 0, 0) + self.assertIsNone(self.doc._pending) + self.assertEqual(self.doc.entities[1].start, (1, 2, 3)) + + def test_readable_insert_identity_cannot_be_patched(self): + self.records[3] = {"handle": 3, "kind": "Insert", "layer": "0", + "block_name": "DOOR", "insert_point": {"x": 1, "y": 2, "z": 0}, + "_editable": True} + old_coverage = self.ocs.entity_coverage + self.ocs.entity_coverage = lambda kind: ( + {"kind": "Insert", "scope": "canvas", "editable": ["layer", "insert_point"], + "readable": ["kind", "handle", "layer", "block_name", "insert_point"], + "unmapped": []} if kind == "Insert" else old_coverage(kind)) + insert = self.doc.entities[3] + self.assertEqual(insert.block_name, "DOOR") + with self.doc.transaction("Move block"): + with self.assertRaises(AttributeError): + insert.block_name = "WINDOW" + insert.insert_point = (4, 5, 0) + self.assertEqual(self.calls[-1], ("Move block", [{"handle": 3, + "insert_point": {"x": 4, "y": 5, "z": 0}}])) + + def test_selection_events_and_input(self): + self.assertEqual([entity.handle for entity in self.doc.selection], [1]) + self.doc.selection = [self.doc.entities[1]] + self.assertEqual(self.calls[-1], ("selection", [1])) + self.assertEqual(self.doc.poll_events(), [{"type": "drawing", "tab_id": 7}]) + self.assertEqual(self.doc.request_entity("Pick"), ("Pick", True)) + self.assertEqual(self.doc.poll_input(10)["status"], "point") + + +if __name__ == "__main__": + unittest.main() diff --git a/plugins/opencad-python/tools/stage-bundled.sh b/plugins/opencad-python/tools/stage-bundled.sh new file mode 100755 index 000000000..65da83240 --- /dev/null +++ b/plugins/opencad-python/tools/stage-bundled.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# Build the in-tree API v7 Python plugin and stage a complete plugin package. +set -euo pipefail + +if [[ $# -lt 1 ]]; then + echo "usage: $0 [--debug] [--target ]" >&2 + exit 2 +fi + +stage_dir=$(python3 -c 'import os,sys; print(os.path.abspath(sys.argv[1]))' "$1") +shift +profile=release +target="" +while [[ $# -gt 0 ]]; do + case "$1" in + --debug) profile=debug; shift ;; + --target) + [[ $# -ge 2 ]] || { echo "--target requires a triple" >&2; exit 2; } + target=$2 + shift 2 + ;; + *) echo "unknown option: $1" >&2; exit 2 ;; + esac +done + +plugin_dir=$(cd "$(dirname "$0")/.." && pwd) +ocs_repo=$(cd "$plugin_dir/../.." && pwd) +cd "$plugin_dir" + +build_args=(build --locked --features experimental-host-model) +[[ $profile == release ]] && build_args+=(--release) +[[ -n $target ]] && build_args+=(--target "$target") +cargo "${build_args[@]}" + +case "$(uname -s)" in + Darwin) library="libopencad_python.dylib" ;; + Linux) library="libopencad_python.so" ;; + MINGW*|MSYS*|CYGWIN*) library="opencad_python.dll" ;; + *) echo "unsupported platform" >&2; exit 2 ;; +esac + +target_dir="$plugin_dir/target" +[[ -n $target ]] && target_dir="$target_dir/$target" +source_library="$target_dir/$profile/$library" +[[ -f $source_library ]] || { echo "build did not produce $source_library" >&2; exit 1; } + +mkdir -p "$stage_dir" +cp "$source_library" "$stage_dir/$library" +python3 - "$stage_dir/plugin.toml" "$(rustc --version)" "$ocs_repo/Cargo.lock" <<'PY' +from pathlib import Path +import re +import sys + +def acadrust_source(path): + packages = re.findall(r"(?ms)^\[\[package\]\]\n(.*?)(?=^\[\[package\]\]|\Z)", Path(path).read_text()) + sources = [] + for package in packages: + if re.search(r'^name = "acadrust"$', package, re.M): + match = re.search(r'^source = "([^"]+)"$', package, re.M) + if match: + sources.append(match.group(1)) + if len(sources) != 1: + raise SystemExit(f"expected one acadrust source in {path}, found {len(sources)}") + return sources[0] + +manifest = Path("plugin.toml").read_text() +if "api_version = 7" not in manifest: + raise SystemExit("bundled plugin.toml must target API v7") +manifest = manifest.replace("__RUSTC_VERSION__", sys.argv[2]) +manifest = manifest.replace("__ACADRUST_SOURCE__", acadrust_source(sys.argv[3])) +if "__" in manifest: + raise SystemExit("unresolved manifest placeholder") +Path(sys.argv[1]).write_text(manifest) +PY +echo "Staged bundled API v7 plugin in $stage_dir" diff --git a/src/app/automation.rs b/src/app/automation.rs index 48ec40066..a7b70c195 100644 --- a/src/app/automation.rs +++ b/src/app/automation.rs @@ -283,6 +283,8 @@ impl OpenCADStudio { // selection check has to run on this path too. #[cfg(not(target_arch = "wasm32"))] self.notify_plugins_selection_changed(); + #[cfg(not(target_arch = "wasm32"))] + self.notify_plugins_document_changed(); res } @@ -767,6 +769,46 @@ impl OpenCADStudio { #[cfg(test)] mod tests { + #[test] + fn clayer_command_sets_layer_used_by_new_geometry() { + let mut app = super::OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + app.automation_op(r#"{"op":"run","cmd":"LAYER NEW Annotations"}"#); + app.automation_op(r#"{"op":"run","cmd":"CLAYER Annotations"}"#); + app.automation_op(r#"{"op":"run","cmd":"LINE 0,0 10,0"}"#); + let lines = app.automation_op(r#"{"op":"query","type":"Line"}"#); + assert_eq!(lines["entities"][0]["layer"], "Annotations"); + } + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn built_in_edits_advance_plugin_document_fingerprint_once() { + let mut app = super::OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let before = app.last_plugin_document.expect("new drawing published"); + app.automation_op(r#"{"op":"run","cmd":"LINE 0,0 10,0"}"#); + let after = app.last_plugin_document.expect("line edit published"); + assert_eq!(after.0, app.tabs[app.active_tab].id); + assert_ne!(after, before); + assert_eq!(after.1, app.tabs[app.active_tab].scene.geometry_epoch); + app.automation_op(r#"{"op":"query","type":"Line"}"#); + assert_eq!(app.last_plugin_document, Some(after)); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn plugin_edit_publication_is_not_repeated_at_message_boundary() { + use acadrust::entities::Point; + use acadrust::EntityType; + + let mut app = super::OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let tab = app.active_tab; + let mut host = super::super::plugin_host::HostSession::new(&mut app, tab); + host.add_entity(EntityType::Point(Point::new())); + let published = app.last_plugin_document.expect("plugin write published"); + app.notify_plugins_document_changed(); + assert_eq!(app.last_plugin_document, Some(published)); + } use crate::app::OpenCADStudio; #[test] diff --git a/src/app/commands/styleprops.rs b/src/app/commands/styleprops.rs index b075bcec7..d185c40f4 100644 --- a/src/app/commands/styleprops.rs +++ b/src/app/commands/styleprops.rs @@ -2745,21 +2745,7 @@ impl OpenCADStudio { self.command_line .push_output(crate::tf!("CLAYER = \"{cur}\"").as_ref()); } else { - if self.tabs[i].scene.document.layers.contains(name_arg) { - let handle = self.tabs[i] - .scene - .document - .layers - .get(name_arg) - .map(|l| l.handle) - .unwrap_or(acadrust::types::Handle::NULL); - self.tabs[i].scene.document.header.current_layer_name = - name_arg.to_string(); - self.tabs[i].scene.document.header.current_layer_handle = handle; - self.tabs[i].active_layer = name_arg.to_string(); - self.tabs[i].layers.current_layer = name_arg.to_string(); - self.ribbon.active_layer = name_arg.to_string(); - self.tabs[i].dirty = true; + if self.set_current_layer_name(i, name_arg).is_ok() { self.command_line .push_output(crate::tf!("CLAYER set to \"{name_arg}\"").as_ref()); } else { @@ -2944,6 +2930,29 @@ impl OpenCADStudio { _ => self.command_line.push_error(crate::t!("Requires 0 or 1").as_ref()), } } + cmd if cmd == "SCRIPTCOMMANDS" || cmd.starts_with("SCRIPTCOMMANDS ") => { + // A user setting, deliberately outside the script's reach: it is refused by + // the script command runner, so only the command line can change it. + let arg = cmd.trim_start_matches("SCRIPTCOMMANDS").trim(); + match arg { + "" => {} + "1" | "ON" | "YES" => self.script_commands = true, + "0" | "OFF" | "NO" => self.script_commands = false, + _ => { + self.command_line.push_error( + crate::t!("SCRIPTCOMMANDS takes 1 (scripts may run commands) or 0 (they may not)") + .as_ref(), + ); + return Some(Task::none()); + } + } + if !arg.is_empty() { + self.persist_settings_if_changed(); + } + let state = if self.script_commands { 1 } else { 0 }; + self.command_line + .push_output(crate::tf!("SCRIPTCOMMANDS = {state}").as_ref()); + } "SAVETIME" => { use crate::command::ValuePromptCommand; let c = ValuePromptCommand::new( diff --git a/src/app/control/mod.rs b/src/app/control/mod.rs index 97cf725f0..40f5bb591 100644 --- a/src/app/control/mod.rs +++ b/src/app/control/mod.rs @@ -314,7 +314,7 @@ fn command_selection_policy(name: &str) -> &'static str { } } -fn active_command_metadata(command: &dyn crate::command::CadCommand) -> Value { +pub(super) fn active_command_metadata(command: &dyn crate::command::CadCommand) -> Value { let options = command.options(); let mut accepts = Vec::new(); if command.is_selection_gathering() { diff --git a/src/app/layers.rs b/src/app/layers.rs index 24792f24c..b446d8fd0 100644 --- a/src/app/layers.rs +++ b/src/app/layers.rs @@ -2,6 +2,27 @@ use super::OpenCADStudio; use crate::ui; impl OpenCADStudio { + /// Apply the current layer to the document header and all per-tab/UI + /// creation state. Both built-in commands and plugin settings use this. + pub(super) fn set_current_layer_name(&mut self, tab: usize, layer: &str) -> Result<(), String> { + let handle = self.tabs[tab] + .scene + .document + .layers + .get(layer) + .map(|entry| entry.handle) + .ok_or_else(|| format!("layer {layer:?} does not exist"))?; + self.tabs[tab].scene.document.header.current_layer_name = layer.to_owned(); + self.tabs[tab].scene.document.header.current_layer_handle = handle; + self.tabs[tab].active_layer = layer.to_owned(); + self.tabs[tab].layers.current_layer = layer.to_owned(); + self.tabs[tab].dirty = true; + if tab == self.active_tab { + self.ribbon.active_layer = layer.to_owned(); + self.refresh_layer_panel(); + } + Ok(()) + } pub(super) fn load_layer_state_editor(&mut self, selected: Option) { let i = self.active_tab; if let Some(name) = selected { diff --git a/src/app/mod.rs b/src/app/mod.rs index 794f4cb96..367fab660 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -35,7 +35,7 @@ mod record_api; pub(crate) mod settings; mod shortcuts; mod startup; -mod style_ops; +pub(crate) mod style_ops; mod text_inline; mod tolerance_dialog; mod update; @@ -634,6 +634,8 @@ pub(super) struct OpenCADStudio { /// Minutes between autosaves to a `.sv$` recovery file (SAVETIME command); /// 0 disables autosave. pub savetime_min: i32, + /// SCRIPTCOMMANDS: scripts may run OCS commands (see `UserSettings`). + pub script_commands: bool, /// Persisted default viewport background, restored from settings and applied /// to every drawing tab (new and opened) so a chosen background survives /// restarts (#188). `None` = the built-in dark-grey / off-white defaults. @@ -904,6 +906,11 @@ pub(super) struct OpenCADStudio { /// `SelectionChangedV4` fires once per real change rather than per message. #[cfg(not(target_arch = "wasm32"))] last_plugin_selection: Option<(u64, u64)>, + /// `(tab id, geometry epoch)` last published to the V4 document view. + /// Built-in edits bypass HostSession, so this is checked at message + /// boundaries as well as after plugin-initiated writes. + #[cfg(not(target_arch = "wasm32"))] + last_plugin_document: Option<(u64, u64)>, /// External add-on packages found in the plugins folder, refreshed when the /// Plugin Manager opens. external_plugins: Vec, @@ -4000,6 +4007,7 @@ impl OpenCADStudio { suppress_plugin_dispatch: false, constraint_bar_mode: 4095, savetime_min: 10, + script_commands: true, default_bg_color: None, default_paper_bg_color: None, cliprompt_lines: 3, @@ -4101,6 +4109,8 @@ impl OpenCADStudio { disabled_plugins: rustc_hash::FxHashSet::default(), #[cfg(not(target_arch = "wasm32"))] last_plugin_selection: None, + #[cfg(not(target_arch = "wasm32"))] + last_plugin_document: None, external_plugins: Vec::new(), loaded_plugin_ids: rustc_hash::FxHashSet::default(), plugin_load_errors: rustc_hash::FxHashMap::default(), diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index 54230af20..b29c6a336 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -4,12 +4,12 @@ use std::any::Any; use acadrust::tables::AppId; use acadrust::xdata::ExtendedDataRecord; -use ocs_plugin_api::host::{CadDocument, EntityType, Handle, HostApi}; +use ocs_plugin_api::host::{CadDocument, EntityType, Handle, HostApi, HostSettingValue}; use ocs_plugin_api::shm::{DocumentSnapshotStore, DocumentViewData}; +use super::OpenCADStudio; #[cfg(not(target_arch = "wasm32"))] use crate::plugin::v4_support; -use super::OpenCADStudio; /// Session adapter: one active document tab, command line, undo. pub(crate) struct HostSession<'a> { @@ -44,7 +44,10 @@ impl<'a> HostSession<'a> { } #[cfg(not(target_arch = "wasm32"))] - pub fn document_view_v4(&mut self, tab_id: u64) -> Option { + pub fn document_view_v4( + &mut self, + tab_id: u64, + ) -> Option { if tab_id != self.tab_id() { return None; } @@ -66,9 +69,52 @@ impl<'a> HostSession<'a> { &mut self.app.tabs[self.tab].scene.document } + fn nested_attribute(&self, handle: Handle) -> Option { + self.document().entities().find_map(|entity| { + let EntityType::Insert(insert) = entity else { + return None; + }; + insert + .attributes + .iter() + .find(|attribute| attribute.common.handle == handle) + .cloned() + .map(EntityType::AttributeEntity) + }) + } + + fn host_model_entity(&self, handle: Handle) -> Option { + self.document() + .get_entity(handle) + .cloned() + .or_else(|| self.nested_attribute(handle)) + } + + fn replace_nested_attribute(&mut self, attribute: acadrust::entities::AttributeEntity) -> bool { + let owner = attribute.common.owner_handle; + let handle = attribute.common.handle; + let Some(EntityType::Insert(mut insert)) = self.document().get_entity(owner).cloned() + else { + return false; + }; + let Some(slot) = insert + .attributes + .iter_mut() + .find(|candidate| candidate.common.handle == handle) + else { + return false; + }; + *slot = attribute; + self.app.tabs[self.tab] + .scene + .update_entity(EntityType::Insert(insert)) + } + pub fn document_view(&mut self) -> Option { if self.doc_store.is_none() { - let mut store = DocumentSnapshotStore::::new(self.tab as u64, 8 * 1024 * 1024).ok()?; + let mut store = + DocumentSnapshotStore::::new(self.tab as u64, 8 * 1024 * 1024) + .ok()?; store.publish(&self.document().into()).ok()?; self.doc_store = Some(store); } @@ -92,16 +138,229 @@ impl<'a> HostSession<'a> { } } #[cfg(not(target_arch = "wasm32"))] - v4_support::publish_document_view_v4(self.tab_id(), doc); + { + let key = (self.tab_id(), self.app.tabs[self.tab].scene.geometry_epoch); + if self.app.last_plugin_document != Some(key) { + v4_support::publish_drawing_changed(key.0, key.1); + } + v4_support::publish_document_view_v4(self.tab_id(), doc); + self.app.last_plugin_document = + Some((self.tab_id(), self.app.tabs[self.tab].scene.geometry_epoch)); + } } - pub fn add_entity(&mut self, entity: EntityType) -> Handle { + /// Recompute host-derived state for entities a script created or edited. + fn normalize_scripted_entity( + &self, + old: Option<&EntityType>, + entity: &mut EntityType, + ) -> Result<(), String> { + if let EntityType::Dimension(dimension) = entity { + crate::entities::dimension::normalize_scripted_dimension(dimension); + } + if let EntityType::Viewport(viewport) = entity { + // A viewport needs an id unique in its layout and at least 2 (id 1 + // is the never-drawn sheet viewport, and a lone id 0 can be taken for + // it), and its scale follows its paper height and model view height; + // both are what the MVIEW command sets. + if old.is_none() { + let owner = viewport.common.owner_handle; + let highest = self + .document() + .entities() + .filter_map(|e| match e { + EntityType::Viewport(other) if other.common.owner_handle == owner => Some(other.id), + _ => None, + }) + .max() + .unwrap_or(1); + viewport.id = (highest + 1).max(2); + } + viewport.custom_scale = viewport.height / viewport.view_height; + } + if let EntityType::SectionSymbol(symbol) = entity { + // `points` is the canonical geometry: the point counts follow it + // (they equal the point count in every verified export) and the + // end, tick and label projections are refreshed from it. A symbol + // whose points did not change is left exactly as it was read. + let unchanged = matches!(old, Some(EntityType::SectionSymbol(previous)) if previous.points == symbol.points); + if !unchanged { + let count = i32::try_from(symbol.points.len()).unwrap_or(i32::MAX); + symbol.raw_point_count_90 = count; + symbol.raw_point_record_count = count; + symbol.sync_display_fields(); + } + } + if let EntityType::Spline(spline) = entity { + // The DXF writer emits weights only for a rational spline, so the + // flag must follow the weights a script sets. + spline.flags.rational = !spline.weights.is_empty(); + } + if let EntityType::Helix(new) = entity { + let old = match old { + Some(EntityType::Helix(old)) => Some(old), + _ => None, + }; + crate::entities::helix::normalize_scripted_helix(old, new)?; + } + if let EntityType::Table(new) = entity { + let old = match old { + Some(EntityType::Table(old)) => Some(old), + _ => None, + }; + crate::entities::table::normalize_scripted_table(old, new); + } + if let EntityType::MLine(new) = entity { + let old = match old { + Some(EntityType::MLine(old)) => Some(old), + _ => None, + }; + crate::entities::mline::normalize_scripted_mline(old, new, self.document())?; + } + Ok(()) + } + + /// The `ACAD_IMAGE_DICT` dictionary under the named-objects root, created + /// when the drawing has none. `None` when the drawing has no root dictionary. + fn ensure_image_dictionary(&mut self) -> Option { + use acadrust::objects::{Dictionary, ObjectType}; + let root = self.document().header.named_objects_dict_handle; + let Some(ObjectType::Dictionary(root_dictionary)) = self.document().objects.get(&root) else { + return None; + }; + if let Some(existing) = root_dictionary.get("ACAD_IMAGE_DICT") { + if matches!(self.document().objects.get(&existing), Some(ObjectType::Dictionary(_))) { + return Some(existing); + } + } + let handle = self.document_mut().allocate_handle(); + let mut dictionary = Dictionary::new(); + dictionary.handle = handle; + dictionary.owner = root; + self.document_mut().objects.insert(handle, ObjectType::Dictionary(dictionary)); + if let Some(ObjectType::Dictionary(root_dictionary)) = self.document_mut().objects.get_mut(&root) { + root_dictionary.add_entry("ACAD_IMAGE_DICT", handle); + } + Some(handle) + } + + /// A scripted RasterImage names a file; the host creates the image + /// definition object it needs (pixel size read from the file) and links it. + fn prepare_scripted_raster_image(&mut self, entity: &mut EntityType) -> Result<(), String> { + let EntityType::RasterImage(image) = entity else { + return Ok(()); + }; + if image.definition_handle.is_some_and(|handle| !handle.is_null()) { + return Ok(()); + } + let (width, height) = image::image_dimensions(&image.file_path) + .map_err(|error| format!("cannot read image {:?}: {error}", image.file_path))?; + // One definition per file, as AutoCAD keeps it: reuse an existing one. + let existing = self.document().objects.iter().find_map(|(handle, object)| match object { + acadrust::objects::ObjectType::ImageDefinition(definition) + if definition.file_name == image.file_path => Some(*handle), + _ => None, + }); + let handle = match existing { + Some(handle) => handle, + None => { + let handle = self.document_mut().allocate_handle(); + let mut definition = acadrust::objects::ImageDefinition::with_dimensions( + image.file_path.clone(), + width, + height, + ); + definition.handle = handle; + definition.is_loaded = true; + // Register it in ACAD_IMAGE_DICT under the file's name, and let + // that dictionary own it, like an image AutoCAD attached. + let stem = std::path::Path::new(&image.file_path) + .file_stem() + .and_then(|name| name.to_str()) + .filter(|name| !name.is_empty()) + .unwrap_or("IMAGE") + .to_owned(); + if let Some(dictionary) = self.ensure_image_dictionary() { + definition.owner = dictionary; + if let Some(acadrust::objects::ObjectType::Dictionary(entries)) = + self.document_mut().objects.get_mut(&dictionary) + { + let mut key = stem.clone(); + let mut suffix = 1; + while entries.get(&key).is_some() { + suffix += 1; + key = format!("{stem}_{suffix}"); + } + entries.add_entry(key, handle); + } + } + self.document_mut() + .objects + .insert(handle, acadrust::objects::ObjectType::ImageDefinition(definition)); + handle + } + }; + image.definition_handle = Some(handle); + // `size` is the image's pixel size, and an untouched clip boundary + // covers the whole image, so both follow the file. + let untouched_clip = image.clip_boundary + == acadrust::entities::ClipBoundary::full_image(image.size.x, image.size.y); + image.size = acadrust::types::Vector2::new(f64::from(width), f64::from(height)); + if untouched_clip { + image.clip_boundary = acadrust::entities::ClipBoundary::full_image( + f64::from(width), + f64::from(height), + ); + } + Ok(()) + } + + pub fn add_entity(&mut self, mut entity: EntityType) -> Handle { + if self.prepare_scripted_raster_image(&mut entity).is_err() { + return Handle::NULL; + } + if self.normalize_scripted_entity(None, &mut entity).is_err() { + return Handle::NULL; + } + if let EntityType::AttributeEntity(mut attribute) = entity { + let owner = attribute.common.owner_handle; + let Some(EntityType::Insert(mut insert)) = self.document().get_entity(owner).cloned() + else { + return Handle::NULL; + }; + if attribute.common.handle.is_null() { + attribute.common.handle = self.document_mut().allocate_handle(); + } + let handle = attribute.common.handle; + if insert + .attributes + .iter() + .any(|existing| existing.common.handle == handle) + { + return Handle::NULL; + } + insert.attributes.push(attribute); + if !self.app.tabs[self.tab] + .scene + .update_entity(EntityType::Insert(insert)) + { + return Handle::NULL; + } + self.publish_document_view(); + return handle; + } let handle = self.app.tabs[self.tab].scene.add_entity(entity); self.publish_document_view(); handle } - pub fn add_entities(&mut self, entities: Vec) -> Vec { + pub fn add_entities(&mut self, mut entities: Vec) -> Vec { + for entity in &mut entities { + let _ = self.prepare_scripted_raster_image(entity); + // Creation input was validated by the caller; a degenerate MLine + // simply keeps its supplied (unnormalized) vertices. + let _ = self.normalize_scripted_entity(None, entity); + } let handles = self.app.tabs[self.tab].scene.add_entities(entities); self.publish_document_view(); handles @@ -113,24 +372,149 @@ impl<'a> HostSession<'a> { /// Replace the entity carrying `entity`'s handle in place, refreshing the /// scene's derived caches. Returns `false` when no entity has that handle. - pub fn update_entity(&mut self, entity: EntityType) -> bool { - let ok = self.app.tabs[self.tab].scene.update_entity(entity); + pub fn update_entity(&mut self, mut entity: EntityType) -> bool { + let old = self.document().get_entity(entity.common().handle).cloned(); + if self.normalize_scripted_entity(old.as_ref(), &mut entity).is_err() { + return false; + } + let ok = match entity { + EntityType::AttributeEntity(attribute) => self.replace_nested_attribute(attribute), + entity => self.app.tabs[self.tab].scene.update_entity(entity), + }; if ok { self.publish_document_view(); } ok } + pub fn update_entities_transaction( + &mut self, + label: &str, + mut entities: Vec, + ) -> Result<(), String> { + use std::collections::HashSet; + if label.trim().is_empty() { + return Err("transaction label is empty".into()); + } + if entities.is_empty() { + return Ok(()); + } + let mut seen = HashSet::new(); + for entity in &mut entities { + let handle = entity.common().handle; + if handle.is_null() || !seen.insert(handle) { + return Err(format!("null or duplicate entity handle: {handle:?}")); + } + let Some(existing) = self.host_model_entity(handle) else { + return Err(format!("entity {handle:?} does not exist")); + }; + if std::mem::discriminant(&existing) != std::mem::discriminant(entity) { + return Err(format!("entity {handle:?} changes kind")); + } + if existing.common().owner_handle != entity.common().owner_handle { + return Err(format!("entity {handle:?} changes owner")); + } + ocs_plugin_api::entity_coverage::bind_canvas_entity_references(self.document(), entity) + .map_err(|error| format!("entity {handle:?}: {error}"))?; + ocs_plugin_api::entity_coverage::validate_entity_mutation(&existing, entity) + .map_err(|error| format!("entity {handle:?}: {error}"))?; + self.normalize_scripted_entity(Some(&existing), entity) + .map_err(|error| format!("entity {handle:?}: {error}"))?; + let lock_handle = if matches!(entity, EntityType::AttributeEntity(_)) { + entity.common().owner_handle + } else { + handle + }; + if self.app.tabs[self.tab].scene.is_layer_locked(lock_handle) { + return Err(format!("entity {handle:?} is on a locked layer")); + } + } + self.push_undo(label); + for entity in entities { + // Validation above makes this infallible while the session owns + // the document exclusively. + let updated = match entity { + EntityType::AttributeEntity(attribute) => self.replace_nested_attribute(attribute), + entity => self.app.tabs[self.tab].scene.update_entity(entity), + }; + assert!(updated); + } + self.set_dirty(); + self.publish_document_view(); + Ok(()) + } + + pub fn selection(&self) -> Vec { + self.app.tabs[self.tab].scene.selected_handles_in_order() + } + + pub fn set_selection(&mut self, handles: &[Handle]) -> Result<(), String> { + let mut seen = std::collections::HashSet::new(); + for handle in handles { + if !seen.insert(*handle) { + return Err(format!("duplicate selected entity: {handle:?}")); + } + if self.document().get_entity(*handle).is_none() { + return Err(format!("entity {handle:?} does not exist")); + } + } + if self.selection() == handles { + return Ok(()); + } + let scene = &mut self.app.tabs[self.tab].scene; + scene.replace_selection_exact(handles); + #[cfg(not(target_arch = "wasm32"))] + { + if self.tab == self.app.active_tab { + self.app.last_plugin_selection = Some(( + self.tab_id(), + self.app.tabs[self.tab].scene.selection_fingerprint(), + )); + } + crate::plugin::v4_support::publish_selection_changed_v4( + self.tab_id(), + self.selection(), + ); + } + Ok(()) + } + /// Delete the entity with `handle`, keeping the scene's render caches in /// sync. Returns `false` when the entity is absent or on a locked layer /// (which `erase_entities` refuses to remove). pub fn remove_entity(&mut self, handle: Handle) -> bool { if self.document().get_entity(handle).is_none() { + let Some(EntityType::AttributeEntity(attribute)) = self.nested_attribute(handle) else { return false; + }; + let owner = attribute.common.owner_handle; + let Some(EntityType::Insert(mut insert)) = self.document().get_entity(owner).cloned() + else { + return false; + }; + insert + .attributes + .retain(|candidate| candidate.common.handle != handle); + let removed = self.app.tabs[self.tab] + .scene + .update_entity(EntityType::Insert(insert)); + if removed { + self.publish_document_view(); + } + return removed; } + let owner = self.document().get_entity(handle).map(|entity| entity.common().owner_handle); self.app.tabs[self.tab].scene.erase_entities(&[handle]); let removed = self.document().get_entity(handle).is_none(); if removed { + // The document keeps a block record's member list separately from the + // entity store, and removing an entity leaves its handle listed; drop + // it so a definition never lists a member that no longer exists. + if let Some(owner) = owner { + if let Some(record) = self.document_mut().block_records.iter_mut().find(|record| record.handle == owner) { + record.entity_handles.retain(|member| *member != handle); + } + } self.publish_document_view(); } removed @@ -184,6 +568,8 @@ impl<'a> HostSession<'a> { if let Some(ah) = app_handle { xd.raw_dwg_eed.retain(|(a, _)| *a != ah); } + self.bump_geometry(); + self.set_dirty(); self.publish_document_view(); true } @@ -194,7 +580,11 @@ impl<'a> HostSession<'a> { if self.app.tabs[self.tab].scene.is_layer_locked(handle) { return false; } - let app_handle = self.document().app_ids.get(app_name).map(|a| a.handle.value()); + let app_handle = self + .document() + .app_ids + .get(app_name) + .map(|a| a.handle.value()); let Some(entity) = self.document_mut().get_entity_mut(handle) else { return false; }; @@ -221,6 +611,8 @@ impl<'a> HostSession<'a> { if let Some(ah) = app_handle { xd.raw_dwg_eed.retain(|(a, _)| *a != ah); } + self.bump_geometry(); + self.set_dirty(); self.publish_document_view(); true } @@ -238,6 +630,380 @@ impl<'a> HostSession<'a> { } } + /// Kernel-backed solid create or transform. Every result is verified by + /// `solid_to_sat` (the exported payload must lift back with no loss) and an + /// existing payload is only transformed when it lifts losslessly, so a + /// failure never changes the document. + pub fn solid_operation( + &mut self, + operation: ocs_plugin_api::host::SolidOperation, + ) -> Result { + use crate::scene::model::solid_model as model; + use ocs_plugin_api::host::{SolidOperation, SolidPrimitive}; + let positive = |name: &str, value: f64| { + if value.is_finite() && value > 0.0 { + Ok(()) + } else { + Err(format!("{name} must be finite and greater than zero")) + } + }; + let finite3 = |name: &str, value: [f64; 3]| { + if value.iter().all(|v| v.is_finite()) { + Ok(()) + } else { + Err(format!("{name} must be finite")) + } + }; + match operation { + SolidOperation::Create { primitive, layer } => { + let body = match primitive { + SolidPrimitive::Box { center, size } => { + finite3("box center", center)?; + for (name, v) in [("length", size[0]), ("width", size[1]), ("height", size[2])] { + positive(&format!("box {name}"), v)?; + } + model::box_solid(center, size[0], size[1], size[2]) + } + SolidPrimitive::Wedge { origin, size } => { + finite3("wedge origin", origin)?; + for (name, v) in [("length", size[0]), ("width", size[1]), ("height", size[2])] { + positive(&format!("wedge {name}"), v)?; + } + model::wedge_solid(origin, size[0], size[1], size[2]) + } + SolidPrimitive::Cylinder { center, radius, height } => { + finite3("cylinder center", center)?; + positive("cylinder radius", radius)?; + positive("cylinder height", height)?; + model::cylinder_solid(center, radius, height) + } + SolidPrimitive::Sphere { center, radius } => { + finite3("sphere center", center)?; + positive("sphere radius", radius)?; + model::sphere_solid(center, radius) + } + SolidPrimitive::Torus { center, major, minor } => { + finite3("torus center", center)?; + positive("torus major radius", major)?; + positive("torus minor radius", minor)?; + if minor >= major { + return Err("torus minor radius must be smaller than the major radius".into()); + } + model::torus_solid(center, major, minor) + } + SolidPrimitive::Pyramid { center, radius, height, sides } => { + finite3("pyramid center", center)?; + positive("pyramid radius", radius)?; + positive("pyramid height", height)?; + if !(3..=1024).contains(&sides) { + return Err("pyramid sides must be between 3 and 1024".into()); + } + model::pyramid_solid(center, radius, height, sides as usize) + } + } + .ok_or("the geometry kernel refused these dimensions")?; + let sat = crate::scene::convert::acis_export::solid_to_sat(&body) + .ok_or("the solid could not be exported losslessly")?; + let mut solid = acadrust::entities::Solid3D::new(); + solid.wires = model::edge_wires(&body); + solid.set_sat_document(&sat); + if let Some(layer) = layer { + if layer.trim().is_empty() { + return Err("layer name is empty".into()); + } + solid.common.layer = layer; + } + self.push_undo("Create solid"); + let handle = self.add_entity(EntityType::Solid3D(solid)); + if handle.is_null() { + return Err("the solid could not be added".into()); + } + Ok(handle) + } + SolidOperation::RegionFromProfile { source, layer, delete_source } => { + let (entity, plane, loops, closed, layer) = self.profile_source(source, layer, delete_source)?; + if matches!(entity, EntityType::Region(_)) { + return Err("the source is already a region".into()); + } + if !closed { + return Err("the profile must be closed".into()); + } + let body = cadkernel::brep::planar_region(plane, &loops) + .ok_or("the geometry kernel could not build a region from this profile")?; + let sat = crate::scene::convert::acis_export::solid_to_sat(&body) + .ok_or("the region could not be exported losslessly")?; + let mut region = acadrust::entities::Region::new(); + region.point_of_reference = + acadrust::types::Vector3::new(plane.origin[0], plane.origin[1], plane.origin[2]); + region.wires = model::edge_wires(&body); + region.set_sat_document(&sat); + region.common.layer = layer; + self.commit_profile_result(EntityType::Region(region), source, delete_source, "Create region") + } + SolidOperation::SurfaceFromProfile { source, layer, delete_source } => { + let (entity, plane, loops, closed, layer) = self.profile_source(source, layer, delete_source)?; + if matches!(entity, EntityType::Region(_)) { + return Err("the source is already a region".into()); + } + if !closed { + return Err("the profile must be closed".into()); + } + let body = cadkernel::brep::planar_region(plane, &loops) + .ok_or("the geometry kernel could not build a surface from this profile")?; + let sat = crate::scene::convert::acis_export::solid_to_sat(&body) + .ok_or("the surface could not be exported losslessly")?; + let mut surface = acadrust::entities::Surface::new(acadrust::entities::SurfaceKind::Plane); + surface.point_of_reference = + acadrust::types::Vector3::new(plane.origin[0], plane.origin[1], plane.origin[2]); + surface.wires = model::edge_wires(&body); + surface.acis_data = acadrust::entities::AcisData::from_sat(&sat.to_sat_string()); + surface.common.layer = layer; + self.commit_profile_result(EntityType::Surface(surface), source, delete_source, "Create surface") + } + SolidOperation::Extrude { source, direction, layer, delete_source } => { + if direction.iter().any(|v| !v.is_finite()) { + return Err("extrusion direction must be finite".into()); + } + if direction.iter().map(|v| v * v).sum::().sqrt() <= 1e-9 { + return Err("extrusion direction must be nonzero".into()); + } + let (entity, _plane, _loops, closed, layer) = self.profile_source(source, layer, delete_source)?; + if matches!(entity, EntityType::Region(_)) { + return Err("extrude a curve profile; a region cannot be extruded here".into()); + } + let body = crate::scene::model::presspull_model::extrusion_body(&entity, direction) + .ok_or("the geometry kernel could not extrude this profile in that direction")?; + let sat = crate::scene::convert::acis_export::solid_to_sat(&body) + .ok_or("the extrusion could not be exported losslessly")?; + let result = if closed { + let mut solid = acadrust::entities::Solid3D::new(); + solid.wires = model::edge_wires(&body); + solid.set_sat_document(&sat); + solid.common.layer = layer; + EntityType::Solid3D(solid) + } else { + // An open profile sweeps to a surface. The generic kind + // carries only the payload, as OCS does after a boolean. + let mut surface = acadrust::entities::Surface::new(acadrust::entities::SurfaceKind::Generic); + surface.wires = model::edge_wires(&body); + surface.acis_data = acadrust::entities::AcisData::from_sat(&sat.to_sat_string()); + surface.common.layer = layer; + EntityType::Surface(surface) + }; + self.commit_profile_result(result, source, delete_source, "Extrude profile") + } + SolidOperation::EmbedPicture { path, origin, width, layer } => { + finite3("picture origin", origin)?; + positive("picture width", width)?; + if layer.as_ref().is_some_and(|name| name.trim().is_empty()) { + return Err("layer name is empty".into()); + } + let picture = crate::io::ole_embed::EmbeddedImage::from_file(std::path::Path::new(&path)) + .map_err(|error| format!("cannot read the picture {path:?}: {error}"))?; + let (upper_left, lower_right) = crate::io::ole_embed::corners_from_placement( + acadrust::types::Vector3::new(origin[0], origin[1], origin[2]), + width, + picture.aspect(), + ); + let mut frame = crate::io::ole_embed::build_embedded_ole(&picture, upper_left, lower_right); + if let Some(layer) = layer { + frame.common.layer = layer; + } + self.push_undo("Embed picture"); + let handle = self.add_entity(EntityType::Ole2Frame(frame)); + if handle.is_null() { + return Err("the picture frame could not be added".into()); + } + Ok(handle) + } + SolidOperation::Boolean { first, second, operation, layer, keep_operands } => { + use ocs_plugin_api::host::SolidBoolean; + if first == second { + return Err("a solid cannot be combined with itself".into()); + } + if layer.as_ref().is_some_and(|name| name.trim().is_empty()) { + return Err("layer name is empty".into()); + } + let operand = |host: &Self, handle: Handle, which: &str| -> Result { + match host.document().get_entity(handle) { + Some(EntityType::Solid3D(solid)) => Ok(solid.clone()), + Some(_) => Err(format!("the {which} operand must be a Solid3D")), + None => Err(format!("entity {handle:?} does not exist")), + } + }; + let a = operand(self, first, "first")?; + let b = operand(self, second, "second")?; + if !keep_operands { + for handle in [first, second] { + if self.app.tabs[self.tab].scene.is_layer_locked(handle) { + return Err(format!("entity {handle:?} is on a locked layer")); + } + } + } + let lift = |solid: &acadrust::entities::Solid3D, which: &str| { + crate::scene::convert::solid3d_tess::kernel_acis_body(&solid.acis_data).ok_or_else(|| { + format!("the {which} solid's payload cannot be lifted losslessly, so nothing was changed") + }) + }; + let (body_a, body_b) = (lift(&a, "first")?, lift(&b, "second")?); + let kind = match operation { + SolidBoolean::Union => model::Bool::Union, + SolidBoolean::Subtract => model::Bool::Subtract, + SolidBoolean::Intersect => model::Bool::Intersect, + }; + let combined = model::boolean_result(kind, &body_a, &body_b).map_err(|snag| { + let reason = match snag { + cadkernel::brep::Snag::Coincident => "two faces lie on the same surface, which the kernel cannot resolve", + cadkernel::brep::Snag::CutRefused => "a face could not be cut along the intersection curve", + cadkernel::brep::Snag::NoClosedForm => "the surfaces meet along a curve the kernel has no closed form for", + }; + format!("the geometry kernel refused this {operation:?} ({snag:?}): {reason}; nothing was changed") + })?; + let sat = crate::scene::convert::acis_export::solid_to_sat(&combined) + .ok_or("the result could not be exported losslessly, so nothing was changed")?; + let mut solid = acadrust::entities::Solid3D::new(); + solid.common = a.common.clone(); + solid.common.handle = Handle::NULL; + solid.common.owner_handle = Handle::NULL; + solid.wires = model::edge_wires(&combined); + solid.set_sat_document(&sat); + if let Some(layer) = layer { + solid.common.layer = layer; + } + self.push_undo("Boolean solids"); + let handle = self.add_entity(EntityType::Solid3D(solid)); + if handle.is_null() { + return Err("the result could not be added".into()); + } + if !keep_operands { + self.app.tabs[self.tab].scene.erase_entities(&[first, second]); + self.publish_document_view(); + } + Ok(handle) + } + SolidOperation::Transform { handle, matrix } => { + if matrix.iter().any(|v| !v.is_finite()) { + return Err("transform matrix must be finite".into()); + } + let column = |i: usize| [matrix[i], matrix[i + 1], matrix[i + 2]]; + let (x, y, z) = (column(0), column(4), column(8)); + let dot = |a: [f64; 3], b: [f64; 3]| a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; + let rigid = [x, y, z].iter().all(|c| (dot(*c, *c) - 1.0).abs() < 1e-6) + && dot(x, y).abs() < 1e-6 + && dot(x, z).abs() < 1e-6 + && dot(y, z).abs() < 1e-6 + && matrix[3].abs() < 1e-9 + && matrix[7].abs() < 1e-9 + && matrix[11].abs() < 1e-9 + && (matrix[15] - 1.0).abs() < 1e-9; + if !rigid { + return Err("only rigid moves and mirrors are supported; scaling and shear are not".into()); + } + let mut entity = self + .document() + .get_entity(handle) + .cloned() + .ok_or_else(|| format!("entity {handle:?} does not exist"))?; + if self.app.tabs[self.tab].scene.is_layer_locked(handle) { + return Err(format!("entity {handle:?} is on a locked layer")); + } + macro_rules! retarget { + ($value:expr) => {{ + let body = crate::scene::convert::solid3d_tess::kernel_acis_body(&$value.acis_data) + .ok_or("this body's payload cannot be lifted losslessly, so it is left untouched")?; + let moved = model::by_matrix(&body, matrix) + .ok_or("the geometry kernel refused this transform")?; + let sat = crate::scene::convert::acis_export::solid_to_sat(&moved) + .ok_or("the moved body could not be exported losslessly")?; + $value.wires = model::edge_wires(&moved); + $value.silhouettes.clear(); + $value.history_handle = None; + $value.set_sat_document(&sat); + }}; + } + match &mut entity { + EntityType::Solid3D(value) => retarget!(value), + EntityType::Body(value) => retarget!(value), + EntityType::Region(value) => retarget!(value), + EntityType::Surface(value) => { + let body = crate::scene::convert::solid3d_tess::kernel_acis_body(&value.acis_data) + .ok_or("this surface's payload cannot be lifted losslessly, so it is left untouched")?; + let moved = model::by_matrix(&body, matrix) + .ok_or("the geometry kernel refused this transform")?; + let sat = crate::scene::convert::acis_export::solid_to_sat(&moved) + .ok_or("the moved surface could not be exported losslessly")?; + value.wires = model::edge_wires(&moved); + value.silhouettes.clear(); + value.history_handle = None; + value.acis_data = acadrust::entities::AcisData::from_sat(&sat.to_sat_string()); + // A moved plane is still a plane; a swept surface no longer + // matches its stored sweep parameters, so it becomes generic. + if value.kind != acadrust::entities::SurfaceKind::Plane { + value.kind = acadrust::entities::SurfaceKind::Generic; + value.surface_data = acadrust::entities::SurfaceData::Generic; + } + } + _ => return Err("transform applies to Solid3D, Body, Region and Surface entities".into()), + } + self.push_undo("Transform solid"); + if !self.update_entity(entity) { + return Err(format!("entity {handle:?} could not be updated")); + } + Ok(handle) + } + } + } + + /// Look up a profile entity and its exact planar loops for the kernel. + /// Returns the entity, its plane, loops and closed flag, and the layer the + /// result should use (`layer`, or the profile's own). + #[allow(clippy::type_complexity)] + fn profile_source( + &self, + source: Handle, + layer: Option, + delete_source: bool, + ) -> Result<(EntityType, cadkernel::space::Plane, Vec>, bool, String), String> { + if layer.as_ref().is_some_and(|name| name.trim().is_empty()) { + return Err("layer name is empty".into()); + } + let entity = self + .document() + .get_entity(source) + .cloned() + .ok_or_else(|| format!("entity {source:?} does not exist"))?; + if delete_source && self.app.tabs[self.tab].scene.is_layer_locked(source) { + return Err(format!("entity {source:?} is on a locked layer")); + } + if matches!(entity, EntityType::Solid3D(_) | EntityType::Body(_) | EntityType::Surface(_)) { + return Err("the source must be a curve profile, not an existing solid or surface".into()); + } + let (plane, loops, closed) = crate::scene::model::presspull_model::profile_geometry(&entity) + .ok_or("the source is not a planar profile")?; + let layer = layer.unwrap_or_else(|| entity.common().layer.clone()); + Ok((entity, plane, loops, closed, layer)) + } + + /// Record one undo step, add `result` and optionally erase the profile. + fn commit_profile_result( + &mut self, + result: EntityType, + source: Handle, + delete_source: bool, + label: &str, + ) -> Result { + self.push_undo(label); + let handle = self.add_entity(result); + if handle.is_null() { + return Err("the result could not be added".into()); + } + if delete_source { + self.app.tabs[self.tab].scene.erase_entities(&[source]); + self.publish_document_view(); + } + Ok(handle) + } + pub fn push_undo(&mut self, label: &str) { self.app.push_undo_snapshot(self.tab, label); } @@ -246,6 +1012,41 @@ impl<'a> HostSession<'a> { self.app.tabs[self.tab].dirty = true; } + pub fn system_variable(&self, name: &str) -> Option { + match name.to_ascii_uppercase().as_str() { + "CLAYER" => Some(HostSettingValue::Text( + self.document().header.current_layer_name.clone(), + )), + "SNAPANG" => Some(HostSettingValue::Number(self.app.snap_angle_deg as f64)), + "CTAB" => Some(HostSettingValue::Text(self.app.tabs[self.tab].scene.current_layout.clone())), + _ => None, + } + } + + pub fn set_system_variable( + &mut self, + name: &str, + value: HostSettingValue, + ) -> Result { + match (name.to_ascii_uppercase().as_str(), value) { + ("CLAYER", HostSettingValue::Text(layer)) => { + self.app.set_current_layer_name(self.tab, &layer)?; + Ok(HostSettingValue::Text(layer)) + } + ("SNAPANG", HostSettingValue::Number(angle)) if (angle as f32).is_finite() => { + self.app.snap_angle_deg = (angle as f32).rem_euclid(360.0); + Ok(HostSettingValue::Number(self.app.snap_angle_deg as f64)) + } + ("SNAPANG", HostSettingValue::Number(_)) => { + Err("SNAPANG: finite number required".to_owned()) + } + ("CLAYER", _) | ("SNAPANG", _) => { + Err(format!("{}: wrong value type", name.to_ascii_uppercase())) + } + _ => Err(format!("unsupported system variable {name:?}")), + } + } + pub fn push_info(&mut self, msg: &str) { self.app.command_line.push_info(msg); } @@ -309,6 +1110,1114 @@ impl<'a> HostSession<'a> { Some(handle) } + /// Validated, undoable layer-table operations (API v7, additive). Every + /// refusal happens before the undo step is recorded, so a failure leaves the + /// drawing and its history untouched. + pub fn table_operation( + &mut self, + operation: ocs_plugin_api::host::TableOperation, + ) -> Result { + use ocs_plugin_api::host::TableOperation; + let layer_handle = |doc: &CadDocument, name: &str| -> Result { + doc.layers + .get(name) + .map(|layer| layer.handle) + .ok_or_else(|| format!("layer {name:?} does not exist")) + }; + let protected = |name: &str| { + let name = name.trim(); + name == "0" || name.eq_ignore_ascii_case("Defpoints") + }; + let same_name = |a: &str, b: &str| a.trim().to_uppercase() == b.trim().to_uppercase(); + let check_color = |color: &acadrust::types::Color| match color { + acadrust::types::Color::ByLayer + | acadrust::types::Color::ByBlock + | acadrust::types::Color::None => Err("a layer color must be an index 1-255 or an RGB value".to_owned()), + acadrust::types::Color::Index(0) => Err("a layer color index must be 1-255".to_owned()), + _ => Ok(()), + }; + let check_config = |config: &ocs_plugin_api::host::LayerConfig| -> Result<(), String> { + if let Some(color) = &config.color { + check_color(color)?; + } + if let Some(acadrust::types::LineWeight::Value(value)) = config.lineweight { + if !(0..=211).contains(&value) { + return Err("a layer lineweight must be between 0 and 211 (1/100 mm)".to_owned()); + } + } + Ok(()) + }; + match operation { + TableOperation::LayerCreate { config } => { + let name = validated_layer_name(&config.name)?; + if self.document().layers.contains(&name) { + return Err(format!("layer {name:?} already exists")); + } + check_config(&config)?; + if let Some(linetype) = &config.linetype { + if resolve_linetype(self.document_mut(), linetype).is_none() { + return Err(format!("linetype {linetype:?} does not exist in this drawing")); + } + } + self.push_undo("Create layer"); + let config = ocs_plugin_api::host::LayerConfig { name, ..config }; + self.add_layer(config).ok_or_else(|| "the layer could not be created".to_owned()) + } + TableOperation::LayerModify { config } => { + let name = config.name.trim().to_owned(); + let handle = layer_handle(self.document(), &name)?; + if config.color.is_none() + && config.linetype.is_none() + && config.lineweight.is_none() + && config.off.is_none() + && config.frozen.is_none() + && config.locked.is_none() + && config.plottable.is_none() + && config.transparency.is_none() + && config.description.is_none() + { + return Err("no layer properties to change".to_owned()); + } + check_config(&config)?; + if config.frozen == Some(true) + && same_name(&self.document().header.current_layer_name, &name) + { + return Err("the current layer cannot be frozen".to_owned()); + } + if let Some(linetype) = &config.linetype { + if resolve_linetype(self.document_mut(), linetype).is_none() { + return Err(format!("linetype {linetype:?} does not exist in this drawing")); + } + } + self.push_undo("Modify layer"); + if self.modify_layer(config) { + Ok(handle) + } else { + Err("the layer could not be changed".to_owned()) + } + } + TableOperation::LayerRename { from, to } => { + let handle = layer_handle(self.document(), from.trim())?; + if protected(&from) { + return Err(format!("layer {:?} cannot be renamed", from.trim())); + } + let to = validated_layer_name(&to)?; + if from.trim() == to { + return Err("the new layer name is the same as the current one".to_owned()); + } + if !same_name(&from, &to) && self.document().layers.contains(&to) { + return Err(format!("layer {to:?} already exists")); + } + self.push_undo("Rename layer"); + if !self.app.tabs[self.tab].rename_layer(from.trim(), &to) { + return Err("the layer could not be renamed".to_owned()); + } + self.finish_layer_change(&[from.trim().to_owned(), to]); + Ok(handle) + } + TableOperation::LayerDelete { name, erase_objects } => { + let name = name.trim().to_owned(); + let handle = layer_handle(self.document(), &name)?; + if protected(&name) { + return Err(format!("layer {name:?} cannot be deleted")); + } + if same_name(&self.document().header.current_layer_name, &name) { + return Err("the current layer cannot be deleted".to_owned()); + } + if name.contains('|') { + return Err("an externally referenced layer cannot be deleted".to_owned()); + } + let key = name.to_uppercase(); + let on_layer: Vec = self + .document() + .entities() + .filter(|entity| entity.common().layer.to_uppercase() == key) + .map(|entity| entity.common().handle) + .collect(); + if !on_layer.is_empty() && !erase_objects { + return Err(format!( + "layer {name:?} still holds {} object(s); pass erase_objects=True to erase them with the layer", + on_layer.len() + )); + } + self.push_undo("Delete layer"); + self.document_mut().layers.remove(&name); + if !on_layer.is_empty() { + self.app.tabs[self.tab].scene.erase_entities(&on_layer); + } + self.finish_layer_change(&[name]); + Ok(handle) + } + TableOperation::LayerSetCurrent { name } => { + let name = name.trim().to_owned(); + let handle = layer_handle(self.document(), &name)?; + let stored = self.document().layers.get(&name).map(|l| l.name.clone()).unwrap_or(name); + self.app.set_current_layer_name(self.tab, &stored)?; + Ok(handle) + } + other @ (TableOperation::BlockCreate { .. } + | TableOperation::BlockModify { .. } + | TableOperation::BlockRename { .. } + | TableOperation::BlockDelete { .. } + | TableOperation::BlockEntityAdd { .. }) => self.block_operation(other), + other @ (TableOperation::LinetypeCreate { .. } + | TableOperation::LinetypeModify { .. } + | TableOperation::LinetypeRename { .. } + | TableOperation::LinetypeDelete { .. }) => self.linetype_operation(other), + other @ (TableOperation::LayoutCreate { .. } + | TableOperation::LayoutRename { .. } + | TableOperation::LayoutDelete { .. } + | TableOperation::LayoutSetCurrent { .. } + | TableOperation::LayoutSetPage { .. }) => self.layout_operation(other), + other => self.style_operation(other), + } + } + + /// Drive a real OCS command one step at a time (see + /// `ocs_plugin_api::host::CommandRequest`). The same primitives the + /// automation channel uses, run synchronously on this thread, so a step + /// never waits on anything that has to arrive from outside. + /// The user's SCRIPTCOMMANDS switch. Every route a script can take to the + /// command line goes through this, not only `run_command`. + pub(super) fn script_commands_allowed(&self) -> Result<(), String> { + if self.app.script_commands { + Ok(()) + } else { + Err("running commands from a script is turned off (SCRIPTCOMMANDS = 0); turn it on at the command line with SCRIPTCOMMANDS 1".to_owned()) + } + } + + /// Whether a script may run this command line. + /// + /// The alias is resolved first: the dispatcher resolves it too, so a + /// denylist that reads the token as typed would let a user-defined alias + /// (`Q` for `QUIT`, added with ALIASEDIT) walk straight past it. + pub(super) fn script_command_allowed(&self, line: &str) -> Result<(), String> { + const DENIED: &[&str] = &[ + "QUIT", "EXIT", "CLOSE", "CLOSEALL", "NEW", "QNEW", "OPEN", "SAVE", "QSAVE", "SAVEAS", + "SAVEALL", "RECOVER", "SCRIPT", "RUNSCRIPT", "SCRIPTCOMMANDS", + ]; + let Some(token) = line.split_whitespace().next() else { + return Ok(()); + }; + let token = token.trim_start_matches(['\'', '_']); + let resolved = self.app.resolve_alias(token); + let first = resolved.as_deref().unwrap_or(token).to_uppercase(); + let first = first.split_whitespace().next().unwrap_or(&first); + if DENIED.contains(&first) || first.starts_with("PY_") { + Err(format!("the command {first} cannot be run from a script")) + } else { + Ok(()) + } + } + + pub fn run_command( + &mut self, + request: ocs_plugin_api::host::CommandRequest, + ) -> Result { + use crate::command::StepInput; + use ocs_plugin_api::host::CommandRequest as R; + if self.tab != self.app.active_tab { + return Err("commands act on the active document; switch to it first".to_owned()); + } + let tab = self.tab; + // Cancel stays allowed: if the user turns scripts off mid-run, a command a script left + // waiting must still be closable rather than stranded. + if !matches!(request, R::Cancel) { + self.script_commands_allowed()?; + } + let active = self.app.tabs[tab].active_cmd.is_some(); + let before = self.document().entities().count() as i64; + let error_revision = self.app.command_line.error_revision; + let editor_before = self.app.text_inline.is_some(); + let mtext_before = self.app.mtext_editor.is_some(); + let modal_before = self.app.active_modal.is_some(); + self.app.command_line.unconsumed.clear(); + let task = match request { + R::Run { line } => { + if line.trim().is_empty() { + return Err("a command line is empty".to_owned()); + } + self.script_command_allowed(&line)?; + if active { + return Err("a command is already active; cancel it first".to_owned()); + } + self.app.suppress_plugin_dispatch = true; + let task = self.app.run_command_line(&line); + self.app.suppress_plugin_dispatch = false; + task + } + R::Start { name } => { + if name.trim().is_empty() || name.split_whitespace().count() != 1 { + return Err("a command name is one word".to_owned()); + } + self.script_command_allowed(&name)?; + if active { + return Err("a command is already active; cancel it first".to_owned()); + } + self.app.suppress_plugin_dispatch = true; + let task = self.app.dispatch_command(name.trim()); + self.app.suppress_plugin_dispatch = false; + task + } + R::Cancel => { + let task = self.app.update(crate::app::Message::CommandEscape); + self.app.drive_headless_task(task)?; + // Close what the command opened: an editor or a dialog would leave + // the interface waiting on a script that has moved on. + if !editor_before { + self.app.text_inline = None; + } + if !mtext_before { + self.app.mtext_editor = None; + } + if !modal_before { + self.app.active_modal = None; + } + return Ok(self.command_outcome(before, None)); + } + other => { + if !active { + return Err("no command is waiting for input".to_owned()); + } + let input = match other { + R::Point { point } => { + if point.iter().any(|v| !v.is_finite()) { + return Err("a point must be finite".to_owned()); + } + StepInput::Point(glam::DVec3::new(point[0], point[1], point[2])) + } + R::Text { text } => StepInput::Text(text), + R::Token { text } => { + let task = self.app.feed_active_cmd(&text); + self.app.drive_headless_task(task)?; + return Ok(self.finish_command_step(before, error_revision, editor_before, mtext_before, modal_before)); + } + R::Entity { handle, point } => { + if point.iter().any(|v| !v.is_finite()) { + return Err("a point must be finite".to_owned()); + } + if self.document().get_entity(handle).is_none() { + return Err(format!("entity {} does not exist", handle.value())); + } + StepInput::EntityPick(handle, glam::DVec3::new(point[0], point[1], point[2])) + } + R::Selection => StepInput::SelectionComplete(self.app.tabs[tab].scene.selected_handles_in_order()), + R::Enter => StepInput::Enter, + R::Run { .. } | R::Start { .. } | R::Cancel => unreachable!(), + }; + self.app.feed_command(input) + } + }; + self.app.drive_headless_task(task)?; + Ok(self.finish_command_step(before, error_revision, editor_before, mtext_before, modal_before)) + } + + fn finish_command_step( + &mut self, + before: i64, + error_revision: u64, + editor_before: bool, + mtext_before: bool, + modal_before: bool, + ) -> ocs_plugin_api::host::CommandOutcome { + let error = (self.app.command_line.error_revision != error_revision) + .then(|| self.app.command_line.last_error.clone().unwrap_or_default()); + let blocked = if self.app.tabs[self.tab].active_cmd.is_some() { + Some("command".to_owned()) + } else if let (false, Some(modal)) = (modal_before, self.app.active_modal.as_ref()) { + Some(format!("modal:{modal:?}")) + } else if !editor_before && self.app.text_inline.is_some() { + Some("text_editor".to_owned()) + } else if !mtext_before && self.app.mtext_editor.is_some() { + Some("mtext_editor".to_owned()) + } else { + None + }; + self.set_dirty(); + self.publish_document_view(); + let mut outcome = self.command_outcome(before, blocked); + outcome.error = error; + outcome + } + + fn command_outcome(&self, before: i64, blocked_by: Option) -> ocs_plugin_api::host::CommandOutcome { + let now = self.document().entities().count() as i64; + let metadata = self.app.tabs[self.tab] + .active_cmd + .as_deref() + .map(crate::app::control::active_command_metadata); + let text = |key: &str| metadata.as_ref().and_then(|m| m[key].as_str()).unwrap_or("").to_owned(); + let list = |key: &str| -> Vec { + metadata + .as_ref() + .and_then(|m| m[key].as_array()) + .map(|items| items.iter().filter_map(|v| v.as_str().map(str::to_owned)).collect()) + .unwrap_or_default() + }; + let options = metadata + .as_ref() + .and_then(|m| m["options"].as_array()) + .map(|items| items.iter().filter_map(|o| o["keyword"].as_str().filter(|k| !k.is_empty()).map(str::to_owned)).collect()) + .unwrap_or_default(); + ocs_plugin_api::host::CommandOutcome { + status: if blocked_by.is_some() { "waiting_input" } else { "completed" }.to_owned(), + blocked_by, + command: text("name"), + prompt: text("prompt"), + accepts: list("accepts"), + options, + entities: now as u64, + added: now - before, + unconsumed: self.app.command_line.unconsumed.clone(), + error: None, + } + } + + /// Simple linetype create/modify/rename/delete. + fn linetype_operation(&mut self, operation: ocs_plugin_api::host::TableOperation) -> Result { + use ocs_plugin_api::host::TableOperation; + let protected = |name: &str| { + ["CONTINUOUS", "BYLAYER", "BYBLOCK"].contains(&name.trim().to_uppercase().as_str()) + }; + let check_pattern = |pattern: &[f64]| -> Result<(), String> { + if !(2..=12).contains(&pattern.len()) { + return Err("a linetype pattern needs 2 to 12 elements".to_owned()); + } + if pattern.iter().any(|v| !v.is_finite() || v.abs() > 1.0e6) { + return Err("linetype pattern lengths must be finite and within 1e6".to_owned()); + } + if !pattern.iter().any(|v| *v >= 0.0) || !pattern.iter().any(|v| *v < 0.0) { + return Err("a linetype pattern needs at least one dash or dot and one gap".to_owned()); + } + if pattern.iter().map(|v| v.abs()).sum::() <= 0.0 { + return Err("a linetype pattern must have a nonzero length".to_owned()); + } + Ok(()) + }; + let build = |lt: &mut acadrust::tables::LineType, pattern: &[f64]| { + lt.elements = pattern + .iter() + .map(|length| acadrust::tables::linetype::LineTypeElement { length: *length, complex: None }) + .collect(); + lt.pattern_length = pattern.iter().map(|v| v.abs()).sum(); + }; + let check_description = |text: &str| { + if text.chars().any(|c| c.is_control()) || text.chars().count() > 255 { + Err("a linetype description must be text of at most 255 characters".to_owned()) + } else { + Ok(()) + } + }; + // Loading the standard linetypes first means a name that already exists as a + // standard pattern (Dashed, Center, ...) is seen as taken. + crate::io::linetypes::populate_document(self.document_mut()); + match operation { + TableOperation::LinetypeCreate { name, description, pattern } => { + let name = validated_symbol_name(&name, "a linetype name")?; + if self.document().line_types.contains(&name) { + return Err(format!("linetype {name:?} already exists")); + } + check_pattern(&pattern)?; + check_description(&description)?; + self.push_undo("Create linetype"); + let mut lt = acadrust::tables::LineType::new(name.clone()); + lt.description = description; + build(&mut lt, &pattern); + let handle = self.document_mut().allocate_handle(); + lt.handle = handle; + self.document_mut().line_types.add(lt).map_err(|e| format!("the linetype could not be created: {e}"))?; + self.finish_linetype_change(&[name]); + Ok(handle) + } + TableOperation::LinetypeModify { name, description, pattern } => { + let existing = self + .document() + .line_types + .get(name.trim()) + .cloned() + .ok_or_else(|| format!("linetype {:?} does not exist", name.trim()))?; + if protected(&existing.name) || existing.xref_dependent { + return Err(format!("linetype {:?} cannot be changed", existing.name)); + } + if existing.elements.iter().any(|e| e.complex.is_some()) { + return Err("a linetype with text or shape elements cannot be changed from a script".to_owned()); + } + if description.is_none() && pattern.is_none() { + return Err("no linetype properties to change".to_owned()); + } + if let Some(text) = &description { + check_description(text)?; + } + if let Some(pattern) = &pattern { + check_pattern(pattern)?; + } + self.push_undo("Modify linetype"); + let mut lt = existing.clone(); + if let Some(text) = description { + lt.description = text; + } + if let Some(pattern) = &pattern { + build(&mut lt, pattern); + } + if let Some(slot) = self.document_mut().line_types.get_mut(&existing.name) { + *slot = lt; + } + self.finish_linetype_change(&[existing.name.clone()]); + Ok(existing.handle) + } + TableOperation::LinetypeRename { from, to } => { + let existing = self + .document() + .line_types + .get(from.trim()) + .cloned() + .ok_or_else(|| format!("linetype {:?} does not exist", from.trim()))?; + if protected(&existing.name) || existing.xref_dependent { + return Err(format!("linetype {:?} cannot be renamed", existing.name)); + } + let to = validated_symbol_name(&to, "a linetype name")?; + if existing.name.eq_ignore_ascii_case(&to) { + return Err("the new linetype name matches the current one (a case-only change is not supported)".to_owned()); + } + if self.document().line_types.contains(&to) { + return Err(format!("linetype {to:?} already exists")); + } + self.push_undo("Rename linetype"); + let old = existing.name.clone(); + let handle = existing.handle; + let doc = self.document_mut(); + let mut lt = existing; + lt.name = to.clone(); + doc.line_types.remove(&old); + doc.line_types.add(lt).map_err(|e| format!("the linetype could not be renamed: {e}"))?; + for layer in doc.layers.iter_mut() { + if layer.line_type.eq_ignore_ascii_case(&old) { + layer.line_type = to.clone(); + } + } + for entity in doc.entities_mut() { + if entity.common().linetype.eq_ignore_ascii_case(&old) { + entity.common_mut().linetype = to.clone(); + } + } + if doc.header.current_linetype_name.eq_ignore_ascii_case(&old) { + doc.header.current_linetype_name = to.clone(); + } + self.finish_linetype_change(&[old, to]); + Ok(handle) + } + TableOperation::LinetypeDelete { name } => { + let existing = self + .document() + .line_types + .get(name.trim()) + .cloned() + .ok_or_else(|| format!("linetype {:?} does not exist", name.trim()))?; + if protected(&existing.name) || existing.xref_dependent { + return Err(format!("linetype {:?} cannot be deleted", existing.name)); + } + let doc = self.document(); + let in_use = doc.header.current_linetype_name.eq_ignore_ascii_case(&existing.name) + || doc.layers.iter().any(|layer| layer.line_type.eq_ignore_ascii_case(&existing.name)) + || doc.entities().any(|entity| entity.common().linetype.eq_ignore_ascii_case(&existing.name)) + || doc.dim_styles.iter().any(|style| { + [style.dimltex_handle, style.dimltex1_handle, style.dimltex2_handle].contains(&existing.handle) + }); + if in_use { + return Err(format!("linetype {:?} is current or still in use", existing.name)); + } + self.push_undo("Delete linetype"); + self.document_mut().line_types.remove(&existing.name); + self.finish_linetype_change(&[existing.name]); + Ok(existing.handle) + } + _ => Err("unsupported table operation".to_owned()), + } + } + + fn finish_linetype_change(&mut self, names: &[String]) { + self.app.tabs[self.tab].dirty = true; + self.app.tabs[self.tab].scene.invalidate_layer_dependencies(names); + self.app.tabs[self.tab].scene.bump_geometry(); + self.publish_document_view(); + } + + /// Paper-space layout operations (create, rename, delete, switch, page). + /// They act on the active document because switching layouts drives the + /// application's view state. + fn layout_operation(&mut self, operation: ocs_plugin_api::host::TableOperation) -> Result { + use acadrust::objects::ObjectType; + use ocs_plugin_api::host::TableOperation; + if self.tab != self.app.active_tab { + return Err("layout operations act on the active document; switch to it first".to_owned()); + } + let find = |doc: &CadDocument, name: &str| -> Result<(String, Handle), String> { + doc.objects + .values() + .find_map(|object| match object { + ObjectType::Layout(layout) + if !layout.block_record.is_null() && layout.name.eq_ignore_ascii_case(name) => + { + Some((layout.name.clone(), layout.handle)) + } + _ => None, + }) + .ok_or_else(|| format!("layout {name:?} does not exist")) + }; + let names_taken = |app: &crate::app::OpenCADStudio, tab: usize, name: &str| { + app.tabs[tab].scene.layout_names().iter().any(|existing| existing.eq_ignore_ascii_case(name)) + }; + match operation { + TableOperation::LayoutCreate { name } => { + let name = validated_symbol_name(&name, "a layout name")?; + if names_taken(self.app, self.tab, &name) { + return Err(format!("layout {name:?} already exists")); + } + self.push_undo("Create layout"); + let handle = self + .document_mut() + .add_layout(&name) + .map_err(|e| format!("the layout could not be created: {e}"))?; + let flags = i16::from(self.document().header.paper_space_linetype_scaling) + | (i16::from(self.document().header.paper_space_limit_check) << 1); + let plot_style = self + .app + .active_plot_style + .as_ref() + .map(|style| style.name.clone()) + .unwrap_or_default(); + for object in self.document_mut().objects.values_mut() { + if let ObjectType::Layout(layout) = object { + if layout.name == name { + layout.flags = flags; + crate::scene::apply_default_page_setup(layout, &plot_style); + break; + } + } + } + self.app.tabs[self.tab].scene.ensure_sheet_viewport(&name); + self.finish_layout_change(); + Ok(handle) + } + TableOperation::LayoutRename { from, to } => { + let (from, handle) = find(self.document(), from.trim())?; + if from.eq_ignore_ascii_case("Model") { + return Err("the Model layout cannot be renamed".to_owned()); + } + let to = validated_symbol_name(&to, "a layout name")?; + if to.eq_ignore_ascii_case("Model") { + return Err("a layout cannot be named Model".to_owned()); + } + if from.eq_ignore_ascii_case(&to) { + return Err("the new layout name matches the current one (a case-only change is not supported)".to_owned()); + } + if names_taken(self.app, self.tab, &to) { + return Err(format!("layout {to:?} already exists")); + } + self.push_undo("Rename layout"); + self.app.tabs[self.tab].scene.rename_layout(&from, &to); + // The layout dictionary is keyed by name; keep it in step. + let dict = self.document().header.acad_layout_dict_handle; + if let Some(ObjectType::Dictionary(dictionary)) = self.document_mut().objects.get_mut(&dict) { + for (key, _) in dictionary.entries.iter_mut() { + if *key == from { + *key = to.clone(); + } + } + } + if self.app.tabs[self.tab].scene.current_layout == from { + self.app.tabs[self.tab].scene.set_current_layout(to); + } + self.finish_layout_change(); + Ok(handle) + } + TableOperation::LayoutDelete { name } => { + let (name, handle) = find(self.document(), name.trim())?; + if name.eq_ignore_ascii_case("Model") { + return Err("the Model layout cannot be deleted".to_owned()); + } + self.push_undo("Delete layout"); + if self.app.tabs[self.tab].scene.current_layout == name { + let _ = self.app.on_layout_switch("Model".to_owned()); + } + if !self.app.tabs[self.tab].scene.delete_layout(&name) { + return Err("the layout could not be deleted".to_owned()); + } + self.finish_layout_change(); + Ok(handle) + } + TableOperation::LayoutSetCurrent { name } => { + let name = name.trim(); + if name.eq_ignore_ascii_case("Model") { + let _ = self.app.on_layout_switch("Model".to_owned()); + return Ok(Handle::NULL); + } + let (name, handle) = find(self.document(), name)?; + let _ = self.app.on_layout_switch(name); + self.publish_document_view(); + Ok(handle) + } + TableOperation::LayoutSetPage { name, paper_size, rotation, scale } => { + let (name, handle) = find(self.document(), name.trim())?; + if name.eq_ignore_ascii_case("Model") { + return Err("the Model layout has no sheet setup".to_owned()); + } + if paper_size.is_none() && rotation.is_none() && scale.is_none() { + return Err("no page setup properties to change".to_owned()); + } + if let Some([width, height]) = paper_size { + if !(width.is_finite() && height.is_finite() && width > 0.0 && height > 0.0 && width <= 100_000.0 && height <= 100_000.0) { + return Err("the paper size must be positive and at most 100000 mm each way".to_owned()); + } + } + let rotation_code = match rotation { + None => None, + Some(0) => Some(acadrust::objects::PlotRotation::None), + Some(90) => Some(acadrust::objects::PlotRotation::Degrees90), + Some(180) => Some(acadrust::objects::PlotRotation::Degrees180), + Some(270) => Some(acadrust::objects::PlotRotation::Degrees270), + Some(_) => return Err("the plot rotation must be 0, 90, 180 or 270 degrees".to_owned()), + }; + if let Some([numerator, denominator]) = scale { + if !(numerator.is_finite() && denominator.is_finite() && numerator > 0.0 && denominator > 0.0) { + return Err("the plot scale numerator and denominator must be positive".to_owned()); + } + } + self.push_undo("Layout page setup"); + for object in self.document_mut().objects.values_mut() { + let ObjectType::Layout(layout) = object else { continue }; + if layout.name != name { + continue; + } + if let Some([width, height]) = paper_size { + layout.paper_width = width; + layout.paper_height = height; + layout.paper_size = String::new(); + } + if let Some(code) = rotation_code { + layout.plot_rotation = code.to_code(); + } + if let Some([numerator, denominator]) = scale { + layout.plot_scale_numerator = numerator; + layout.plot_scale_denominator = denominator; + layout.plot_scale_factor = numerator / denominator; + layout.plot_scale_type = acadrust::objects::ScaledType::CustomScale.to_code(); + layout.plot_flags.use_standard_scale = false; + } + let quarter_turn = layout.plot_rotation == acadrust::objects::PlotRotation::Degrees90.to_code() + || layout.plot_rotation == acadrust::objects::PlotRotation::Degrees270.to_code(); + let (x, y) = if quarter_turn { + (layout.paper_height, layout.paper_width) + } else { + (layout.paper_width, layout.paper_height) + }; + layout.min_limits = (0.0, 0.0); + layout.max_limits = (x, y); + layout.min_extents = (0.0, 0.0, 0.0); + layout.max_extents = (x, y, 0.0); + break; + } + self.app.tabs[self.tab].scene.invalidate_display_plot_style(); + self.finish_layout_change(); + Ok(handle) + } + _ => Err("unsupported table operation".to_owned()), + } + } + + fn finish_layout_change(&mut self) { + self.app.tabs[self.tab].dirty = true; + self.app.tabs[self.tab].scene.bump_geometry(); + self.publish_document_view(); + } + + /// Block definition operations: create from entities, modify settings, + /// rename (inserts follow) and delete an unreferenced definition. + fn block_operation(&mut self, operation: ocs_plugin_api::host::TableOperation) -> Result { + use acadrust::entities::EntityType as E; + use ocs_plugin_api::host::TableOperation; + let editable_block = |doc: &CadDocument, name: &str| -> Result<(String, Handle), String> { + let record = doc + .block_records + .get(name) + .ok_or_else(|| format!("block {name:?} does not exist"))?; + if record.is_layout() || record.is_anonymous() || record.flags.is_xref || record.flags.is_xref_overlay || record.name.contains('|') { + return Err(format!("block {:?} is a layout, anonymous or externally referenced block", record.name)); + } + Ok((record.name.clone(), record.handle)) + }; + match operation { + TableOperation::BlockCreate { name, entities, base_point, erase_originals, description } => { + let name = validated_symbol_name(&name, "a block name")?; + if name.starts_with('*') { + return Err("a block name cannot start with '*'".to_owned()); + } + if self.document().block_records.get(&name).is_some() { + return Err(format!("block {name:?} already exists")); + } + if base_point.iter().any(|v| !v.is_finite()) { + return Err("the block base point must be finite".to_owned()); + } + if entities.is_empty() { + return Err("a block needs at least one entity".to_owned()); + } + let mut seen = std::collections::HashSet::new(); + let mut sources = Vec::with_capacity(entities.len()); + for handle in &entities { + if !seen.insert(handle.value()) { + return Err(format!("entity {} is listed twice", handle.value())); + } + let entity = self + .document() + .get_entity(*handle) + .ok_or_else(|| format!("entity {} does not exist", handle.value()))?; + let owner = entity.common().owner_handle; + let in_canvas = self + .document() + .block_records + .iter() + .any(|record| record.handle == owner && record.is_layout()); + if !in_canvas { + return Err(format!("entity {} is not in model or paper space", handle.value())); + } + if matches!(entity, E::Viewport(_) | E::Block(_) | E::BlockEnd(_) | E::AttributeEntity(_) | E::Unknown(_)) { + return Err(format!("entity {} of this kind cannot be placed in a block", handle.value())); + } + sources.push(entity.clone()); + } + if let Some(text) = &description { + if text.chars().any(|c| c.is_control() && c != '\n') { + return Err("a block description cannot contain control characters".to_owned()); + } + } + self.push_undo("Create block"); + let base = glam::DVec3::new(base_point[0], base_point[1], base_point[2]); + self.app.tabs[self.tab] + .scene + .define_block_from_owned_entities(sources, &name, base)?; + let (stored, handle) = editable_block(self.document(), &name)?; + if let Some(record) = self.document_mut().block_records.get_mut(&stored) { + record.base_point = acadrust::types::Vector3::new(base_point[0], base_point[1], base_point[2]); + if let Some(text) = description { + record.description = text; + } + } + let marker = self.document().block_records.get(&stored).map(|r| r.block_entity_handle); + if let Some(marker) = marker { + if let Some(E::Block(block)) = self.document_mut().get_entity_mut(marker) { + block.base_point = acadrust::types::Vector3::new(base_point[0], base_point[1], base_point[2]); + } + } + if erase_originals { + self.app.tabs[self.tab].scene.erase_entities(&entities); + } + self.finish_block_change(); + Ok(handle) + } + TableOperation::BlockEntityAdd { block, mut entity } => { + let (name, record_handle) = editable_block(self.document(), block.trim())?; + if matches!( + entity, + E::Viewport(_) | E::Block(_) | E::BlockEnd(_) | E::AttributeEntity(_) | E::Unknown(_) | E::RasterImage(_) + ) { + return Err("an entity of this kind cannot be added to a block from a script".to_owned()); + } + if self.app.tabs[self.tab].scene.block_edit_block.is_some() { + return Err("finish the open block editor before adding entities to a block".to_owned()); + } + if let E::Insert(insert) = &entity { + if insert.block_name.eq_ignore_ascii_case(&name) || block_nests(self.document(), &insert.block_name, &name, 0) { + return Err(format!("inserting {:?} into {name:?} would nest a block inside itself", insert.block_name)); + } + } + entity.common_mut().owner_handle = record_handle; + ocs_plugin_api::entity_coverage::validate_new_canvas_entity(&entity)?; + ocs_plugin_api::entity_coverage::bind_canvas_entity_references(self.document(), &mut entity)?; + if self.document().layers.get(&entity.common().layer).is_some_and(|layer| layer.is_locked()) { + return Err(format!("layer {:?} is locked", entity.common().layer)); + } + self.push_undo("Add to block"); + entity.common_mut().handle = Handle::NULL; + entity.common_mut().owner_handle = record_handle; + // The scene routes an add into the block being edited; reuse that + // path so the entity gets the same preparation as any new entity. + self.app.tabs[self.tab].scene.block_edit_block = Some(record_handle); + let handle = self.add_entity(entity); + self.app.tabs[self.tab].scene.block_edit_block = None; + if handle.is_null() { + return Err(format!("the entity could not be added to block {name:?}")); + } + self.finish_block_change(); + Ok(handle) + } + TableOperation::BlockModify { name, description, explodable, scale_uniformly } => { + let (name, handle) = editable_block(self.document(), name.trim())?; + if description.is_none() && explodable.is_none() && scale_uniformly.is_none() { + return Err("no block properties to change".to_owned()); + } + if let Some(text) = &description { + if text.chars().any(|c| c.is_control() && c != '\n') { + return Err("a block description cannot contain control characters".to_owned()); + } + } + self.push_undo("Modify block"); + if let Some(record) = self.document_mut().block_records.get_mut(&name) { + if let Some(text) = description { + record.description = text; + } + if let Some(value) = explodable { + record.explodable = value; + } + if let Some(value) = scale_uniformly { + record.scale_uniformly = value; + } + } + self.finish_block_change(); + Ok(handle) + } + TableOperation::BlockRename { from, to } => { + let (from, handle) = editable_block(self.document(), from.trim())?; + let to = validated_symbol_name(&to, "a block name")?; + if to.starts_with('*') { + return Err("a block name cannot start with '*'".to_owned()); + } + if from.eq_ignore_ascii_case(&to) { + return Err("the new block name matches the current one (a case-only change is not supported)".to_owned()); + } + if self.document().block_records.get(&to).is_some() { + return Err(format!("block {to:?} already exists")); + } + self.push_undo("Rename block"); + if !self.app.tabs[self.tab].scene.rename_block(&from, &to) { + return Err("the block could not be renamed".to_owned()); + } + self.finish_block_change(); + Ok(handle) + } + TableOperation::BlockDelete { name } => { + let (name, handle) = editable_block(self.document(), name.trim())?; + let doc = self.document(); + let referenced = doc.entities().any(|entity| match entity { + E::Insert(insert) => insert.block_name.eq_ignore_ascii_case(&name), + E::MultiLeader(leader) => { + leader.block_content_handle == Some(handle) + || leader.context.block_content_handle == Some(handle) + } + _ => false, + }) || doc.dim_styles.iter().any(|style| { + [style.dimblk, style.dimblk1, style.dimblk2, style.dimldrblk].contains(&handle) + }); + if referenced { + return Err(format!("block {name:?} is still inserted or used by a style or leader")); + } + let members: Vec = { + let record = doc.block_records.get(&name).expect("checked above"); + let mut members = record.entity_handles.clone(); + members.push(record.block_entity_handle); + members.push(record.block_end_handle); + members + }; + self.push_undo("Delete block"); + let doc = self.document_mut(); + for member in members { + doc.remove_entity(member); + } + doc.block_records.remove(&name); + let orphans: Vec = { + let live: std::collections::HashSet = doc.block_records.iter().map(|r| r.handle).collect(); + doc.objects + .iter() + .filter_map(|(h, object)| match object { + acadrust::objects::ObjectType::SortEntitiesTable(table) if !live.contains(&table.block_owner_handle) => Some(*h), + _ => None, + }) + .collect() + }; + for orphan in orphans { + doc.objects.remove(&orphan); + } + self.finish_block_change(); + Ok(handle) + } + _ => Err("unsupported table operation".to_owned()), + } + } + + fn finish_block_change(&mut self) { + self.app.tabs[self.tab].dirty = true; + self.app.tabs[self.tab].scene.invalidate_dependency_index(); + self.app.tabs[self.tab].scene.bump_geometry(); + self.publish_document_view(); + } + + /// Text and dimension style operations. They act on the active document + /// because the shared style machinery (in-use scan, rename, ribbon sync) is + /// keyed by the active tab. + fn style_operation(&mut self, operation: ocs_plugin_api::host::TableOperation) -> Result { + use crate::app::style_ops::StyleKind; + use ocs_plugin_api::host::{TableOperation, TableStyleKind}; + if self.tab != self.app.active_tab { + return Err("style operations act on the active document; switch to it first".to_owned()); + } + let kind_of = |kind: TableStyleKind| match kind { + TableStyleKind::Text => StyleKind::Text, + TableStyleKind::Dim => StyleKind::Dim, + }; + let stored = |doc: &CadDocument, kind: TableStyleKind, name: &str| -> Result<(String, Handle), String> { + let found = match kind { + TableStyleKind::Text => doc.text_styles.get(name).map(|s| (s.name.clone(), s.handle)), + TableStyleKind::Dim => doc.dim_styles.get(name).map(|s| (s.name.clone(), s.handle)), + }; + found.ok_or_else(|| format!("{} style {name:?} does not exist", match kind { + TableStyleKind::Text => "text", + TableStyleKind::Dim => "dimension", + })) + }; + match operation { + TableOperation::TextStyleCreate { config } => { + let name = validated_symbol_name(&config.name, "a text style name")?; + if self.app.style_exists(StyleKind::Text, &name) { + return Err(format!("text style {name:?} already exists")); + } + let mut style = acadrust::tables::TextStyle::new(name.clone()); + apply_text_style_config(&mut style, &config)?; + self.push_undo("Create text style"); + let handle = self.document_mut().allocate_handle(); + style.handle = handle; + self.document_mut().text_styles.add(style).map_err(|e| format!("the text style could not be created: {e}"))?; + self.finish_style_change(StyleKind::Text); + Ok(handle) + } + TableOperation::TextStyleModify { config } => { + let (name, handle) = stored(self.document(), TableStyleKind::Text, config.name.trim())?; + let mut style = self.document().text_styles.get(&name).cloned().expect("checked above"); + let before = style.clone(); + if config == (ocs_plugin_api::host::TextStyleConfig { name: config.name.clone(), ..Default::default() }) { + return Err("no text style properties to change".to_owned()); + } + apply_text_style_config(&mut style, &config)?; + if style == before { + return Ok(handle); + } + self.push_undo("Modify text style"); + if let Some(slot) = self.document_mut().text_styles.get_mut(&name) { + *slot = style; + } + self.finish_style_change(StyleKind::Text); + Ok(handle) + } + TableOperation::DimStyleCreate { name, copy_from, properties } => { + let name = validated_symbol_name(&name, "a dimension style name")?; + if self.app.style_exists(StyleKind::Dim, &name) { + return Err(format!("dimension style {name:?} already exists")); + } + let base = match ©_from { + Some(source) => { + let (source, _) = stored(self.document(), TableStyleKind::Dim, source.trim())?; + self.document().dim_styles.get(&source).cloned().expect("checked above") + } + None => acadrust::tables::DimStyle::new(name.clone()), + }; + let mut style = apply_dim_style_json(self.document(), &base, &properties)?; + self.push_undo("Create dimension style"); + let handle = self.document_mut().allocate_handle(); + style.handle = handle; + style.name = name; + self.document_mut().dim_styles.add(style).map_err(|e| format!("the dimension style could not be created: {e}"))?; + self.finish_style_change(StyleKind::Dim); + Ok(handle) + } + TableOperation::DimStyleModify { name, properties } => { + let (name, handle) = stored(self.document(), TableStyleKind::Dim, name.trim())?; + let base = self.document().dim_styles.get(&name).cloned().expect("checked above"); + let style = apply_dim_style_json(self.document(), &base, &properties)?; + if style == base { + return Ok(handle); + } + self.push_undo("Modify dimension style"); + if let Some(slot) = self.document_mut().dim_styles.get_mut(&name) { + *slot = style; + } + self.finish_style_change(StyleKind::Dim); + Ok(handle) + } + TableOperation::StyleRename { kind, from, to } => { + let (from, handle) = stored(self.document(), kind, from.trim())?; + if from.eq_ignore_ascii_case("Standard") { + return Err("the Standard style cannot be renamed".to_owned()); + } + let to = validated_symbol_name(&to, "a style name")?; + if from.eq_ignore_ascii_case(&to) { + return Err("the new style name matches the current one (a case-only change is not supported)".to_owned()); + } + if self.app.style_exists(kind_of(kind), &to) { + return Err(format!("style {to:?} already exists")); + } + self.push_undo("Rename style"); + self.app.rename_style_storage(kind_of(kind), &from, &to); + if kind == TableStyleKind::Text { + // A dimension style names its text style; follow the rename. + for dim in self.document_mut().dim_styles.iter_mut() { + if dim.dimtxsty.eq_ignore_ascii_case(&from) { + dim.dimtxsty = to.clone(); + } + } + } + self.finish_style_change(kind_of(kind)); + Ok(handle) + } + TableOperation::StyleDelete { kind, name } => { + let (name, handle) = stored(self.document(), kind, name.trim())?; + if name.eq_ignore_ascii_case("Standard") { + return Err("the Standard style cannot be deleted".to_owned()); + } + if self.app.style_in_use(kind_of(kind), &name) { + return Err(format!("style {name:?} is current or still in use")); + } + self.push_undo("Delete style"); + if !self.app.remove_style_storage(kind_of(kind), &name) { + return Err("the style could not be deleted".to_owned()); + } + self.finish_style_change(kind_of(kind)); + Ok(handle) + } + TableOperation::StyleSetCurrent { kind, name } => { + let (name, handle) = stored(self.document(), kind, name.trim())?; + let header = &mut self.document_mut().header; + match kind { + TableStyleKind::Text => { + header.current_text_style_handle = handle; + header.current_text_style_name = name.clone(); + self.app.ribbon.active_text_style = name; + } + TableStyleKind::Dim => { + header.current_dimstyle_handle = handle; + header.current_dimstyle_name = name.clone(); + self.app.ribbon.active_dim_style = name; + } + } + self.app.tabs[self.tab].dirty = true; + Ok(handle) + } + _ => Err("unsupported table operation".to_owned()), + } + } + + fn finish_style_change(&mut self, kind: crate::app::style_ops::StyleKind) { + self.app.tabs[self.tab].dirty = true; + self.app.after_style_change(kind); + self.app.tabs[self.tab].scene.bump_geometry(); + self.publish_document_view(); + } + + fn finish_layer_change(&mut self, names: &[String]) { + self.app.tabs[self.tab].dirty = true; + self.app.tabs[self.tab].scene.invalidate_layer_dependencies(names); + self.app.refresh_layer_panel(); + self.publish_document_view(); + } + pub fn modify_layer(&mut self, config: ocs_plugin_api::host::LayerConfig) -> bool { let trimmed = config.name.trim(); if trimmed.is_empty() { @@ -378,6 +2287,19 @@ impl<'a> HostSession<'a> { let has_newline = cmd.ends_with('\n') || cmd.ends_with('\r'); let trimmed = cmd.trim(); + // The same two guards `run_command` applies: this is the other route a + // script reaches the command line by, so it cannot be the unguarded one. + // Cancelling stays allowed, as it does there. + let cancelling = trimmed.eq_ignore_ascii_case("ESC") + || trimmed.eq_ignore_ascii_case("ESCAPE") + || trimmed.eq_ignore_ascii_case("CANCEL"); + if !cancelling + && (self.script_commands_allowed().is_err() + || self.script_command_allowed(trimmed).is_err()) + { + return false; + } + // 1. ESC / CANCEL handling if trimmed.eq_ignore_ascii_case("ESC") || trimmed.eq_ignore_ascii_case("ESCAPE") @@ -449,6 +2371,143 @@ impl<'a> HostSession<'a> { } } +/// True when block `outer` (transitively) contains an insert of `target`. +fn block_nests(doc: &CadDocument, outer: &str, target: &str, depth: usize) -> bool { + if depth > 64 { + return true; + } + let Some(record) = doc.block_records.get(outer) else { + return false; + }; + record.entity_handles.iter().filter_map(|h| doc.get_entity(*h)).any(|entity| match entity { + EntityType::Insert(insert) => { + insert.block_name.eq_ignore_ascii_case(target) || block_nests(doc, &insert.block_name, target, depth + 1) + } + _ => false, + }) +} + +/// A layer name AutoCAD would accept: 1-255 characters, none of `<>/\":;?*|=\``. +fn validated_layer_name(raw: &str) -> Result { + validated_symbol_name(raw, "a layer name") +} + +/// A symbol-table name AutoCAD would accept (layers, styles): 1-255 characters, +/// none of `<>/\":;?*|=` or a backquote, and no control characters. +fn validated_symbol_name(raw: &str, what: &str) -> Result { + let name = raw.trim(); + if name.is_empty() { + return Err(format!("{what} cannot be empty")); + } + if name.chars().count() > 255 { + return Err(format!("{what} is limited to 255 characters")); + } + if let Some(bad) = name.chars().find(|c| "<>/\\\":;?*|=`".contains(*c) || c.is_control()) { + return Err(format!("{what} cannot contain {bad:?}")); + } + Ok(name.to_owned()) +} + +fn apply_text_style_config( + style: &mut acadrust::tables::TextStyle, + config: &ocs_plugin_api::host::TextStyleConfig, +) -> Result<(), String> { + let text = |what: &str, value: &str| { + if value.chars().any(|c| c.is_control()) || value.chars().count() > 255 { + Err(format!("{what} must be text of at most 255 characters")) + } else { + Ok(value.trim().to_owned()) + } + }; + if let Some(height) = config.height { + if !height.is_finite() || height < 0.0 { + return Err("text style height must be finite and not negative (0 = variable)".to_owned()); + } + style.height = height; + } + if let Some(width) = config.width_factor { + if !width.is_finite() || width <= 0.0 || width > 100.0 { + return Err("text style width factor must be greater than 0 and at most 100".to_owned()); + } + style.width_factor = width; + } + if let Some(angle) = config.oblique_angle { + if !angle.is_finite() || angle.abs() > 85f64.to_radians() + 1e-9 { + return Err("text style oblique angle must be within 85 degrees either way".to_owned()); + } + style.oblique_angle = angle; + } + if let Some(font) = &config.font_file { + style.font_file = text("font file", font)?; + } + if let Some(font) = &config.big_font_file { + style.big_font_file = text("big font file", font)?; + } + if let Some(font) = &config.true_type_font { + style.true_type_font = text("TrueType font", font)?; + } + if let Some(value) = config.backward { + style.flags.backward = value; + } + if let Some(value) = config.upside_down { + style.flags.upside_down = value; + } + if let Some(value) = config.vertical { + style.is_vertical = value; + } + if let Some(value) = config.annotative { + style.annotative = value; + } + if style.font_file.is_empty() && style.true_type_font.is_empty() { + return Err("a text style needs a font file or a TrueType font".to_owned()); + } + Ok(()) +} + +/// Apply a JSON object of DimStyle fields on top of `base`. Handles and xref +/// fields belong to the host and the name is set separately, so those keys are +/// refused; unknown keys and wrongly typed values are refused too. `dimtxsty` +/// must name an existing text style, whose handle is linked here. +fn apply_dim_style_json( + doc: &CadDocument, + base: &acadrust::tables::DimStyle, + properties: &str, +) -> Result { + let patch: serde_json::Value = serde_json::from_str(properties) + .map_err(|e| format!("dimension style properties are not valid JSON: {e}"))?; + let serde_json::Value::Object(patch) = patch else { + return Err("dimension style properties must be an object".to_owned()); + }; + let mut value = serde_json::to_value(base).map_err(|e| e.to_string())?; + let object = value.as_object_mut().ok_or("dimension style did not serialize to an object")?; + for (key, new) in patch { + if key == "handle" || key == "name" || key.starts_with("xref_") || key.ends_with("_handle") { + return Err(format!("dimension style property {key:?} is managed by the host")); + } + if !object.contains_key(&key) { + return Err(format!("unknown dimension style property {key:?}")); + } + object.insert(key, new); + } + let mut style: acadrust::tables::DimStyle = serde_json::from_value(value) + .map_err(|e| format!("invalid dimension style value: {e}"))?; + if !(style.dimscale.is_finite() && style.dimscale > 0.0) { + return Err("dimscale must be greater than zero".to_owned()); + } + if !(style.dimtxt.is_finite() && style.dimtxt > 0.0) { + return Err("dimtxt (text height) must be greater than zero".to_owned()); + } + if !(style.dimasz.is_finite() && style.dimasz >= 0.0) { + return Err("dimasz (arrow size) cannot be negative".to_owned()); + } + let Some(text_style) = doc.text_styles.get(&style.dimtxsty) else { + return Err(format!("dimtxsty {:?} is not a text style in this drawing", style.dimtxsty)); + }; + style.dimtxsty = text_style.name.clone(); + style.dimtxsty_handle = text_style.handle; + Ok(style) +} + /// The stored spelling of `name` in the drawing's linetype table, loading the /// standard linetypes first when it is not there yet. `None` when the drawing /// cannot supply it: a layer must not reference a linetype that does not exist. @@ -484,6 +2543,25 @@ impl HostApi for HostSession<'_> { fn update_entity(&mut self, entity: EntityType) -> bool { self.update_entity(entity) } + fn update_entities_transaction( + &mut self, + label: &str, + entities: Vec, + ) -> Result<(), String> { + self.update_entities_transaction(label, entities) + } + fn selection(&self) -> Vec { + self.selection() + } + fn set_selection(&mut self, handles: &[Handle]) -> Result<(), String> { + self.set_selection(handles) + } + fn solid_operation( + &mut self, + operation: ocs_plugin_api::host::SolidOperation, + ) -> Result { + self.solid_operation(operation) + } fn remove_entity(&mut self, handle: Handle) -> bool { self.remove_entity(handle) } @@ -553,6 +2631,16 @@ impl HostApi for HostSession<'_> { fn document_path(&self, tab_id: u64) -> Option { self.document_path(tab_id) } + fn system_variable(&self, name: &str) -> Option { + self.system_variable(name) + } + fn set_system_variable( + &mut self, + name: &str, + value: HostSettingValue, + ) -> Result { + self.set_system_variable(name, value) + } #[cfg(not(target_arch = "wasm32"))] fn document_view_v4(&mut self, tab_id: u64) -> Option { self.document_view_v4(tab_id) @@ -570,6 +2658,12 @@ impl HostApi for HostSession<'_> { fn execute_command(&mut self, cmd: &str) -> bool { self.execute_command(cmd) } + fn table_operation(&mut self, operation: ocs_plugin_api::host::TableOperation) -> Result { + self.table_operation(operation) + } + fn run_command(&mut self, request: ocs_plugin_api::host::CommandRequest) -> Result { + self.run_command(request) + } } /// Bridges a plugin's [`InteractiveCommand`](ocs_plugin_api::host::InteractiveCommand) @@ -662,7 +2756,10 @@ impl PluginProcessInteractiveAdapter { impl Drop for PluginProcessInteractiveAdapter { fn drop(&mut self) { + // Tell the command it was cancelled before the runner forgets it: + // a plugin waiting on a step would otherwise never be released. self.cancel(); + let _ = self.process.drop_interactive(self.command_id); } } @@ -677,10 +2774,7 @@ impl crate::command::CadCommand for PluginProcessInteractiveAdapter { use ocs_plugin_api::ipc::protocol::InteractiveEvent; let result = self .process - .interactive_event( - self.command_id, - InteractiveEvent::Point([pt.x, pt.y, pt.z]), - ) + .interactive_event(self.command_id, InteractiveEvent::Point([pt.x, pt.y, pt.z])) .map(plugin_step_to_result) .unwrap_or(crate::command::CmdResult::Cancel); if matches!(result, crate::command::CmdResult::CommitAndExit(_) | crate::command::CmdResult::Cancel) { @@ -745,10 +2839,6219 @@ fn plugin_step_to_result(step: ocs_plugin_api::host::CommandStep) -> crate::comm mod tests { use super::*; use crate::app::OpenCADStudio; - use acadrust::entities::Point; + use crate::entities::traits::RenderConvertible; + use acadrust::entities::{Line, Point}; use acadrust::xdata::XDataValue; use ocs_plugin_api::host::DocumentReader; + #[test] + fn selection_query_is_live_and_replacement_is_validated() { + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let a = host.add_entity(EntityType::Point(Point::new())); + let b = host.add_entity(EntityType::Point(Point::new())); + assert!(host.selection().is_empty()); + host.set_selection(&[b, a]).unwrap(); + assert_eq!(host.selection(), vec![b, a]); + assert!(host.set_selection(&[Handle::new(99999)]).is_err()); + assert!(host.set_selection(&[a, a]).is_err()); + assert_eq!(host.selection(), vec![b, a]); + host.set_selection(&[]).unwrap(); + assert!(host.selection().is_empty()); + } + + #[test] + fn nested_insert_attribute_crud_uses_parent_storage_and_undo() { + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let (insert_handle, attribute_handle); + { + let mut host = HostSession::new(&mut app, 0); + let next = host.document().next_handle(); + let record_handle = Handle::new(next); + let block_handle = Handle::new(next + 1); + let block_end_handle = Handle::new(next + 2); + let mut record = acadrust::tables::BlockRecord::new("TAGBLOCK"); + record.handle = record_handle; + record.block_entity_handle = block_handle; + record.block_end_handle = block_end_handle; + host.document_mut().block_records.add(record).unwrap(); + let mut block = + acadrust::entities::Block::new("TAGBLOCK", acadrust::types::Vector3::ZERO); + block.common.handle = block_handle; + block.common.owner_handle = record_handle; + host.document_mut() + .add_entity(EntityType::Block(block)) + .unwrap(); + let mut end = acadrust::entities::BlockEnd::new(); + end.common.handle = block_end_handle; + end.common.owner_handle = record_handle; + host.document_mut() + .add_entity(EntityType::BlockEnd(end)) + .unwrap(); + let mut definition = acadrust::entities::AttributeDefinition::new( + "PART_NO".into(), + "Part number".into(), + "PN-001".into(), + ); + definition.common.owner_handle = record_handle; + let definition_handle = host + .document_mut() + .add_entity(EntityType::AttributeDefinition(definition)) + .unwrap(); + insert_handle = host + .document_mut() + .add_entity(EntityType::Insert(acadrust::entities::Insert::new( + "TAGBLOCK", + acadrust::types::Vector3::new(10.0, 0.0, 0.0), + ))) + .unwrap(); + + let mut attribute = + acadrust::entities::AttributeEntity::new("PART_NO".into(), "PN-101".into()); + attribute.common.owner_handle = insert_handle; + attribute.attdef_handle = definition_handle; + attribute.insertion_point = acadrust::types::Vector3::new(10.0, 2.0, 0.0); + host.push_undo("Create attribute"); + attribute_handle = host.add_entity(EntityType::AttributeEntity(attribute)); + assert!(!attribute_handle.is_null()); + assert!( + host.document().get_entity(attribute_handle).is_none(), + "nested attributes must not become orphan flat entities" + ); + assert!(matches!(host.host_model_entity(attribute_handle), + Some(EntityType::AttributeEntity(value)) if value.value == "PN-101")); + let view = ocs_plugin_api::shm::DocumentViewDataV4::from(host.document()); + assert!(view + .entities + .iter() + .any(|entity| entity.handle == attribute_handle.value())); + + let mut changed = host.host_model_entity(attribute_handle).unwrap(); + let EntityType::AttributeEntity(value) = &mut changed else { + unreachable!() + }; + value.value = "PN-102".into(); + value.insertion_point.x = 12.0; + host.update_entities_transaction("Edit attribute", vec![changed]) + .unwrap(); + assert!(matches!(host.host_model_entity(attribute_handle), + Some(EntityType::AttributeEntity(value)) + if value.value == "PN-102" && value.insertion_point.x == 12.0)); + + host.push_undo("Delete attribute"); + assert!(host.remove_entity(attribute_handle)); + assert!(host.host_model_entity(attribute_handle).is_none()); + } + app.finish_pending_history(0); + assert_eq!(app.tabs[0].history.undo_stack.len(), 3); + app.undo_steps(1); + assert!( + matches!(app.tabs[0].scene.document.get_entity(insert_handle), + Some(EntityType::Insert(insert)) if insert.attributes.len() == 1 + && insert.attributes[0].value == "PN-102") + ); + app.undo_steps(1); + assert!( + matches!(app.tabs[0].scene.document.get_entity(insert_handle), + Some(EntityType::Insert(insert)) if insert.attributes[0].value == "PN-101") + ); + app.undo_steps(1); + assert!( + matches!(app.tabs[0].scene.document.get_entity(insert_handle), + Some(EntityType::Insert(insert)) if insert.attributes.is_empty()) + ); + app.redo_steps(3); + assert!( + matches!(app.tabs[0].scene.document.get_entity(insert_handle), + Some(EntityType::Insert(insert)) if insert.attributes.is_empty()) + ); + } + + #[test] + fn scripted_selection_does_not_expand_linked_leader_annotation() { + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let annotation = host.add_entity(EntityType::Text(acadrust::entities::Text::default())); + let mut leader = acadrust::entities::Leader::default(); + leader.annotation_handle = annotation; + let leader_handle = host.add_entity(EntityType::Leader(leader)); + host.set_selection(&[leader_handle]).unwrap(); + assert_eq!(host.selection(), vec![leader_handle]); + } + + #[test] + fn entity_transaction_validates_before_mutation_and_undoes_as_one_step() { + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let (first, second); + { + let mut host = HostSession::new(&mut app, 0); + first = host.add_entity(EntityType::Point(Point::at(acadrust::types::Vector3::new( + 1.0, 0.0, 0.0, + )))); + second = host.add_entity(EntityType::Point(Point::at(acadrust::types::Vector3::new( + 2.0, 0.0, 0.0, + )))); + let mut a = host.document().get_entity(first).unwrap().clone(); + let mut b = host.document().get_entity(second).unwrap().clone(); + if let EntityType::Point(p) = &mut a { + p.location.x = 10.0; + } + if let EntityType::Point(p) = &mut b { + p.location.x = 20.0; + } + let mut invalid = b.clone(); + invalid.common_mut().owner_handle = Handle::new(99999); + assert!(host + .update_entities_transaction("Move points", vec![a.clone(), invalid]) + .is_err()); + let mut invalid_geometry = b.clone(); + if let EntityType::Point(p) = &mut invalid_geometry { + p.location.x = f64::NAN; + } + assert!(host + .update_entities_transaction("Move points", vec![a.clone(), invalid_geometry]) + .unwrap_err() + .contains("Point.location")); + assert_eq!(host.app.tabs[0].history.undo_stack.len(), 0); + assert!( + matches!(host.document().get_entity(first), Some(EntityType::Point(p)) if p.location.x == 1.0) + ); + host.update_entities_transaction("Move points", vec![a, b]) + .unwrap(); + assert!( + matches!(host.document().get_entity(first), Some(EntityType::Point(p)) if p.location.x == 10.0) + ); + } + app.finish_pending_history(0); + assert_eq!(app.tabs[0].history.undo_stack.len(), 1); + app.undo_steps(1); + assert!( + matches!(app.tabs[0].scene.document.get_entity(first), Some(EntityType::Point(p)) if p.location.x == 1.0) + ); + assert!( + matches!(app.tabs[0].scene.document.get_entity(second), Some(EntityType::Point(p)) if p.location.x == 2.0) + ); + app.redo_steps(1); + assert!( + matches!(app.tabs[0].scene.document.get_entity(first), Some(EntityType::Point(p)) if p.location.x == 10.0) + ); + assert!( + matches!(app.tabs[0].scene.document.get_entity(second), Some(EntityType::Point(p)) if p.location.x == 20.0) + ); + } + + /// Opt-in end-to-end check using the staged Python cdylib and the actual + /// OCS executable as its out-of-process runner. Run with OCS_TEST_PYTHON_PLUGIN + /// and OCS_PLUGIN_RUNNER_EXE set; unlike the local-socket protocol test, + /// this executes Python and calls the app's HostSession over real IPC. + #[test] + fn staged_python_plugin_line_lifecycle_over_real_ipc() { + let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { + return; + }; + let plugin_path = std::path::PathBuf::from(plugin_path); + assert!( + plugin_path.is_file(), + "missing staged Python plugin: {}", + plugin_path.display() + ); + let runner_path = std::env::var_os("OCS_PLUGIN_RUNNER_EXE") + .expect("set OCS_PLUGIN_RUNNER_EXE to the built OpenCADStudio executable"); + assert!(std::path::Path::new(&runner_path).is_file()); + + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let process = ocs_plugin_api::process::PluginProcess::spawn( + &plugin_path, + &mut host, + crate::plugin::v4_support::notification_handler(), + ) + .expect("spawn staged Python plugin"); + assert_eq!(process.id(), "opencad.python"); + let dispatch = |host: &mut HostSession<'_>, command: &str| { + assert!(process + .dispatch(host, command, &mut |_| {}) + .expect("Python dispatch")); + }; + dispatch( + &mut host, + concat!( + "PY_EVAL ocs.active_document.create_entity('Line',", + "start={'x':0.0,'y':0.0,'z':0.0},", + "end={'x':1.0,'y':0.0,'z':0.0}).handle" + ), + ); + let handles: Vec<_> = host + .document() + .entities() + .map(|entity| entity.common().handle) + .collect(); + assert_eq!(handles.len(), 1, "Python add did not reach the host"); + let handle = handles[0]; + assert!( + matches!(host.document().get_entity(handle), Some(EntityType::Line(line)) if line.end.x == 1.0) + ); + let before_edit = host.document().get_entity(handle).unwrap().clone(); + dispatch(&mut host, &format!( + "PY_EVAL ocs.update_many('Move line', [{{'handle':{},'end':{{'x':5.0,'y':0.0,'z':0.0}}}}])", + handle.value() + )); + let mut expected_after_edit = before_edit; + let EntityType::Line(expected_line) = &mut expected_after_edit else { + unreachable!() + }; + expected_line.end.x = 5.0; + assert_eq!( + host.document().get_entity(handle), + Some(&expected_after_edit), + "partial Python edit must preserve every unmentioned common and geometry field" + ); + dispatch(&mut host, &format!( + "PY_EVAL ocs.update_many('Reject NaN', [{{'handle':{},'start':{{'x':float('nan'),'y':0.0,'z':0.0}}}}])", + handle.value() + )); + assert_eq!( + host.document().get_entity(handle), + Some(&expected_after_edit), + "invalid geometry changed the line" + ); + assert!(host + .app + .command_line + .history + .last() + .unwrap() + .text + .contains("finite coordinates")); + dispatch(&mut host, &format!( + "PY_EVAL ocs.update_many('Reject duplicate', [{{'handle':{},'end':{{'x':8.0,'y':0.0,'z':0.0}}}},{{'handle':{},'end':{{'x':9.0,'y':0.0,'z':0.0}}}}])", + handle.value(), handle.value() + )); + assert!( + matches!(host.document().get_entity(handle), Some(EntityType::Line(line)) if line.end.x == 5.0), + "rejected batch changed the line" + ); + let dwg_bytes = + acadrust::DwgWriter::write_to_vec(host.document()).expect("write edited DWG"); + let dwg_doc = acadrust::DwgReader::from_stream(std::io::Cursor::new(dwg_bytes)) + .read() + .expect("reopen edited DWG"); + assert!( + matches!(dwg_doc.get_entity(handle), Some(EntityType::Line(line)) if line.end.x == 5.0) + ); + let dxf_bytes = acadrust::DxfWriter::new(host.document()) + .write_to_vec() + .expect("write edited DXF"); + let dxf_doc = acadrust::DxfReader::from_reader(std::io::Cursor::new(dxf_bytes)) + .expect("open edited DXF") + .read() + .expect("reopen edited DXF"); + assert!( + matches!(dxf_doc.get_entity(handle), Some(EntityType::Line(line)) if line.end.x == 5.0) + ); + dispatch( + &mut host, + &format!( + "PY_EVAL ocs.active_document.delete_entity({})", + handle.value() + ), + ); + assert!( + host.document().get_entity(handle).is_none(), + "Python removal did not reach the host" + ); + drop(process); + drop(host); + app.finish_pending_history(0); + assert_eq!( + app.tabs[0].history.undo_stack.len(), + 3, + "create, edit, and delete should each form one undo entry" + ); + app.undo_steps(1); + assert!( + matches!(app.tabs[0].scene.document.get_entity(handle), Some(EntityType::Line(line)) if line.end.x == 5.0) + ); + app.undo_steps(1); + assert!( + matches!(app.tabs[0].scene.document.get_entity(handle), Some(EntityType::Line(line)) if line.end.x == 1.0) + ); + app.undo_steps(1); + assert!(app.tabs[0].scene.document.get_entity(handle).is_none()); + app.redo_steps(3); + assert!(app.tabs[0].scene.document.get_entity(handle).is_none()); + } + + #[test] + fn staged_python_tolerance_lifecycle_over_real_ipc() { + let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { + return; + }; + let runner_path = std::env::var_os("OCS_PLUGIN_RUNNER_EXE") + .expect("set OCS_PLUGIN_RUNNER_EXE to the built OpenCADStudio executable"); + assert!(std::path::Path::new(&runner_path).is_file()); + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let process = ocs_plugin_api::process::PluginProcess::spawn( + std::path::Path::new(&plugin_path), + &mut host, + crate::plugin::v4_support::notification_handler(), + ) + .expect("spawn staged Python plugin"); + let dispatch = |host: &mut HostSession<'_>, command: &str| { + assert!(process + .dispatch(host, command, &mut |_| {}) + .expect("Python dispatch")); + }; + dispatch( + &mut host, + concat!( + "PY_EVAL ocs.active_document.create_entity('Tolerance',", + "insertion_point={'x':1.0,'y':2.0,'z':0.0},", + "text='POSITION%%v0.1').handle" + ), + ); + let handle = host + .document() + .entities() + .next() + .expect("Python created a Tolerance") + .common() + .handle; + let Some(EntityType::Tolerance(created)) = host.document().get_entity(handle) else { + panic!("expected Tolerance"); + }; + assert_eq!( + created.insertion_point, + acadrust::types::Vector3::new(1.0, 2.0, 0.0) + ); + assert_eq!(created.dimension_style_name, "Standard"); + let created = created.clone(); + dispatch( + &mut host, + &format!( + "PY_EVAL ocs.active_document.entities[{}].text", + handle.value() + ), + ); + assert!(host + .app + .command_line + .history + .last() + .unwrap() + .text + .contains("POSITION%%v0.1")); + + let script_path = std::env::temp_dir().join(format!( + "ocs_tolerance_document_model_{}.py", + std::process::id() + )); + std::fs::write( + &script_path, + format!( + concat!( + "doc = ocs.active_document\n", + "tolerance = doc.entities[{}]\n", + "with doc.transaction('Edit tolerance'):\n", + " tolerance.insertion_point = (4.0, 5.0, 0.0)\n", + " tolerance.direction = (0.0, 1.0, 0.0)\n", + " tolerance.text = 'POSITION%%v0.2'\n", + "doc.selection = [tolerance]\n" + ), + handle.value() + ), + ) + .unwrap(); + dispatch(&mut host, &format!("PY_RUN {}", script_path.display())); + let _ = std::fs::remove_file(&script_path); + let expected = host.document().get_entity(handle).unwrap().clone(); + let EntityType::Tolerance(edited) = &expected else { + unreachable!() + }; + assert_eq!( + edited.insertion_point, + acadrust::types::Vector3::new(4.0, 5.0, 0.0) + ); + assert_eq!(edited.direction, acadrust::types::Vector3::UNIT_Y); + assert_eq!(edited.text, "POSITION%%v0.2"); + assert_eq!(edited.common, created.common); + assert_eq!(edited.normal, created.normal); + assert_eq!(edited.dimension_style_name, created.dimension_style_name); + assert_eq!( + edited.dimension_style_handle, + created.dimension_style_handle + ); + assert_eq!(edited.text_height, created.text_height); + assert_eq!(edited.dimension_gap, created.dimension_gap); + assert_eq!(edited.dwg_unknown_short, created.dwg_unknown_short); + assert_eq!( + host.selection(), + vec![handle], + "Python selection did not reach the canvas" + ); + + dispatch(&mut host, &format!( + "PY_EVAL ocs.update_many('Reject direction', [{{'handle':{},'direction':{{'x':2.0,'y':0.0,'z':0.0}}}}])", + handle.value())); + assert_eq!(host.document().get_entity(handle), Some(&expected)); + assert!(host + .app + .command_line + .history + .last() + .unwrap() + .text + .contains("unit vector")); + dispatch(&mut host, &format!( + "PY_EVAL ocs.update_many('Reject style', [{{'handle':{},'dimension_style_name':'Missing'}}])", + handle.value())); + assert_eq!(host.document().get_entity(handle), Some(&expected)); + assert!(host + .app + .command_line + .history + .last() + .unwrap() + .text + .contains("does not exist")); + + let dwg_bytes = acadrust::DwgWriter::write_to_vec(host.document()).unwrap(); + let dwg_doc = acadrust::DwgReader::from_stream(std::io::Cursor::new(dwg_bytes)) + .read() + .unwrap(); + assert!( + matches!(dwg_doc.get_entity(handle), Some(EntityType::Tolerance(value)) + if value.insertion_point.x == 4.0 && value.text == "POSITION%%v0.2") + ); + let dxf_bytes = acadrust::DxfWriter::new(host.document()) + .write_to_vec() + .unwrap(); + let dxf_doc = acadrust::DxfReader::from_reader(std::io::Cursor::new(dxf_bytes)) + .unwrap() + .read() + .unwrap(); + assert!( + matches!(dxf_doc.get_entity(handle), Some(EntityType::Tolerance(value)) + if value.insertion_point.x == 4.0 && value.text == "POSITION%%v0.2") + ); + + dispatch( + &mut host, + &format!( + "PY_EVAL ocs.active_document.delete_entity({})", + handle.value() + ), + ); + assert!(host.document().get_entity(handle).is_none()); + drop(process); + drop(host); + app.finish_pending_history(0); + assert_eq!(app.tabs[0].history.undo_stack.len(), 3); + app.undo_steps(1); + assert_eq!( + app.tabs[0].scene.document.get_entity(handle), + Some(&expected) + ); + app.undo_steps(1); + assert!( + matches!(app.tabs[0].scene.document.get_entity(handle), Some(EntityType::Tolerance(value)) + if value.insertion_point.x == 1.0 && value.text == "POSITION%%v0.1") + ); + app.undo_steps(1); + assert!(app.tabs[0].scene.document.get_entity(handle).is_none()); + app.redo_steps(3); + assert!(app.tabs[0].scene.document.get_entity(handle).is_none()); + } + + #[test] + fn staged_python_shape_lifecycle_over_real_ipc() { + let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { + return; + }; + let runner_path = std::env::var_os("OCS_PLUGIN_RUNNER_EXE") + .expect("set OCS_PLUGIN_RUNNER_EXE to the built OpenCADStudio executable"); + assert!(std::path::Path::new(&runner_path).is_file()); + + let shx_path = + std::env::temp_dir().join(format!("ocs_host_shape_{}.shx", std::process::id())); + let mut shx = b"AutoCAD-86 shapes 1.0\r\n\x1A".to_vec(); + for value in [1_u16, 1, 1, 1, 9] { + shx.extend_from_slice(&value.to_le_bytes()); + } + shx.extend_from_slice(b"ARROW\0\x10\x14\0"); + std::fs::write(&shx_path, shx).unwrap(); + + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let style_handle = host.document_mut().allocate_handle(); + let mut style = acadrust::tables::TextStyle::new("TestShapes"); + style.handle = style_handle; + style.is_shape_file = true; + style.font_file = shx_path.to_string_lossy().into_owned(); + host.document_mut().text_styles.add(style).unwrap(); + + let process = ocs_plugin_api::process::PluginProcess::spawn( + std::path::Path::new(&plugin_path), + &mut host, + crate::plugin::v4_support::notification_handler(), + ) + .expect("spawn staged Python plugin"); + let dispatch = |host: &mut HostSession<'_>, command: &str| { + assert!(process + .dispatch(host, command, &mut |_| {}) + .expect("Python dispatch")); + }; + dispatch( + &mut host, + concat!( + "PY_EVAL ocs.active_document.create_entity('Shape',", + "insertion_point={'x':1.0,'y':2.0,'z':0.0},size=2.0,", + "shape_name='ARROW',shape_number=1,style_name='TestShapes').handle" + ), + ); + let handle = host + .document() + .entities() + .next() + .expect("Python created a Shape") + .common() + .handle; + let Some(EntityType::Shape(created)) = host.document().get_entity(handle) else { + panic!("expected Shape"); + }; + assert_eq!(created.style_handle, Some(style_handle)); + let created = created.clone(); + dispatch( + &mut host, + &format!( + "PY_EVAL ocs.active_document.entities[{}].shape_name", + handle.value() + ), + ); + assert!(host + .app + .command_line + .history + .last() + .unwrap() + .text + .contains("ARROW")); + + let script_path = std::env::temp_dir().join(format!( + "ocs_shape_document_model_{}.py", + std::process::id() + )); + std::fs::write( + &script_path, + format!( + concat!( + "doc = ocs.active_document\n", + "shape = doc.entities[{}]\n", + "with doc.transaction('Edit shape'):\n", + " shape.insertion_point = (4.0, 5.0, 0.0)\n", + " shape.size = 3.0\n", + " shape.rotation = 0.5\n", + " shape.relative_x_scale = 1.5\n", + "doc.selection = [shape]\n" + ), + handle.value() + ), + ) + .unwrap(); + dispatch(&mut host, &format!("PY_RUN {}", script_path.display())); + let _ = std::fs::remove_file(&script_path); + let expected = host.document().get_entity(handle).unwrap().clone(); + let EntityType::Shape(edited) = &expected else { + unreachable!() + }; + assert_eq!( + edited.insertion_point, + acadrust::types::Vector3::new(4.0, 5.0, 0.0) + ); + assert_eq!(edited.size, 3.0); + assert_eq!(edited.rotation, 0.5); + assert_eq!(edited.relative_x_scale, 1.5); + assert_eq!(edited.common, created.common); + assert_eq!(edited.shape_name, created.shape_name); + assert_eq!(edited.shape_number, created.shape_number); + assert_eq!(edited.style_name, created.style_name); + assert_eq!(edited.style_handle, created.style_handle); + assert_eq!(edited.normal, created.normal); + assert_eq!(edited.thickness, created.thickness); + assert_eq!(host.selection(), vec![handle]); + + let assert_real_glyph = |entity: &EntityType, document: &CadDocument| { + let EntityType::Shape(shape) = entity else { + panic!("expected Shape"); + }; + let render = shape.to_render(document).expect("shape render"); + let crate::scene::convert::acad_to_render::RenderObject::Lines(points) = render.object + else { + panic!("expected shape linework"); + }; + assert_eq!( + points.len(), + 3, + "expected SHX glyph rather than diamond placeholder" + ); + assert!(points.iter().flatten().all(|value| value.is_finite())); + }; + assert_real_glyph(&expected, host.document()); + + dispatch( + &mut host, + &format!( + "PY_EVAL ocs.update_many('Reject size', [{{'handle':{},'size':0.0}}])", + handle.value() + ), + ); + assert_eq!(host.document().get_entity(handle), Some(&expected)); + dispatch( + &mut host, + &format!( + "PY_EVAL ocs.update_many('Reject style', [{{'handle':{},'style_name':'Missing'}}])", + handle.value() + ), + ); + assert_eq!(host.document().get_entity(handle), Some(&expected)); + assert!(host + .app + .command_line + .history + .last() + .unwrap() + .text + .contains("does not exist")); + + let dwg_bytes = acadrust::DwgWriter::write_to_vec(host.document()).unwrap(); + let dwg_doc = crate::io::load_bytes("shape.dwg", dwg_bytes).unwrap(); + let dwg_entity = dwg_doc.get_entity(handle).expect("Shape survives DWG"); + assert!(matches!(dwg_entity, EntityType::Shape(value) + if value.shape_number == 1 && value.size == 3.0 && value.rotation == 0.5)); + assert_real_glyph(dwg_entity, &dwg_doc); + let dxf_bytes = acadrust::DxfWriter::new(host.document()) + .write_to_vec() + .unwrap(); + let dxf_doc = crate::io::load_bytes("shape.dxf", dxf_bytes).unwrap(); + let dxf_entity = dxf_doc.get_entity(handle).expect("Shape survives DXF"); + assert!(matches!(dxf_entity, EntityType::Shape(value) + if value.shape_name == "ARROW" && value.size == 3.0 && (value.rotation - 0.5).abs() < 1e-12)); + assert_real_glyph(dxf_entity, &dxf_doc); + + dispatch( + &mut host, + &format!( + "PY_EVAL ocs.active_document.delete_entity({})", + handle.value() + ), + ); + assert!(host.document().get_entity(handle).is_none()); + drop(process); + drop(host); + let _ = std::fs::remove_file(&shx_path); + app.finish_pending_history(0); + assert_eq!(app.tabs[0].history.undo_stack.len(), 3); + app.undo_steps(1); + assert_eq!( + app.tabs[0].scene.document.get_entity(handle), + Some(&expected) + ); + app.undo_steps(1); + assert!( + matches!(app.tabs[0].scene.document.get_entity(handle), Some(EntityType::Shape(value)) + if value.insertion_point.x == 1.0 && value.size == 2.0) + ); + app.undo_steps(1); + assert!(app.tabs[0].scene.document.get_entity(handle).is_none()); + app.redo_steps(3); + assert!(app.tabs[0].scene.document.get_entity(handle).is_none()); + } + + #[test] + fn staged_python_attribute_definition_lifecycle_over_real_ipc() { + let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { + return; + }; + let runner_path = std::env::var_os("OCS_PLUGIN_RUNNER_EXE") + .expect("set OCS_PLUGIN_RUNNER_EXE to the built OpenCADStudio executable"); + assert!(std::path::Path::new(&runner_path).is_file()); + + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let next = host.document().next_handle(); + let block_record_handle = Handle::new(next); + let block_handle = Handle::new(next + 1); + let block_end_handle = Handle::new(next + 2); + let mut block_record = acadrust::tables::BlockRecord::new("TAGBLOCK"); + block_record.handle = block_record_handle; + block_record.block_entity_handle = block_handle; + block_record.block_end_handle = block_end_handle; + host.document_mut().block_records.add(block_record).unwrap(); + let mut block = acadrust::entities::Block::new("TAGBLOCK", acadrust::types::Vector3::ZERO); + block.common.handle = block_handle; + block.common.owner_handle = block_record_handle; + host.document_mut() + .add_entity(EntityType::Block(block)) + .unwrap(); + let mut block_end = acadrust::entities::BlockEnd::new(); + block_end.common.handle = block_end_handle; + block_end.common.owner_handle = block_record_handle; + host.document_mut() + .add_entity(EntityType::BlockEnd(block_end)) + .unwrap(); + let insert = acadrust::entities::Insert::new( + "TAGBLOCK", + acadrust::types::Vector3::new(10.0, 0.0, 0.0), + ); + let insert_handle = host + .document_mut() + .add_entity(EntityType::Insert(insert)) + .unwrap(); + + let process = ocs_plugin_api::process::PluginProcess::spawn( + std::path::Path::new(&plugin_path), + &mut host, + crate::plugin::v4_support::notification_handler(), + ) + .expect("spawn staged Python plugin"); + let dispatch = |host: &mut HostSession<'_>, command: &str| { + assert!(process + .dispatch(host, command, &mut |_| {}) + .expect("Python dispatch")); + }; + dispatch( + &mut host, + &format!( + concat!( + "PY_EVAL ocs.active_document.create_entity('AttributeDefinition',", + "owner_handle={},tag='PART_NO',prompt='Part number',default_value='PN-001',", + "insertion_point={{'x':1.0,'y':2.0,'z':0.0}},height=2.5).handle" + ), + block_record_handle.value() + ), + ); + let handle = host + .document() + .entities() + .find(|entity| matches!(entity, EntityType::AttributeDefinition(_))) + .expect("Python created an AttributeDefinition") + .common() + .handle; + let Some(EntityType::AttributeDefinition(created)) = host.document().get_entity(handle) + else { + panic!("expected AttributeDefinition"); + }; + assert_eq!(created.common.owner_handle, block_record_handle); + assert_eq!(created.tag, "PART_NO"); + assert_eq!(created.text_style, "Standard"); + assert!(host + .document() + .block_records + .get("TAGBLOCK") + .unwrap() + .entity_handles + .contains(&handle)); + let created = created.clone(); + dispatch(&mut host, &format!( + "PY_EVAL (ocs.active_document.entities[{}].tag, ocs.active_document.entities[{}].owner_handle)", + handle.value(), handle.value())); + let readback = &host.app.command_line.history.last().unwrap().text; + assert!( + readback.contains("PART_NO") + && readback.contains(&block_record_handle.value().to_string()) + ); + + let script_path = std::env::temp_dir().join(format!( + "ocs_attribute_definition_document_model_{}.py", + std::process::id() + )); + std::fs::write( + &script_path, + format!( + concat!( + "doc = ocs.active_document\n", + "definition = doc.entities[{}]\n", + "with doc.transaction('Edit attribute definition'):\n", + " definition.insertion_point = (4.0, 5.0, 0.0)\n", + " definition.default_value = 'PN-002'\n", + " definition.rotation = 0.25\n", + " definition.width_factor = 1.25\n", + "doc.selection = [definition]\n" + ), + handle.value() + ), + ) + .unwrap(); + dispatch(&mut host, &format!("PY_RUN {}", script_path.display())); + let _ = std::fs::remove_file(&script_path); + let expected = host.document().get_entity(handle).unwrap().clone(); + let EntityType::AttributeDefinition(edited) = &expected else { + unreachable!() + }; + assert_eq!( + edited.insertion_point, + acadrust::types::Vector3::new(4.0, 5.0, 0.0) + ); + assert_eq!(edited.default_value, "PN-002"); + assert_eq!(edited.rotation, 0.25); + assert_eq!(edited.width_factor, 1.25); + assert_eq!(edited.common, created.common); + assert_eq!(edited.tag, created.tag); + assert_eq!(edited.prompt, created.prompt); + assert_eq!(edited.text_style, created.text_style); + assert_eq!(edited.flags, created.flags); + assert_eq!(edited.embedded_mtext, created.embedded_mtext); + assert_eq!(host.selection(), vec![handle]); + assert!( + matches!(host.document().get_entity(insert_handle), Some(EntityType::Insert(value)) + if value.block_name == "TAGBLOCK") + ); + + let render = edited + .to_render(host.document()) + .expect("attribute definition render"); + let crate::scene::convert::acad_to_render::RenderObject::Text(strokes) = render.object + else { + panic!("expected attribute definition text"); + }; + assert!(!strokes.is_empty()); + assert!(strokes.iter().any(|stroke| stroke + .run + .as_ref() + .is_some_and(|run| run.text.contains("PN-002")))); + + for (label, patch, message) in [ + ("Reject tag", "'tag':'BAD TAG'", "whitespace"), + ("Reject height", "'height':0.0", "greater than zero"), + ("Reject style", "'text_style':'Missing'", "does not exist"), + ("Reject owner", "'owner_handle':999999", "read-only"), + ] { + dispatch( + &mut host, + &format!( + "PY_EVAL ocs.update_many({label:?}, [{{'handle':{}, {patch}}}])", + handle.value() + ), + ); + assert_eq!(host.document().get_entity(handle), Some(&expected)); + assert!(host + .app + .command_line + .history + .last() + .unwrap() + .text + .contains(message)); + } + + let assert_persisted = |label: &str, document: &CadDocument, full_text_state: bool| { + let actual = document.get_entity(handle); + assert!( + matches!(actual, Some(EntityType::AttributeDefinition(value)) + if value.common.owner_handle == block_record_handle + && value.tag == "PART_NO" && value.default_value == "PN-002" + && value.insertion_point == acadrust::types::Vector3::new(4.0, 5.0, 0.0) + && (value.rotation - 0.25).abs() < 1e-12), + "{label}: {actual:?}" + ); + if full_text_state { + assert!( + matches!(actual, Some(EntityType::AttributeDefinition(value)) + if value.width_factor == 1.25 && value.text_style == "Standard" + && value.prompt == "Part number"), + "{label}: {actual:?}" + ); + } + assert!(document + .block_records + .get("TAGBLOCK") + .unwrap() + .entity_handles + .contains(&handle)); + assert!( + matches!(document.get_entity(insert_handle), Some(EntityType::Insert(value)) + if value.block_name == "TAGBLOCK") + ); + }; + let dwg_bytes = acadrust::DwgWriter::write_to_vec(host.document()).unwrap(); + let dwg_doc = crate::io::load_bytes("attribute-definition.dwg", dwg_bytes).unwrap(); + assert_persisted("DWG", &dwg_doc, true); + let dxf_bytes = acadrust::DxfWriter::new(host.document()) + .write_to_vec() + .unwrap(); + let dxf_doc = crate::io::load_bytes("attribute-definition.dxf", dxf_bytes).unwrap(); + // The pinned cadcodec DXF ATTDEF reader retains identity, placement, + // height, value, prompt and rotation, but currently drops several + // optional AcDbText fields such as width factor. Keep that external + // codec gap explicit in the coverage ledger rather than claiming a + // complete DXF property round trip here. + assert_persisted("DXF", &dxf_doc, false); + + dispatch( + &mut host, + &format!( + "PY_EVAL ocs.active_document.delete_entity({})", + handle.value() + ), + ); + assert!(host.document().get_entity(handle).is_none()); + // CadDocument intentionally leaves the block membership slot in place + // so delta undo can restore the shared entity without rewriting the + // record. Writers must still omit the absent entity. + let deleted_dwg = acadrust::DwgWriter::write_to_vec(host.document()).unwrap(); + assert!( + crate::io::load_bytes("attribute-definition-deleted.dwg", deleted_dwg) + .unwrap() + .get_entity(handle) + .is_none() + ); + let deleted_dxf = acadrust::DxfWriter::new(host.document()) + .write_to_vec() + .unwrap(); + assert!( + crate::io::load_bytes("attribute-definition-deleted.dxf", deleted_dxf) + .unwrap() + .get_entity(handle) + .is_none() + ); + drop(process); + drop(host); + app.finish_pending_history(0); + assert_eq!(app.tabs[0].history.undo_stack.len(), 3); + app.undo_steps(1); + assert_eq!( + app.tabs[0].scene.document.get_entity(handle), + Some(&expected) + ); + app.undo_steps(1); + assert!(matches!(app.tabs[0].scene.document.get_entity(handle), + Some(EntityType::AttributeDefinition(value)) + if value.default_value == "PN-001" && value.insertion_point.x == 1.0)); + app.undo_steps(1); + assert!(app.tabs[0].scene.document.get_entity(handle).is_none()); + app.redo_steps(3); + assert!(app.tabs[0].scene.document.get_entity(handle).is_none()); + } + + #[test] + fn staged_python_attribute_entity_lifecycle_over_real_ipc() { + let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { + return; + }; + let runner_path = std::env::var_os("OCS_PLUGIN_RUNNER_EXE") + .expect("set OCS_PLUGIN_RUNNER_EXE to the built OpenCADStudio executable"); + assert!(std::path::Path::new(&runner_path).is_file()); + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let next = host.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"); + record.handle = record_handle; + record.block_entity_handle = block_handle; + record.block_end_handle = end_handle; + host.document_mut().block_records.add(record).unwrap(); + let mut block = acadrust::entities::Block::new("TAGBLOCK", acadrust::types::Vector3::ZERO); + block.common.handle = block_handle; + block.common.owner_handle = record_handle; + host.document_mut() + .add_entity(EntityType::Block(block)) + .unwrap(); + let mut end = acadrust::entities::BlockEnd::new(); + end.common.handle = end_handle; + end.common.owner_handle = record_handle; + host.document_mut() + .add_entity(EntityType::BlockEnd(end)) + .unwrap(); + let mut definition = acadrust::entities::AttributeDefinition::new( + "PART_NO".into(), + "Part number".into(), + "PN-001".into(), + ); + definition.common.owner_handle = record_handle; + definition.insertion_point = acadrust::types::Vector3::new(0.0, 2.0, 0.0); + let definition_handle = host + .document_mut() + .add_entity(EntityType::AttributeDefinition(definition)) + .unwrap(); + let insert_handle = host + .document_mut() + .add_entity(EntityType::Insert(acadrust::entities::Insert::new( + "TAGBLOCK", + acadrust::types::Vector3::new(10.0, 0.0, 0.0), + ))) + .unwrap(); + + let process = ocs_plugin_api::process::PluginProcess::spawn( + std::path::Path::new(&plugin_path), + &mut host, + crate::plugin::v4_support::notification_handler(), + ) + .unwrap(); + let dispatch = |host: &mut HostSession<'_>, command: &str| { + assert!(process + .dispatch(host, command, &mut |_| {}) + .expect("Python dispatch")); + }; + dispatch( + &mut host, + &format!( + concat!( + "PY_EVAL ocs.active_document.create_entity('AttributeEntity',owner_handle={},", + "tag='PART_NO',value='PN-101',", + "insertion_point={{'x':10.0,'y':2.0,'z':0.0}},height=2.5).handle" + ), + insert_handle.value() + ), + ); + let created_insert = match host.document().get_entity(insert_handle).unwrap() { + EntityType::Insert(insert) => insert, + _ => unreachable!(), + }; + assert_eq!(created_insert.attributes.len(), 1); + let attribute_handle = created_insert.attributes[0].common.handle; + assert_eq!( + created_insert.attributes[0].attdef_handle, + definition_handle + ); + let created = created_insert.attributes[0].clone(); + dispatch( + &mut host, + &format!( + "PY_EVAL ocs.active_document.entities[{}].value", + attribute_handle.value() + ), + ); + let readback = &host.app.command_line.history.last().unwrap().text; + assert!(readback.contains("PN-101"), "{readback}"); + + let script = + std::env::temp_dir().join(format!("ocs_attribute_entity_{}.py", std::process::id())); + std::fs::write( + &script, + format!( + concat!( + "doc = ocs.active_document\n", + "attribute = doc.entities[{}]\n", + "with doc.transaction('Edit attribute'):\n", + " attribute.value = 'PN-102'\n", + " attribute.insertion_point = (12.0, 3.0, 0.0)\n", + " attribute.rotation = 0.25\n", + "doc.selection = [doc.entities[{}]]\n" + ), + attribute_handle.value(), + insert_handle.value() + ), + ) + .unwrap(); + dispatch(&mut host, &format!("PY_RUN {}", script.display())); + let _ = std::fs::remove_file(&script); + let expected = host.nested_attribute(attribute_handle).unwrap(); + let EntityType::AttributeEntity(edited) = &expected else { + unreachable!() + }; + assert_eq!(edited.value, "PN-102"); + assert_eq!( + edited.insertion_point, + acadrust::types::Vector3::new(12.0, 3.0, 0.0) + ); + assert_eq!(edited.common, created.common); + assert_eq!(edited.attdef_handle, definition_handle); + assert_eq!(host.selection(), vec![insert_handle]); + let render = edited.to_render(host.document()).unwrap(); + assert!(matches!(render.object, + crate::scene::convert::acad_to_render::RenderObject::Text(ref strokes) + if !strokes.is_empty())); + + for (patch, message) in [ + ("'tag':'MISSING'", "no definition"), + ("'height':0.0", "greater than zero"), + ("'owner_handle':999999", "read-only"), + ] { + dispatch( + &mut host, + &format!( + "PY_EVAL ocs.update_many('Reject attribute', [{{'handle':{}, {patch}}}])", + attribute_handle.value() + ), + ); + assert_eq!( + host.nested_attribute(attribute_handle), + Some(expected.clone()) + ); + assert!(host + .app + .command_line + .history + .last() + .unwrap() + .text + .contains(message)); + } + + let assert_saved = |document: &CadDocument| { + assert!( + matches!(document.get_entity(insert_handle), Some(EntityType::Insert(insert)) + if insert.attributes.len() == 1 + && insert.attributes[0].common.handle == attribute_handle + && insert.attributes[0].value == "PN-102" + && (insert.attributes[0].rotation - 0.25).abs() < 1e-12) + ); + }; + assert_saved( + &crate::io::load_bytes( + "attribute.dwg", + acadrust::DwgWriter::write_to_vec(host.document()).unwrap(), + ) + .unwrap(), + ); + assert_saved( + &crate::io::load_bytes( + "attribute.dxf", + acadrust::DxfWriter::new(host.document()) + .write_to_vec() + .unwrap(), + ) + .unwrap(), + ); + + dispatch( + &mut host, + &format!( + "PY_EVAL ocs.active_document.delete_entity({})", + attribute_handle.value() + ), + ); + assert!(host.nested_attribute(attribute_handle).is_none()); + let assert_deleted = |document: &CadDocument| { + assert!( + matches!(document.get_entity(insert_handle), Some(EntityType::Insert(insert)) + if insert.attributes.is_empty()) + ); + }; + assert_deleted( + &crate::io::load_bytes( + "attribute-deleted.dwg", + acadrust::DwgWriter::write_to_vec(host.document()).unwrap(), + ) + .unwrap(), + ); + assert_deleted( + &crate::io::load_bytes( + "attribute-deleted.dxf", + acadrust::DxfWriter::new(host.document()) + .write_to_vec() + .unwrap(), + ) + .unwrap(), + ); + drop(process); + drop(host); + app.finish_pending_history(0); + assert_eq!(app.tabs[0].history.undo_stack.len(), 3); + app.undo_steps(1); + assert!( + matches!(app.tabs[0].scene.document.get_entity(insert_handle), + Some(EntityType::Insert(insert)) if insert.attributes.len() == 1 + && insert.attributes[0].value == "PN-102") + ); + app.undo_steps(1); + assert!( + matches!(app.tabs[0].scene.document.get_entity(insert_handle), + Some(EntityType::Insert(insert)) if insert.attributes[0].value == "PN-101") + ); + app.undo_steps(1); + assert!( + matches!(app.tabs[0].scene.document.get_entity(insert_handle), + Some(EntityType::Insert(insert)) if insert.attributes.is_empty()) + ); + app.redo_steps(3); + assert!( + matches!(app.tabs[0].scene.document.get_entity(insert_handle), + Some(EntityType::Insert(insert)) if insert.attributes.is_empty()) + ); + } + + #[test] + fn staged_python_hatch_lifecycle_over_real_ipc() { + let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { + return; + }; + let runner_path = std::env::var_os("OCS_PLUGIN_RUNNER_EXE") + .expect("set OCS_PLUGIN_RUNNER_EXE to the built OpenCADStudio executable"); + assert!(std::path::Path::new(&runner_path).is_file()); + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let process = ocs_plugin_api::process::PluginProcess::spawn( + std::path::Path::new(&plugin_path), + &mut host, + crate::plugin::v4_support::notification_handler(), + ) + .unwrap(); + let dispatch = |host: &mut HostSession<'_>, command: &str| { + assert!(process.dispatch(host, command, &mut |_| {}).expect("Python dispatch")); + }; + let script = std::env::temp_dir().join(format!("ocs_hatch_create_{}.py", std::process::id())); + std::fs::write(&script, concat!( + "def p(x, y): return {'x': x, 'y': y}\n", + "def boundary(size):\n", + " pairs = [(0.0,0.0,size,0.0),(size,0.0,size,size),(size,size,0.0,size),(0.0,size,0.0,0.0)]\n", + " edges = [{'kind':'Line','value':{'start':p(a,b),'end':p(c,d)}} for a,b,c,d in pairs]\n", + " return {'flags':1,'edges':edges,'boundary_handles':[]}\n", + "doc = ocs.active_document\n", + "hatch = doc.create_entity('Hatch', paths=[boundary(10.0)])\n", + )).unwrap(); + dispatch(&mut host, &format!("PY_RUN {}", script.display())); + let _ = std::fs::remove_file(&script); + let hatch_handle = host.document().entities().find_map(|entity| match entity { + EntityType::Hatch(hatch) => Some(hatch.common.handle), + _ => None, + }).unwrap_or_else(|| panic!("Python did not create Hatch: {:?}", + host.app.command_line.history.last().map(|entry| &entry.text))); + let created = host.document().get_entity(hatch_handle).unwrap().clone(); + assert!(host.app.tabs[0].scene.hatches.get(&hatch_handle) + .is_some_and(|model| model.boundary.len() >= 4 && model.name == "SOLID")); + dispatch(&mut host, &format!( + "PY_EVAL ocs.active_document.entities[{}].pattern['name']", hatch_handle.value())); + assert!(host.app.command_line.history.last().unwrap().text.contains("SOLID")); + + let script = std::env::temp_dir().join(format!("ocs_hatch_edit_{}.py", std::process::id())); + std::fs::write(&script, format!(concat!( + "def p(x, y): return {{'x': x, 'y': y}}\n", + "def boundary(size):\n", + " pairs = [(0.0,0.0,size,0.0),(size,0.0,size,size),(size,size,0.0,size),(0.0,size,0.0,0.0)]\n", + " edges = [{{'kind':'Line','value':{{'start':p(a,b),'end':p(c,d)}}}} for a,b,c,d in pairs]\n", + " return {{'flags':1,'edges':edges,'boundary_handles':[]}}\n", + "doc = ocs.active_document\n", + "hatch = doc.entities[{}]\n", + "with doc.transaction('Edit hatch'):\n", + " hatch.paths = [boundary(12.0)]\n", + " hatch.is_solid = False\n", + " hatch.pattern = {{'name':'TEST','description':'test pattern','lines':[", + "{{'angle':0.0,'base_point':p(0.0,0.0),'offset':p(0.0,2.0),'dash_lengths':[1.0,-1.0]}}]}}\n", + " hatch.pattern_scale = 2.0\n", + " hatch.pattern_angle = 0.25\n", + "doc.selection = [hatch]\n" + ), hatch_handle.value())).unwrap(); + dispatch(&mut host, &format!("PY_RUN {}", script.display())); + let _ = std::fs::remove_file(&script); + let expected = host.document().get_entity(hatch_handle).unwrap().clone(); + let EntityType::Hatch(edited) = &expected else { unreachable!() }; + assert!(!edited.is_solid); + assert_eq!(edited.pattern.name, "TEST"); + assert_eq!(edited.pattern.lines.len(), 1); + assert_eq!(edited.paths[0].edges.len(), 4); + assert!((edited.pattern_scale - 2.0).abs() < 1e-12); + assert_eq!(edited.gradient_color, match &created { EntityType::Hatch(value) => value.gradient_color.clone(), _ => unreachable!() }); + assert_eq!(host.selection(), vec![hatch_handle]); + assert!(host.app.tabs[0].scene.hatches.get(&hatch_handle).is_some_and(|model| + model.boundary.len() >= 4 && model.name == "TEST" + && matches!(&model.pattern, + crate::scene::model::hatch_model::HatchPattern::Pattern(lines) + if !lines.is_empty()))); + + for (patch, message) in [ + ("'pattern_scale':0.0", "greater than zero"), + ("'is_associative':True", "requires boundary handles"), + ("'gradient_color':{}", "outside the editable schema"), + ("'paths':[{'flags':1,'edges':[{'kind':'Line','value':{'start':{'x':0.0,'y':0.0},'end':{'x':1.0,'y':0.0}}}],'boundary_handles':[]}]", "not closed"), + ] { + dispatch(&mut host, &format!( + "PY_EVAL ocs.update_many('Reject hatch', [{{'handle':{}, {patch}}}])", + hatch_handle.value())); + assert_eq!(host.document().get_entity(hatch_handle), Some(&expected)); + assert!(host.app.command_line.history.last().unwrap().text.contains(message)); + } + + let assert_saved = |document: &CadDocument| { + assert!(matches!(document.get_entity(hatch_handle), Some(EntityType::Hatch(hatch)) + if !hatch.is_solid && hatch.pattern.name == "TEST" + && hatch.pattern.lines.len() == 1 && hatch.paths[0].edges.len() == 4 + && (hatch.pattern_scale - 2.0).abs() < 1e-12)); + }; + let reopened_dwg = crate::io::load_bytes("hatch.dwg", + acadrust::DwgWriter::write_to_vec(host.document()).unwrap()).unwrap(); + let reopened_dxf = crate::io::load_bytes("hatch.dxf", + acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()).unwrap(); + assert_saved(&reopened_dwg); + assert_saved(&reopened_dxf); + for (name, mut document) in [("hatch-reedit.dwg", reopened_dwg), + ("hatch-reedit.dxf", reopened_dxf)] { + let before = document.get_entity(hatch_handle).unwrap().clone(); + let mut after = before.clone(); + let EntityType::Hatch(hatch) = &mut after else { unreachable!() }; + hatch.elevation = 3.0; + ocs_plugin_api::entity_coverage::validate_entity_mutation(&before, &after).unwrap(); + *document.get_entity_mut(hatch_handle).unwrap() = after; + let bytes = if name.ends_with("dwg") { + acadrust::DwgWriter::write_to_vec(&document).unwrap() + } else { + acadrust::DxfWriter::new(&document).write_to_vec().unwrap() + }; + assert!(matches!(crate::io::load_bytes(name, bytes).unwrap().get_entity(hatch_handle), + Some(EntityType::Hatch(hatch)) if (hatch.elevation - 3.0).abs() < 1e-12)); + } + dispatch(&mut host, &format!( + "PY_EVAL ocs.active_document.delete_entity({})", hatch_handle.value())); + assert!(host.document().get_entity(hatch_handle).is_none()); + for (name, bytes) in [ + ("hatch-deleted.dwg", acadrust::DwgWriter::write_to_vec(host.document()).unwrap()), + ("hatch-deleted.dxf", acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()), + ] { + assert!(crate::io::load_bytes(name, bytes).unwrap().get_entity(hatch_handle).is_none()); + } + drop(process); + drop(host); + app.finish_pending_history(0); + assert_eq!(app.tabs[0].history.undo_stack.len(), 3); + app.undo_steps(1); + assert_eq!(app.tabs[0].scene.document.get_entity(hatch_handle), Some(&expected)); + app.undo_steps(1); + assert_eq!(app.tabs[0].scene.document.get_entity(hatch_handle), Some(&created)); + app.undo_steps(1); + assert!(app.tabs[0].scene.document.get_entity(hatch_handle).is_none()); + app.redo_steps(3); + assert!(app.tabs[0].scene.document.get_entity(hatch_handle).is_none()); + } + + #[test] + fn staged_python_leader_lifecycle_over_real_ipc() { + let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { + return; + }; + let runner_path = std::env::var_os("OCS_PLUGIN_RUNNER_EXE") + .expect("set OCS_PLUGIN_RUNNER_EXE to the built OpenCADStudio executable"); + assert!(std::path::Path::new(&runner_path).is_file()); + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let process = ocs_plugin_api::process::PluginProcess::spawn( + std::path::Path::new(&plugin_path), + &mut host, + crate::plugin::v4_support::notification_handler(), + ) + .unwrap(); + let dispatch = |host: &mut HostSession<'_>, command: &str| { + assert!(process.dispatch(host, command, &mut |_| {}).expect("Python dispatch")); + }; + let last_output = |host: &HostSession<'_>| host.app.command_line.history.last().unwrap().text.clone(); + + dispatch(&mut host, concat!( + "PY_EVAL ocs.active_document.create_entity('Text', text='NOTE', ", + "insertion={'x':20.0,'y':10.0,'z':0.0}, height=2.5).handle" + )); + let text_handle = host.document().entities().find_map(|entity| match entity { + EntityType::Text(text) => Some(text.common.handle), + _ => None, + }).unwrap_or_else(|| panic!("Python did not create Text: {}", last_output(&host))); + let script = std::env::temp_dir().join(format!("ocs_leader_create_{}.py", std::process::id())); + std::fs::write(&script, format!(concat!( + "doc = ocs.active_document\n", + "doc.create_entity('Leader', vertices=[{{'x':0.0,'y':0.0,'z':0.0}},", + "{{'x':10.0,'y':10.0,'z':0.0}},{{'x':20.0,'y':10.0,'z':0.0}}], ", + "annotation_handle={}, hookline_enabled=True)\n" + ), text_handle.value())).unwrap(); + dispatch(&mut host, &format!("PY_RUN {}", script.display())); + let _ = std::fs::remove_file(&script); + let leader_handle = host.document().entities().find_map(|entity| match entity { + EntityType::Leader(leader) => Some(leader.common.handle), + _ => None, + }).unwrap_or_else(|| panic!("Python did not create Leader: {}", last_output(&host))); + let created = host.document().get_entity(leader_handle).unwrap().clone(); + let EntityType::Leader(created_leader) = &created else { unreachable!() }; + assert_eq!(created_leader.annotation_handle, text_handle); + assert_eq!(created_leader.vertices.len(), 3); + + let assert_geometry = |entity: &EntityType, document: &CadDocument| { + let EntityType::Leader(leader) = entity else { panic!("expected Leader") }; + let render = leader.to_render(document).expect("leader render"); + let crate::scene::convert::acad_to_render::RenderObject::Lines(points) = render.object else { + panic!("expected leader linework"); + }; + assert!(points.len() >= leader.vertices.len()); + assert!(points.iter().flatten().all(|value| value.is_nan() || value.is_finite())); + }; + assert_geometry(&created, host.document()); + dispatch(&mut host, &format!( + "PY_EVAL ocs.active_document.entities[{}].annotation_handle", leader_handle.value())); + assert!(last_output(&host).contains(&text_handle.value().to_string())); + + // GUI picking groups a leader with its annotation, while scripted + // selection stays exact. + host.app.tabs[0].scene.select_entities(&[leader_handle]); + let mut grouped = host.selection(); + grouped.sort_by_key(|handle| handle.value()); + assert_eq!(grouped, vec![text_handle, leader_handle]); + host.app.tabs[0].scene.replace_selection_exact(&[]); + + let script = std::env::temp_dir().join(format!("ocs_leader_edit_{}.py", std::process::id())); + std::fs::write(&script, format!(concat!( + "doc = ocs.active_document\n", + "leader = doc.entities[{}]\n", + "with doc.transaction('Edit leader'):\n", + " leader.vertices = [{{'x':1.0,'y':1.0,'z':0.0}},{{'x':11.0,'y':12.0,'z':0.0}},", + "{{'x':22.0,'y':12.0,'z':0.0}},{{'x':30.0,'y':12.0,'z':0.0}}]\n", + " leader.text_height = 4.0\n", + " leader.arrow_enabled = False\n", + " leader.annotation_offset = (1.0, 2.0, 0.0)\n", + " leader.override_color = {{'kind':'Rgb','value':{{'r':255,'g':0,'b':0}}}}\n", + "doc.selection = [leader]\n" + ), leader_handle.value())).unwrap(); + dispatch(&mut host, &format!("PY_RUN {}", script.display())); + let _ = std::fs::remove_file(&script); + let expected = host.document().get_entity(leader_handle).unwrap().clone(); + let EntityType::Leader(edited) = &expected else { unreachable!() }; + assert_eq!(edited.vertices.len(), 4, "edit failed: {}", last_output(&host)); + assert_eq!(edited.text_height, 4.0); + assert!(!edited.arrow_enabled); + assert_eq!(edited.annotation_offset, acadrust::types::Vector3::new(1.0, 2.0, 0.0)); + assert_eq!(edited.annotation_handle, text_handle); + assert_eq!(edited.common, created_leader.common); + assert_eq!(edited.dimension_style, created_leader.dimension_style); + assert_eq!(edited.hookline_enabled, created_leader.hookline_enabled); + assert_eq!(edited.normal, created_leader.normal); + assert_eq!(edited.origin, created_leader.origin); + assert_eq!(edited.override_color, acadrust::types::Color::Rgb { r: 255, g: 0, b: 0 }); + assert_eq!(host.selection(), vec![leader_handle]); + assert_geometry(&expected, host.document()); + + for (patch, message) in [ + ("'annotation_handle':999999", "does not exist"), + ("'creation_type':'WithTolerance'", "does not match creation_type"), + ("'creation_type':'NoAnnotation'", "no annotation"), + ("'vertices':[{'x':0.0,'y':0.0,'z':0.0}]", "at least 2"), + ("'text_height':0.0", "greater than zero"), + ("'dimension_style':'Missing'", "dimension style"), + ("'override_color':{'kind':'Index','value':300}", "Color"), + ] { + dispatch(&mut host, &format!( + "PY_EVAL ocs.update_many('Reject leader', [{{'handle':{}, {patch}}}])", + leader_handle.value())); + assert_eq!(host.document().get_entity(leader_handle), Some(&expected), "{patch}"); + assert!(last_output(&host).contains(message), "{patch}: {}", last_output(&host)); + } + // A valid edit paired with an invalid one is atomic across entities. + dispatch(&mut host, &format!(concat!( + "PY_EVAL ocs.update_many('Atomic', [{{'handle':{},'text_width':5.0}},", + "{{'handle':{},'text_height':0.0}}])" + ), leader_handle.value(), text_handle.value())); + assert_eq!(host.document().get_entity(leader_handle), Some(&expected)); + + // Both formats keep the path, annotation link, arrow flag and offset. + // DWG R2010+ derives text_height/text_width/hookline_enabled instead of + // storing them (acadrust writes them for R13-R2007/R13-R14 only), so + // only DXF must round-trip those three. + let assert_saved = |document: &CadDocument, is_dxf: bool| { + let Some(EntityType::Leader(leader)) = document.get_entity(leader_handle) else { + panic!("Leader missing after reopen"); + }; + assert_eq!(leader.vertices.len(), 4); + if is_dxf { + // BLOCKER: acadrust's DXF writer never emits group 340, so a + // DXF save unlinks the annotation. Flip this to + // `assert_eq!(.., text_handle)` once the engine writes it. + assert!(leader.annotation_handle.is_null(), "acadrust now writes DXF 340; drop the blocker"); + } else { + assert_eq!(leader.annotation_handle, text_handle); + } + assert!(!leader.arrow_enabled); + // BLOCKER: neither writer stores `override_color`, so it reopens + // as ByLayer in both formats. + assert_eq!(leader.override_color, acadrust::types::Color::ByLayer, + "acadrust now persists Leader.override_color; drop the blocker"); + if is_dxf { + // BLOCKER: the DXF writer emits group 213 but the reader's + // coordinate mapping does not reassemble it. + assert_eq!(leader.annotation_offset, acadrust::types::Vector3::ZERO, + "acadrust now reads DXF 213; drop the blocker"); + } else { + assert_eq!(leader.annotation_offset, acadrust::types::Vector3::new(1.0, 2.0, 0.0)); + } + assert_eq!(leader.dimension_style, "Standard"); + if is_dxf { + assert!((leader.text_height - 4.0).abs() < 1e-12); + assert!(leader.hookline_enabled); + } else { + assert_eq!(leader.text_height, 2.5, "DWG R2010+ does not store text_height"); + assert!(!leader.hookline_enabled, "DWG R2010+ does not store hookline_enabled"); + } + assert!(matches!(document.get_entity(text_handle), Some(EntityType::Text(_)))); + }; + let reopened_dwg = crate::io::load_bytes("leader.dwg", + acadrust::DwgWriter::write_to_vec(host.document()).unwrap()).unwrap(); + let reopened_dxf = crate::io::load_bytes("leader.dxf", + acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()).unwrap(); + assert_saved(&reopened_dwg, false); + assert_saved(&reopened_dxf, true); + for (name, mut document) in [("leader-reedit.dwg", reopened_dwg), + ("leader-reedit.dxf", reopened_dxf)] { + assert_geometry(document.get_entity(leader_handle).unwrap(), &document); + let before = document.get_entity(leader_handle).unwrap().clone(); + let mut after = before.clone(); + let EntityType::Leader(leader) = &mut after else { unreachable!() }; + leader.vertices[3] = acadrust::types::Vector3::new(35.0, 12.0, 0.0); + ocs_plugin_api::entity_coverage::validate_entity_mutation(&before, &after).unwrap(); + ocs_plugin_api::entity_coverage::validate_canvas_entity_references(&document, &after).unwrap(); + *document.get_entity_mut(leader_handle).unwrap() = after; + let bytes = if name.ends_with("dwg") { + acadrust::DwgWriter::write_to_vec(&document).unwrap() + } else { + acadrust::DxfWriter::new(&document).write_to_vec().unwrap() + }; + assert!(matches!(crate::io::load_bytes(name, bytes).unwrap().get_entity(leader_handle), + Some(EntityType::Leader(leader)) if (leader.vertices[3].x - 35.0).abs() < 1e-9)); + } + + // Erasing a leader also erases its annotation (scene leader grouping). + dispatch(&mut host, &format!( + "PY_EVAL ocs.active_document.delete_entity({})", leader_handle.value())); + assert!(host.document().get_entity(leader_handle).is_none()); + assert!(host.document().get_entity(text_handle).is_none()); + for (name, bytes) in [ + ("leader-deleted.dwg", acadrust::DwgWriter::write_to_vec(host.document()).unwrap()), + ("leader-deleted.dxf", acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()), + ] { + let document = crate::io::load_bytes(name, bytes).unwrap(); + assert!(document.get_entity(leader_handle).is_none()); + assert!(document.get_entity(text_handle).is_none()); + } + drop(process); + drop(host); + app.finish_pending_history(0); + // Text create, Leader create, edit transaction, Leader delete. + assert_eq!(app.tabs[0].history.undo_stack.len(), 4); + app.undo_steps(1); + assert_eq!(app.tabs[0].scene.document.get_entity(leader_handle), Some(&expected)); + assert!(app.tabs[0].scene.document.get_entity(text_handle).is_some()); + app.undo_steps(1); + assert_eq!(app.tabs[0].scene.document.get_entity(leader_handle), Some(&created)); + app.undo_steps(1); + assert!(app.tabs[0].scene.document.get_entity(leader_handle).is_none()); + assert!(app.tabs[0].scene.document.get_entity(text_handle).is_some()); + app.undo_steps(1); + assert!(app.tabs[0].scene.document.get_entity(text_handle).is_none()); + app.redo_steps(4); + assert!(app.tabs[0].scene.document.get_entity(leader_handle).is_none()); + assert!(app.tabs[0].scene.document.get_entity(text_handle).is_none()); + } + + #[test] + fn staged_python_mline_lifecycle_over_real_ipc() { + let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { + return; + }; + let runner_path = std::env::var_os("OCS_PLUGIN_RUNNER_EXE") + .expect("set OCS_PLUGIN_RUNNER_EXE to the built OpenCADStudio executable"); + assert!(std::path::Path::new(&runner_path).is_file()); + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let process = ocs_plugin_api::process::PluginProcess::spawn( + std::path::Path::new(&plugin_path), + &mut host, + crate::plugin::v4_support::notification_handler(), + ) + .unwrap(); + let dispatch = |host: &mut HostSession<'_>, command: &str| { + assert!(process.dispatch(host, command, &mut |_| {}).expect("Python dispatch")); + }; + let last_output = |host: &HostSession<'_>| host.app.command_line.history.last().unwrap().text.clone(); + let style_handle = host.document().objects.iter().find_map(|(handle, object)| match object { + acadrust::objects::ObjectType::MLineStyle(style) if style.name == "Standard" => Some(*handle), + _ => None, + }).expect("document carries the Standard MLineStyle"); + + let script = std::env::temp_dir().join(format!("ocs_mline_create_{}.py", std::process::id())); + std::fs::write(&script, concat!( + "def p(x, y): return {'position': {'x': x, 'y': y, 'z': 0.0}}\n", + "doc = ocs.active_document\n", + "doc.create_entity('MLine', vertices=[p(0.0, 0.0), p(10.0, 0.0)])\n", + )).unwrap(); + dispatch(&mut host, &format!("PY_RUN {}", script.display())); + let _ = std::fs::remove_file(&script); + let handle = host.document().entities().find_map(|entity| match entity { + EntityType::MLine(mline) => Some(mline.common.handle), + _ => None, + }).unwrap_or_else(|| panic!("Python did not create MLine: {}", last_output(&host))); + let created = host.document().get_entity(handle).unwrap().clone(); + let EntityType::MLine(created_mline) = &created else { unreachable!() }; + assert_eq!(created_mline.style_handle, Some(style_handle)); + assert_eq!(created_mline.style_element_count, 2); + assert_eq!(created_mline.start_point, acadrust::types::Vector3::ZERO); + let first_parameters = |mline: &acadrust::entities::MLine, vertex: usize| -> Vec { + mline.vertices[vertex].segments.iter().map(|segment| segment.parameters[0]).collect() + }; + assert_eq!(first_parameters(created_mline, 0), vec![0.5, -0.5]); + assert!(created_mline.vertices.iter().all(|vertex| { + (vertex.direction.x - 1.0).abs() < 1e-9 && (vertex.miter.y.abs() - 1.0).abs() < 1e-9 + })); + let assert_lines = |entity: &EntityType, document: &CadDocument, expected_vertices: usize| { + let EntityType::MLine(mline) = entity else { panic!("expected MLine") }; + assert_eq!(mline.vertices.len(), expected_vertices); + let lines = crate::entities::mline::mline_lines(mline, document); + assert!(lines.len() >= 2, "expected styled element lines"); + assert!(lines.iter().flat_map(|line| &line.points).flatten().all(|v| v.is_finite())); + }; + assert_lines(&created, host.document(), 2); + + let script = std::env::temp_dir().join(format!("ocs_mline_edit_{}.py", std::process::id())); + std::fs::write(&script, format!(concat!( + "def p(x, y): return {{'position': {{'x': x, 'y': y, 'z': 0.0}}}}\n", + "doc = ocs.active_document\n", + "mline = doc.entities[{}]\n", + "with doc.transaction('Edit mline'):\n", + " mline.vertices = [p(0.0, 0.0), p(10.0, 0.0), p(10.0, 10.0)]\n", + " mline.scale_factor = 2.0\n", + " mline.justification = 'Top'\n", + "doc.selection = [mline]\n" + ), handle.value())).unwrap(); + dispatch(&mut host, &format!("PY_RUN {}", script.display())); + let _ = std::fs::remove_file(&script); + let expected = host.document().get_entity(handle).unwrap().clone(); + let EntityType::MLine(edited) = &expected else { unreachable!() }; + assert_eq!(edited.vertices.len(), 3, "edit failed: {}", last_output(&host)); + assert_eq!(edited.scale_factor, 2.0); + assert_eq!(edited.justification, acadrust::entities::MLineJustification::Top); + assert_eq!(edited.style_handle, created_mline.style_handle); + assert_eq!(edited.common, created_mline.common); + assert_eq!(edited.normal, created_mline.normal); + assert_eq!(edited.flags.bits() & !1, 0); + // Top justification with scale 2: first element sits on the path, + // the second 2.0 to the right of travel; the corner miter widens it. + let end = first_parameters(edited, 0); + assert!(end[0].abs() < 1e-9 && (end[1] + 2.0).abs() < 1e-9, "{end:?}"); + let corner = first_parameters(edited, 1); + assert!(corner[1].abs() > 2.0 && corner[1].is_finite(), "miter must widen at the corner: {corner:?}"); + assert!((edited.vertices[1].direction.y - edited.vertices[1].direction.x).abs() > 1e-6); + assert_lines(&expected, host.document(), 3); + assert_eq!(host.selection(), vec![handle]); + + for (patch, message) in [ + ("'style_name':'Missing'", "does not exist"), + ("'vertices':[{'position':{'x':0.0,'y':0.0,'z':0.0}}]", "at least 2"), + ("'scale_factor':0.0", "nonzero"), + ("'flags':64", "unknown bits"), + ("'flags':3,'vertices':[{'position':{'x':0.0,'y':0.0,'z':0.0}},{'position':{'x':1.0,'y':0.0,'z':0.0}}]", "requires at least 3"), + ("'vertices':[{'position':{'x':0.0,'y':0.0,'z':0.0}},{'position':{'x':0.0,'y':0.0,'z':0.0}}]", "coincide"), + ("'vertices':[{'position':{'x':0.0,'y':0.0,'z':0.0}},{'position':{'x':1.0,'y':float('nan'),'z':0.0}}]", "finite"), + ("'vertices':[{'position':{'x':0.0,'y':0.0,'z':0.0},'segments':[{}]},{'position':{'x':1.0,'y':0.0,'z':0.0}}]", "segments but style"), + ("'start_point':{'x':5.0,'y':5.0,'z':0.0}", "read-only"), + ] { + dispatch(&mut host, &format!( + "PY_EVAL ocs.update_many('Reject mline', [{{'handle':{}, {patch}}}])", handle.value())); + assert_eq!(host.document().get_entity(handle), Some(&expected), "{patch}"); + assert!(last_output(&host).contains(message), "{patch}: {}", last_output(&host)); + } + dispatch(&mut host, &format!(concat!( + "PY_EVAL ocs.update_many('Atomic', [{{'handle':{},'scale_factor':3.0}},", + "{{'handle':{},'scale_factor':0.0}}])" + ), handle.value(), handle.value())); + assert_eq!(host.document().get_entity(handle), Some(&expected)); + + let assert_saved = |document: &CadDocument| { + let Some(EntityType::MLine(mline)) = document.get_entity(handle) else { + panic!("MLine missing after reopen"); + }; + assert_eq!(mline.vertices.len(), 3); + assert_eq!(mline.scale_factor, 2.0); + assert_eq!(mline.justification, acadrust::entities::MLineJustification::Top); + assert_eq!(mline.style_name, "Standard"); + assert_eq!(mline.vertices[2].position, acadrust::types::Vector3::new(10.0, 10.0, 0.0)); + let (a, b) = (first_parameters(mline, 1), first_parameters(edited, 1)); + assert!(a.iter().zip(&b).all(|(x, y)| (x - y).abs() < 1e-9), "{a:?} vs {b:?}"); + assert_lines(&document.get_entity(handle).unwrap().clone(), document, 3); + }; + let reopened_dwg = crate::io::load_bytes("mline.dwg", + acadrust::DwgWriter::write_to_vec(host.document()).unwrap()).unwrap(); + let reopened_dxf = crate::io::load_bytes("mline.dxf", + acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()).unwrap(); + assert_saved(&reopened_dwg); + assert_saved(&reopened_dxf); + for (name, mut document) in [("mline-reedit.dwg", reopened_dwg), ("mline-reedit.dxf", reopened_dxf)] { + let before = document.get_entity(handle).unwrap().clone(); + let mut after = before.clone(); + let EntityType::MLine(mline) = &mut after else { unreachable!() }; + mline.vertices[2].position = acadrust::types::Vector3::new(10.0, 12.0, 0.0); + ocs_plugin_api::entity_coverage::validate_entity_mutation(&before, &after).unwrap(); + ocs_plugin_api::entity_coverage::validate_canvas_entity_references(&document, &after).unwrap(); + let EntityType::MLine(mline) = &mut after else { unreachable!() }; + let old = match &before { EntityType::MLine(old) => old, _ => unreachable!() }; + crate::entities::mline::normalize_scripted_mline(Some(old), mline, &document).unwrap(); + *document.get_entity_mut(handle).unwrap() = after; + let bytes = if name.ends_with("dwg") { + acadrust::DwgWriter::write_to_vec(&document).unwrap() + } else { + acadrust::DxfWriter::new(&document).write_to_vec().unwrap() + }; + assert!(matches!(crate::io::load_bytes(name, bytes).unwrap().get_entity(handle), + Some(EntityType::MLine(mline)) if (mline.vertices[2].position.y - 12.0).abs() < 1e-9)); + } + + dispatch(&mut host, &format!("PY_EVAL ocs.active_document.delete_entity({})", handle.value())); + assert!(host.document().get_entity(handle).is_none()); + for (name, bytes) in [ + ("mline-deleted.dwg", acadrust::DwgWriter::write_to_vec(host.document()).unwrap()), + ("mline-deleted.dxf", acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()), + ] { + assert!(crate::io::load_bytes(name, bytes).unwrap().get_entity(handle).is_none()); + } + drop(process); + drop(host); + app.finish_pending_history(0); + assert_eq!(app.tabs[0].history.undo_stack.len(), 3); + app.undo_steps(1); + assert_eq!(app.tabs[0].scene.document.get_entity(handle), Some(&expected)); + app.undo_steps(1); + assert_eq!(app.tabs[0].scene.document.get_entity(handle), Some(&created)); + app.undo_steps(1); + assert!(app.tabs[0].scene.document.get_entity(handle).is_none()); + app.redo_steps(3); + assert!(app.tabs[0].scene.document.get_entity(handle).is_none()); + } + + #[test] + fn staged_python_dimension_lifecycle_over_real_ipc() { + let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { + return; + }; + let runner_path = std::env::var_os("OCS_PLUGIN_RUNNER_EXE") + .expect("set OCS_PLUGIN_RUNNER_EXE to the built OpenCADStudio executable"); + assert!(std::path::Path::new(&runner_path).is_file()); + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let process = ocs_plugin_api::process::PluginProcess::spawn( + std::path::Path::new(&plugin_path), + &mut host, + crate::plugin::v4_support::notification_handler(), + ) + .unwrap(); + let dispatch = |host: &mut HostSession<'_>, command: &str| { + assert!(process.dispatch(host, command, &mut |_| {}).expect("Python dispatch")); + }; + let last_output = |host: &HostSession<'_>| host.app.command_line.history.last().unwrap().text.clone(); + + // (subtype, python fields, expected measurement) + let fixtures: [(&str, &str, f64); 9] = [ + ("Linear", "definition_point=P(10,5), first_point=P(0,0), second_point=P(10,0)", 10.0), + ("Aligned", "definition_point=P(1,5.5), first_point=P(0,0), second_point=P(3,4)", 5.0), + ("Radius", "definition_point=P(5,0), angle_vertex=P(0,0)", 5.0), + ("Diameter", "definition_point=P(10,0), angle_vertex=P(0,0)", 10.0), + ("Angular3Pt", "definition_point=P(3,3), first_point=P(5,0), second_point=P(0,5), angle_vertex=P(0,0)", 90.0), + ("Angular2Ln", "definition_point=P(0,5), dimension_arc=P(3,3), first_point=P(0,0), second_point=P(5,0), angle_vertex=P(0,0)", 90.0), + ("Ordinate", "feature_location=P(5,3), leader_endpoint=P(5,8), is_ordinate_type_x=True", 5.0), + ("Arc", "definition_point=P(3.5,3.5), first_extension_point=P(5,0), second_extension_point=P(0,5), center_point=P(0,0), arc_start_parameter=0.0, arc_end_parameter=1.5707963267948966", 5.0 * std::f64::consts::FRAC_PI_2), + ("LargeRadial", "definition_point=P(10,0), chord_point=P(4,0)", 6.0), + ]; + let mut script = String::from("def P(x, y): return {'x': x, 'y': y, 'z': 0.0}\ndoc = ocs.active_document\n"); + for (subtype, fields, _) in &fixtures { + script.push_str(&format!("doc.create_entity('Dimension', subtype='{subtype}', {fields})\n")); + } + let path = std::env::temp_dir().join(format!("ocs_dimension_create_{}.py", std::process::id())); + std::fs::write(&path, script).unwrap(); + dispatch(&mut host, &format!("PY_RUN {}", path.display())); + let _ = std::fs::remove_file(&path); + let subtype_of = |dimension: &acadrust::entities::Dimension| -> &'static str { + use acadrust::entities::Dimension as D; + match dimension { + D::Aligned(_) => "Aligned", D::Linear(_) => "Linear", D::Radius(_) => "Radius", + D::Diameter(_) => "Diameter", D::Angular2Ln(_) => "Angular2Ln", + D::Angular3Pt(_) => "Angular3Pt", D::Ordinate(_) => "Ordinate", + D::Arc(_) => "Arc", D::LargeRadial(_) => "LargeRadial", + } + }; + let handles_by_subtype = |document: &CadDocument| { + let mut map = std::collections::BTreeMap::new(); + for entity in document.entities() { + if let EntityType::Dimension(dimension) = entity { + map.insert(subtype_of(dimension), dimension.base().common.handle); + } + } + map + }; + let handles = handles_by_subtype(host.document()); + assert_eq!(handles.len(), 9, "created: {handles:?}; {}", last_output(&host)); + let measured = |document: &CadDocument, handle: Handle| -> f64 { + let Some(EntityType::Dimension(dimension)) = document.get_entity(handle) else { + panic!("Dimension missing"); + }; + dimension.base().actual_measurement + }; + for (subtype, _, expected) in &fixtures { + let value = measured(host.document(), handles[subtype]); + assert!((value - expected).abs() < 1e-6, "{subtype}: measured {value}, expected {expected}"); + let Some(EntityType::Dimension(dimension)) = host.document().get_entity(handles[subtype]) else { unreachable!() }; + assert_eq!(dimension.measurement(), value, "{subtype}: stored measurement is derived"); + let wires = host.app.tabs[0].scene.wire_models_for(&[handles[subtype]]); + assert!(!wires.is_empty(), "{subtype}: dimension produced no canvas geometry"); + } + let linear = handles["Linear"]; + let created_linear = host.document().get_entity(linear).unwrap().clone(); + + // Edit: move a defining point (the measurement follows), change text + // and style-independent placement; untouched fields survive. + let script = std::env::temp_dir().join(format!("ocs_dimension_edit_{}.py", std::process::id())); + std::fs::write(&script, format!(concat!( + "doc = ocs.active_document\n", + "linear = doc.entities[{}]\n", + "radius = doc.entities[{}]\n", + "with doc.transaction('Edit dimensions'):\n", + " linear.second_point = (20.0, 0.0, 0.0)\n", + " linear.text = '<> mm'\n", + " linear.attachment_point = 'TopCenter'\n", + " radius.definition_point = (8.0, 0.0, 0.0)\n", + "doc.selection = [linear]\n" + ), linear.value(), handles["Radius"].value())).unwrap(); + dispatch(&mut host, &format!("PY_RUN {}", script.display())); + let _ = std::fs::remove_file(&script); + let expected_linear = host.document().get_entity(linear).unwrap().clone(); + let EntityType::Dimension(edited) = &expected_linear else { unreachable!() }; + let EntityType::Dimension(before) = &created_linear else { unreachable!() }; + assert!((edited.base().actual_measurement - 20.0).abs() < 1e-9, "edit failed: {}", last_output(&host)); + assert!((measured(host.document(), handles["Radius"]) - 8.0).abs() < 1e-9); + assert_eq!(edited.base().text, "<> mm"); + assert_eq!(edited.base().attachment_point, acadrust::entities::AttachmentPointType::TopCenter); + assert_eq!(edited.base().common, before.base().common); + assert_eq!(edited.base().style_name, before.base().style_name); + assert_eq!(edited.base().normal, before.base().normal); + assert_eq!(host.selection(), vec![linear]); + let expected_radius = host.document().get_entity(handles["Radius"]).unwrap().clone(); + assert!(!host.app.tabs[0].scene.wire_models_for(&[linear]).is_empty()); + + let reject_cases: [(&str, &str, &str); 9] = [ + ("Linear", "'leader_length':2.0", "does not apply to the Linear subtype"), + ("Linear", "'subtype':'Radius'", "read-only"), + ("Linear", "'actual_measurement':5.0", "read-only"), + ("Linear", "'style_name':'Missing'", "does not exist"), + ("Linear", "'first_point':{'x':20.0,'y':0.0,'z':0.0}", "must not coincide"), + ("Linear", "'first_point':{'x':float('nan'),'y':0.0,'z':0.0}", "finite"), + ("Linear", "'normal':{'x':0.0,'y':0.0,'z':0.0}", "nonzero"), + ("Radius", "'angle_vertex':{'x':8.0,'y':0.0,'z':0.0}", "must not coincide"), + ("Linear", "'attachment_point':'Nowhere'", "unsupported AttachmentPointType"), + ]; + for (subtype, patch, message) in reject_cases { + dispatch(&mut host, &format!( + "PY_EVAL ocs.update_many('Reject dimension', [{{'handle':{}, {patch}}}])", + handles[subtype].value())); + assert_eq!(host.document().get_entity(linear), Some(&expected_linear), "{patch}"); + assert_eq!(host.document().get_entity(handles["Radius"]), Some(&expected_radius), "{patch}"); + assert!(last_output(&host).contains(message), "{patch}: {}", last_output(&host)); + } + dispatch(&mut host, &format!(concat!( + "PY_EVAL ocs.update_many('Atomic', [{{'handle':{},'text':'changed'}},", + "{{'handle':{},'style_name':'Missing'}}])" + ), linear.value(), handles["Radius"].value())); + assert_eq!(host.document().get_entity(linear), Some(&expected_linear)); + for (patch, message) in [ + ("ocs.active_document.create_entity('Dimension', definition_point=P(0,0))", "requires subtype"), + ("ocs.active_document.create_entity('Dimension', subtype='Cone')", "unsupported Dimension subtype"), + ("ocs.active_document.create_entity('Dimension', subtype='Linear', first_point=P(0,0), second_point=P(1,0))", "requires definition_point"), + ] { + dispatch(&mut host, &format!("PY_EVAL (lambda P: {patch})(lambda x, y: {{'x': x, 'y': y, 'z': 0.0}})")); + assert_eq!(handles_by_subtype(host.document()).len(), 9, "{patch}"); + assert!(last_output(&host).contains(message), "{patch}: {}", last_output(&host)); + } + + // DWG and DXF: every subtype is recorded separately. + let mut failures = Vec::new(); + let dwg = crate::io::load_bytes("dimension.dwg", acadrust::DwgWriter::write_to_vec(host.document()).unwrap()).unwrap(); + let dxf = crate::io::load_bytes("dimension.dxf", acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()).unwrap(); + for (format, document) in [("DWG", &dwg), ("DXF", &dxf)] { + let reopened = handles_by_subtype(document); + for (subtype, _, expected) in &fixtures { + let expected = match *subtype { "Linear" => 20.0, "Radius" => 8.0, _ => *expected }; + match reopened.get(subtype) { + None => failures.push(format!("{format} {subtype}: missing")), + Some(handle) => { + let value = measured(document, *handle); + if (value - expected).abs() > 1e-6 { + failures.push(format!("{format} {subtype}: measurement {value} != {expected}")); + } + } + } + } + let Some(EntityType::Dimension(dimension)) = document.get_entity(linear) else { + failures.push(format!("{format} Linear handle lost")); + continue; + }; + if dimension.base().text != "<> mm" { failures.push(format!("{format} Linear text {:?}", dimension.base().text)); } + } + assert!(failures.is_empty(), "persistence gaps: {failures:#?}"); + for (name, mut document) in [("dimension-reedit.dwg", dwg), ("dimension-reedit.dxf", dxf)] { + let before = document.get_entity(linear).unwrap().clone(); + let mut after = before.clone(); + let EntityType::Dimension(dimension) = &mut after else { unreachable!() }; + if let acadrust::entities::Dimension::Linear(value) = dimension { value.second_point.x = 30.0; } + ocs_plugin_api::entity_coverage::validate_entity_mutation(&before, &after).unwrap(); + ocs_plugin_api::entity_coverage::validate_canvas_entity_references(&document, &after).unwrap(); + let EntityType::Dimension(dimension) = &mut after else { unreachable!() }; + crate::entities::dimension::normalize_scripted_dimension(dimension); + *document.get_entity_mut(linear).unwrap() = after; + let bytes = if name.ends_with("dwg") { + acadrust::DwgWriter::write_to_vec(&document).unwrap() + } else { + acadrust::DxfWriter::new(&document).write_to_vec().unwrap() + }; + let reopened = crate::io::load_bytes(name, bytes).unwrap(); + assert!((measured(&reopened, linear) - 30.0).abs() < 1e-6, "{name}"); + } + + dispatch(&mut host, &format!("PY_EVAL ocs.active_document.delete_entity({})", linear.value())); + assert!(host.document().get_entity(linear).is_none()); + drop(process); + drop(host); + app.finish_pending_history(0); + assert_eq!(app.tabs[0].history.undo_stack.len(), 3); + app.undo_steps(1); + assert_eq!(app.tabs[0].scene.document.get_entity(linear), Some(&expected_linear)); + app.undo_steps(1); + assert_eq!(app.tabs[0].scene.document.get_entity(linear), Some(&created_linear)); + app.undo_steps(1); + assert!(app.tabs[0].scene.document.get_entity(linear).is_none()); + app.redo_steps(3); + assert!(app.tabs[0].scene.document.get_entity(linear).is_none()); + } + + #[test] + fn staged_python_multileader_lifecycle_over_real_ipc() { + let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { + return; + }; + let runner_path = std::env::var_os("OCS_PLUGIN_RUNNER_EXE") + .expect("set OCS_PLUGIN_RUNNER_EXE to the built OpenCADStudio executable"); + assert!(std::path::Path::new(&runner_path).is_file()); + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let process = ocs_plugin_api::process::PluginProcess::spawn( + std::path::Path::new(&plugin_path), + &mut host, + crate::plugin::v4_support::notification_handler(), + ) + .unwrap(); + let dispatch = |host: &mut HostSession<'_>, command: &str| { + assert!(process.dispatch(host, command, &mut |_| {}).expect("Python dispatch")); + }; + let last_output = |host: &HostSession<'_>| host.app.command_line.history.last().unwrap().text.clone(); + + let script = std::env::temp_dir().join(format!("ocs_mleader_create_{}.py", std::process::id())); + std::fs::write(&script, concat!( + "def P(x, y): return {'x': x, 'y': y, 'z': 0.0}\n", + "doc = ocs.active_document\n", + "doc.create_entity('MultiLeader', content_type='MText', context={\n", + " 'has_text_contents': True, 'text_string': 'NOTE', 'text_location': P(20, 10),\n", + " 'text_height': 2.5,\n", + " 'leader_roots': [{'connection_point': P(15, 10), 'direction': P(1, 0), 'landing_distance': 2.0,\n", + " 'lines': [{'points': [P(0, 0), P(10, 10), P(15, 10)]}]}]})\n", + )).unwrap(); + dispatch(&mut host, &format!("PY_RUN {}", script.display())); + let _ = std::fs::remove_file(&script); + let handle = host.document().entities().find_map(|entity| match entity { + EntityType::MultiLeader(value) => Some(value.common.handle), + _ => None, + }).unwrap_or_else(|| panic!("Python did not create MultiLeader: {}", last_output(&host))); + let created = host.document().get_entity(handle).unwrap().clone(); + let EntityType::MultiLeader(created_ml) = &created else { unreachable!() }; + assert_eq!(created_ml.context.text_string, "NOTE"); + assert_eq!(created_ml.context.leader_roots[0].lines[0].points.len(), 3); + assert!(!host.app.tabs[0].scene.wire_models_for(&[handle]).is_empty(), "no canvas geometry"); + + let script = std::env::temp_dir().join(format!("ocs_mleader_edit_{}.py", std::process::id())); + std::fs::write(&script, format!(concat!( + "def P(x, y): return {{'x': x, 'y': y, 'z': 0.0}}\n", + "doc = ocs.active_document\n", + "ml = doc.entities[{}]\n", + "roots = ml.context['leader_roots']\n", + "roots[0]['lines'][0]['points'] = [P(0, 0), P(12, 12), P(18, 12), P(22, 12)]\n", + "with doc.transaction('Edit multileader'):\n", + " ml.context = {{'leader_roots': roots, 'text_string': 'EDITED', 'text_location': P(26, 12), 'text_height': 3.0}}\n", + " ml.dogleg_length = 3.0\n", + " ml.line_color = {{'kind': 'Rgb', 'value': {{'r': 0, 'g': 128, 'b': 255}}}}\n", + "doc.selection = [ml]\n" + ), handle.value())).unwrap(); + dispatch(&mut host, &format!("PY_RUN {}", script.display())); + let _ = std::fs::remove_file(&script); + let expected = host.document().get_entity(handle).unwrap().clone(); + let EntityType::MultiLeader(edited) = &expected else { unreachable!() }; + assert_eq!(edited.context.text_string, "EDITED", "edit failed: {}", last_output(&host)); + assert_eq!(edited.context.leader_roots[0].lines[0].points.len(), 4); + assert_eq!(edited.dogleg_length, 3.0); + assert_eq!(edited.context.text_height, 3.0); + assert_eq!(edited.text_height, created_ml.text_height); + assert_eq!(edited.line_color, acadrust::types::Color::Rgb { r: 0, g: 128, b: 255 }); + // Untouched context fields and the entity identity survive a partial patch. + assert_eq!(edited.common, created_ml.common); + assert_eq!(edited.context.scale_factor, created_ml.context.scale_factor); + assert_eq!(edited.content_type, created_ml.content_type); + assert_eq!(edited.context.leader_roots[0].connection_point, created_ml.context.leader_roots[0].connection_point); + assert_eq!(host.selection(), vec![handle]); + assert!(!host.app.tabs[0].scene.wire_models_for(&[handle]).is_empty()); + + for (patch, message) in [ + ("'text_height':0.0", "read-only"), + ("'scale_factor':-1.0", "greater than zero"), + ("'context':{'text_height':float('inf')}", "finite"), + ("'style_handle':999999", "does not exist"), + ("'text_style_handle':999999", "does not exist"), + ("'content_type':'None'", "cannot carry"), + ("'context':{'nonsense':1}", "outside the editable schema"), + ("'context':{'leader_roots':[{'lines':[{'points':[]}]}]}", "has no points"), + ("'context':{'text_location':{'x':float('nan'),'y':0.0,'z':0.0}}", "finite"), + ("'dwg_version':5", "read-only"), + ("'line_color':{'kind':'Index','value':300}", "Color"), + ] { + dispatch(&mut host, &format!( + "PY_EVAL ocs.update_many('Reject multileader', [{{'handle':{}, {patch}}}])", handle.value())); + assert_eq!(host.document().get_entity(handle), Some(&expected), "{patch}"); + assert!(last_output(&host).contains(message), "{patch}: {}", last_output(&host)); + } + dispatch(&mut host, &format!(concat!( + "PY_EVAL ocs.update_many('Atomic', [{{'handle':{},'dogleg_length':9.0}},", + "{{'handle':{},'scale_factor':-1.0}}])" + ), handle.value(), handle.value())); + assert_eq!(host.document().get_entity(handle), Some(&expected)); + + let mut gaps = Vec::new(); + let dwg = crate::io::load_bytes("mleader.dwg", acadrust::DwgWriter::write_to_vec(host.document()).unwrap()).unwrap(); + let dxf = crate::io::load_bytes("mleader.dxf", acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()).unwrap(); + for (format, document) in [("DWG", &dwg), ("DXF", &dxf)] { + let Some(EntityType::MultiLeader(value)) = document.get_entity(handle) else { + gaps.push(format!("{format}: MultiLeader missing")); + continue; + }; + if value.context.text_string != "EDITED" { gaps.push(format!("{format}: text {:?}", value.context.text_string)); } + let points = value.context.leader_roots.first().and_then(|r| r.lines.first()).map_or(0, |l| l.points.len()); + if points != 4 { gaps.push(format!("{format}: leader points {points}")); } + if value.dogleg_length != 3.0 { gaps.push(format!("{format}: dogleg {}", value.dogleg_length)); } + if value.context.text_height != edited.context.text_height { gaps.push(format!("{format}: context.text_height {}", value.context.text_height)); } + if value.line_color != edited.line_color { gaps.push(format!("{format}: line_color {:?}", value.line_color)); } + } + assert!(gaps.is_empty(), "persistence gaps: {gaps:#?}"); + for (name, mut document) in [("mleader-reedit.dwg", dwg), ("mleader-reedit.dxf", dxf)] { + let before = document.get_entity(handle).unwrap().clone(); + let mut after = before.clone(); + let EntityType::MultiLeader(value) = &mut after else { unreachable!() }; + value.dogleg_length = 4.5; + ocs_plugin_api::entity_coverage::validate_entity_mutation(&before, &after).unwrap(); + ocs_plugin_api::entity_coverage::validate_canvas_entity_references(&document, &after).unwrap(); + *document.get_entity_mut(handle).unwrap() = after; + let bytes = if name.ends_with("dwg") { + acadrust::DwgWriter::write_to_vec(&document).unwrap() + } else { + acadrust::DxfWriter::new(&document).write_to_vec().unwrap() + }; + assert!(matches!(crate::io::load_bytes(name, bytes).unwrap().get_entity(handle), + Some(EntityType::MultiLeader(value)) if value.dogleg_length == 4.5), "{name}"); + } + + dispatch(&mut host, &format!("PY_EVAL ocs.active_document.delete_entity({})", handle.value())); + assert!(host.document().get_entity(handle).is_none()); + for (name, bytes) in [ + ("mleader-deleted.dwg", acadrust::DwgWriter::write_to_vec(host.document()).unwrap()), + ("mleader-deleted.dxf", acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()), + ] { + assert!(crate::io::load_bytes(name, bytes).unwrap().get_entity(handle).is_none()); + } + drop(process); + drop(host); + app.finish_pending_history(0); + assert_eq!(app.tabs[0].history.undo_stack.len(), 3); + app.undo_steps(1); + assert_eq!(app.tabs[0].scene.document.get_entity(handle), Some(&expected)); + app.undo_steps(1); + assert_eq!(app.tabs[0].scene.document.get_entity(handle), Some(&created)); + app.undo_steps(1); + assert!(app.tabs[0].scene.document.get_entity(handle).is_none()); + app.redo_steps(3); + assert!(app.tabs[0].scene.document.get_entity(handle).is_none()); + } + + #[test] + fn staged_python_table_lifecycle_over_real_ipc() { + let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { + return; + }; + let runner_path = std::env::var_os("OCS_PLUGIN_RUNNER_EXE") + .expect("set OCS_PLUGIN_RUNNER_EXE to the built OpenCADStudio executable"); + assert!(std::path::Path::new(&runner_path).is_file()); + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let process = ocs_plugin_api::process::PluginProcess::spawn( + std::path::Path::new(&plugin_path), + &mut host, + crate::plugin::v4_support::notification_handler(), + ) + .unwrap(); + let dispatch = |host: &mut HostSession<'_>, command: &str| { + assert!(process.dispatch(host, command, &mut |_| {}).expect("Python dispatch")); + }; + let last_output = |host: &HostSession<'_>| host.app.command_line.history.last().unwrap().text.clone(); + + dispatch(&mut host, "PY_EVAL ocs.active_document.create_entity('Table', insertion_point={'x':5.0,'y':5.0,'z':0.0}).handle"); + let handle = host.document().entities().find_map(|entity| match entity { + EntityType::Table(value) => Some(value.common.handle), + _ => None, + }).unwrap_or_else(|| panic!("Python did not create Table: {}", last_output(&host))); + let created = host.document().get_entity(handle).unwrap().clone(); + let EntityType::Table(created_table) = &created else { unreachable!() }; + assert_eq!((created_table.rows.len(), created_table.columns.len()), (3, 3)); + assert_eq!(created_table.insertion_point, acadrust::types::Vector3::new(5.0, 5.0, 0.0)); + assert!(!host.app.tabs[0].scene.wire_models_for(&[handle]).is_empty(), "no canvas geometry"); + + let script = std::env::temp_dir().join(format!("ocs_table_edit_{}.py", std::process::id())); + std::fs::write(&script, format!(concat!( + "doc = ocs.active_document\n", + "t = doc.entities[{}]\n", + "rows = t.rows\n", + "cols = t.columns\n", + "rows.append(dict(rows[0]))\n", + "cols[0]['width'] = 30.0\n", + "rows[1]['cells'][1]['contents'] = [{{'content_type': 'Value', 'scale': 1.0, 'text_height': 2.5,\n", + " 'value': {{'value_type': 'String', 'raw_type_code': 4, 'text': 'Hello', 'formatted_value': 'Hello'}}}}]\n", + "with doc.transaction('Edit table'):\n", + " t.rows = rows\n", + " t.columns = cols\n", + " t.merged_ranges = [{{'top_row': 0, 'left_col': 0, 'bottom_row': 0, 'right_col': 2}}]\n", + " t.break_spacing = 2.0\n", + "doc.selection = [t]\n" + ), handle.value())).unwrap(); + dispatch(&mut host, &format!("PY_RUN {}", script.display())); + let _ = std::fs::remove_file(&script); + let expected = host.document().get_entity(handle).unwrap().clone(); + let EntityType::Table(edited) = &expected else { unreachable!() }; + assert_eq!(edited.rows.len(), 4, "edit failed: {}", last_output(&host)); + assert_eq!(edited.columns[0].width, 30.0); + assert_eq!(edited.rows[1].cells[1].contents[0].value.text, "Hello"); + assert_eq!(edited.merged_ranges.len(), 1); + assert_eq!(edited.break_spacing, 2.0); + assert_eq!(edited.common, created_table.common); + assert_eq!(edited.insertion_point, created_table.insertion_point); + assert_eq!(edited.normal, created_table.normal); + assert_eq!(edited.columns[1], created_table.columns[1]); + assert_eq!(edited.rows[2], created_table.rows[2]); + assert_eq!(edited.table_style_handle, created_table.table_style_handle); + assert_eq!(host.selection(), vec![handle]); + assert!(!host.app.tabs[0].scene.wire_models_for(&[handle]).is_empty()); + + for (patch, message) in [ + ("'columns':[{'name':'A','width':0.0},{'width':1.0},{'width':1.0}]", "greater than zero"), + ("'columns':[{'width':5.0}]", "cells but the table has 1 columns"), + ("'columns':[]", "at least one row and one column"), + ("'merged_ranges':[{'top_row':0,'left_col':0,'bottom_row':9,'right_col':0}]", "outside the"), + ("'merged_ranges':[{'top_row':1,'left_col':0,'bottom_row':2,'right_col':1},{'top_row':2,'left_col':1,'bottom_row':3,'right_col':2}]", "overlaps"), + ("'table_style_handle':999999", "does not exist"), + ("'block_name':'x'", "read-only"), + ("'normal':{'x':0.0,'y':0.0,'z':0.0}", "nonzero"), + ("'break_spacing':-1.0", "non-negative"), + ("'insertion_point':{'x':float('nan'),'y':0.0,'z':0.0}", "finite"), + ] { + dispatch(&mut host, &format!( + "PY_EVAL ocs.update_many('Reject table', [{{'handle':{}, {patch}}}])", handle.value())); + assert_eq!(host.document().get_entity(handle), Some(&expected), "{patch}"); + assert!(last_output(&host).contains(message), "{patch}: {}", last_output(&host)); + } + dispatch(&mut host, &format!(concat!( + "PY_EVAL ocs.update_many('Atomic', [{{'handle':{},'break_spacing':7.0}},", + "{{'handle':{},'break_spacing':-1.0}}])" + ), handle.value(), handle.value())); + assert_eq!(host.document().get_entity(handle), Some(&expected)); + + let mut gaps = Vec::new(); + let dwg = crate::io::load_bytes("table.dwg", acadrust::DwgWriter::write_to_vec(host.document()).unwrap()).unwrap(); + let dxf = crate::io::load_bytes("table.dxf", acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()).unwrap(); + for (format, document) in [("DWG", &dwg), ("DXF", &dxf)] { + let Some(EntityType::Table(value)) = document.get_entity(handle) else { + gaps.push(format!("{format}: Table missing")); + continue; + }; + if value.rows.len() != 4 { gaps.push(format!("{format}: rows {}", value.rows.len())); } + if value.columns.first().map(|c| c.width) != Some(30.0) { gaps.push(format!("{format}: column width {:?}", value.columns.first().map(|c| c.width))); } + let text = value.rows.get(1).and_then(|r| r.cells.get(1)).and_then(|c| c.contents.first()).map(|c| c.value.text.clone()); + if text.as_deref() != Some("Hello") { gaps.push(format!("{format}: cell text {text:?}")); } + // DWG stores the ranges; DXF stores the origin cell's merge + // dimensions and its reader does not rebuild `merged_ranges` + // (engine gap, recorded in the ledger). Scripted saves populate + // both, so each format keeps the merge in its own form. + let origin = &value.rows[0].cells[0]; + if format == "DXF" { + if (origin.merge_width, origin.merge_height) != (3, 1) { gaps.push(format!("DXF: merge dims {}x{}", origin.merge_width, origin.merge_height)); } + if !value.merged_ranges.is_empty() { gaps.push("DXF: reader now rebuilds merged_ranges; drop the blocker".into()); } + } else if value.merged_ranges.len() != 1 { + gaps.push(format!("DWG: merged_ranges {}", value.merged_ranges.len())); + } + if value.insertion_point != edited.insertion_point { gaps.push(format!("{format}: insertion {:?}", value.insertion_point)); } + } + assert!(gaps.is_empty(), "persistence gaps: {gaps:#?}"); + for (name, mut document) in [("table-reedit.dwg", dwg), ("table-reedit.dxf", dxf)] { + let before = document.get_entity(handle).unwrap().clone(); + let mut after = before.clone(); + let EntityType::Table(value) = &mut after else { unreachable!() }; + value.columns[1].width = 12.0; + ocs_plugin_api::entity_coverage::validate_entity_mutation(&before, &after).unwrap(); + ocs_plugin_api::entity_coverage::validate_canvas_entity_references(&document, &after).unwrap(); + *document.get_entity_mut(handle).unwrap() = after; + let bytes = if name.ends_with("dwg") { + acadrust::DwgWriter::write_to_vec(&document).unwrap() + } else { + acadrust::DxfWriter::new(&document).write_to_vec().unwrap() + }; + assert!(matches!(crate::io::load_bytes(name, bytes).unwrap().get_entity(handle), + Some(EntityType::Table(value)) if value.columns[1].width == 12.0), "{name}"); + } + + dispatch(&mut host, &format!("PY_EVAL ocs.active_document.delete_entity({})", handle.value())); + assert!(host.document().get_entity(handle).is_none()); + for (name, bytes) in [ + ("table-deleted.dwg", acadrust::DwgWriter::write_to_vec(host.document()).unwrap()), + ("table-deleted.dxf", acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()), + ] { + assert!(crate::io::load_bytes(name, bytes).unwrap().get_entity(handle).is_none()); + } + drop(process); + drop(host); + app.finish_pending_history(0); + assert_eq!(app.tabs[0].history.undo_stack.len(), 3); + app.undo_steps(1); + assert_eq!(app.tabs[0].scene.document.get_entity(handle), Some(&expected)); + app.undo_steps(1); + assert_eq!(app.tabs[0].scene.document.get_entity(handle), Some(&created)); + app.undo_steps(1); + assert!(app.tabs[0].scene.document.get_entity(handle).is_none()); + app.redo_steps(3); + assert!(app.tabs[0].scene.document.get_entity(handle).is_none()); + } + + /// One scripted lifecycle shared by the mesh kinds: create, edit, + /// reject, save/reopen both formats, re-edit, delete, undo/redo. + struct MeshCase { + create: &'static str, + edit: &'static str, + rejects: &'static [(&'static str, &'static str)], + is_kind: fn(&EntityType) -> bool, + digest: fn(&EntityType) -> String, + reedit: fn(&mut EntityType), + expect_created: &'static str, + expect_edited: &'static str, + expect_reedited: &'static str, + /// Digest a DXF reopen yields when the engine loses a field (empty: + /// same as DWG). A canary that fails once the engine round-trips it. + expect_edited_dxf: &'static str, + expect_reedited_dxf: &'static str, + /// Same idea for DWG (empty: same as the in-session digest). + expect_edited_dwg: &'static str, + expect_reedited_dwg: &'static str, + } + + fn run_mesh_lifecycle(case: &MeshCase) { + let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { + return; + }; + let runner_path = std::env::var_os("OCS_PLUGIN_RUNNER_EXE") + .expect("set OCS_PLUGIN_RUNNER_EXE to the built OpenCADStudio executable"); + assert!(std::path::Path::new(&runner_path).is_file()); + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let process = ocs_plugin_api::process::PluginProcess::spawn( + std::path::Path::new(&plugin_path), + &mut host, + crate::plugin::v4_support::notification_handler(), + ) + .unwrap(); + let dispatch = |host: &mut HostSession<'_>, command: &str| { + assert!(process.dispatch(host, command, &mut |_| {}).expect("Python dispatch")); + }; + let last_output = |host: &HostSession<'_>| host.app.command_line.history.last().unwrap().text.clone(); + let run_script = |host: &mut HostSession<'_>, tag: &str, body: &str| { + let path = std::env::temp_dir().join(format!("ocs_mesh_{tag}_{}.py", std::process::id())); + std::fs::write(&path, format!("def P(x, y, z): return {{'x': x, 'y': y, 'z': z}}\ndoc = ocs.active_document\n{body}")).unwrap(); + dispatch(host, &format!("PY_RUN {}", path.display())); + let _ = std::fs::remove_file(&path); + }; + + // Substituted into Python string literals: on Windows a backslash path such as + // `C:\Users\...` is read as escape sequences (`\U` is a unicode escape), and Windows + // accepts forward slashes, so hand the script a forward-slash path. + let tmp = std::env::temp_dir().to_string_lossy().replace('\\', "/"); + let mut create_script = case.create.replace("TMPDIR", &tmp); + // Insert is update-only: the host makes a block and one insert, and the + // "create" script only has to exist. + let make_polyline = create_script.contains("MAKEPOLYLINE"); + if make_polyline { + // The legacy Polyline is update-only: the host makes one to edit. + let points = [(0.0, 0.0, 0.0), (10.0, 0.0, 2.0), (10.0, 5.0, 4.0)] + .map(|(x, y, z)| acadrust::types::Vector3::new(x, y, z)); + host.add_entity(EntityType::Polyline(acadrust::entities::Polyline::from_points(points.to_vec()))); + } + let make_ole = create_script.contains("MAKEOLE"); + if make_ole { + // An Ole2Frame is update-only: the host embeds a real picture. + let mut png = std::io::Cursor::new(Vec::new()); + image::RgbaImage::from_pixel(4, 3, image::Rgba([10, 120, 200, 255])) + .write_to(&mut png, image::ImageFormat::Png) + .unwrap(); + let picture = crate::io::ole_embed::EmbeddedImage { + bytes: png.into_inner(), + pixel_width: 4, + pixel_height: 3, + name: "audit.png".into(), + }; + crate::io::ole_embed::add_embedded_image( + host.document_mut(), + &picture, + acadrust::types::Vector3::new(10.0, 10.0, 0.0), + 20.0, + ) + .unwrap(); + } + let update_only = create_script.contains("MAKEINSERT") || make_polyline || make_ole; + if update_only || create_script.contains("MAKEBLOCK") { + let next = host.document().next_handle(); + let (record_handle, block_handle, end_handle) = (Handle::new(next), Handle::new(next + 1), Handle::new(next + 2)); + let mut record = acadrust::tables::BlockRecord::new("AUDITBLK"); + record.handle = record_handle; + record.block_entity_handle = block_handle; + record.block_end_handle = end_handle; + host.document_mut().block_records.add(record).unwrap(); + let mut block = acadrust::entities::Block::new("AUDITBLK", acadrust::types::Vector3::ZERO); + block.common.handle = block_handle; + block.common.owner_handle = record_handle; + host.document_mut().add_entity(EntityType::Block(block)).unwrap(); + let mut end = acadrust::entities::BlockEnd::new(); + end.common.handle = end_handle; + end.common.owner_handle = record_handle; + host.document_mut().add_entity(EntityType::BlockEnd(end)).unwrap(); + if update_only { + let insert = acadrust::entities::Insert::new("AUDITBLK", acadrust::types::Vector3::new(1.0, 2.0, 0.0)); + host.add_entity(EntityType::Insert(insert)); + } + create_script = create_script.replace("BLOCKRECORD", &record_handle.value().to_string()); + } + let paper = host.document().block_records.iter().find(|r| r.is_paper_space()).map(|r| r.handle.value()); + let layer0 = host.document().layers.iter().find(|l| l.name == "0").map(|l| l.handle.value()); + if create_script.contains("VIEWPORTHANDLE") || create_script.contains("SCALEHANDLE") { + // A view border ties to an existing viewport and scale. + let owner = host.document().block_records.iter().find(|r| r.is_paper_space()).map(|r| r.handle).unwrap(); + let mut viewport = acadrust::entities::Viewport::new(); + viewport.common.owner_handle = owner; + let viewport_handle = host.add_entity(EntityType::Viewport(viewport)); + let scale_handle = host.document_mut().allocate_handle(); + let mut scale = acadrust::objects::Scale::new("1:1", 1.0, 1.0); + scale.handle = scale_handle; + host.document_mut().objects.insert(scale_handle, acadrust::objects::ObjectType::Scale(scale)); + create_script = create_script + .replace("VIEWPORTHANDLE", &viewport_handle.value().to_string()) + .replace("SCALEHANDLE", &scale_handle.value().to_string()); + } + create_script = create_script + .replace("PAPERSPACE", &paper.unwrap_or_default().to_string()) + .replace("LAYER0", &layer0.unwrap_or_default().to_string()); + if create_script.contains("DEFHANDLE") { + // Scripts reference an underlay definition that the drawing owns. + let handle = host.document_mut().allocate_handle(); + let mut definition = acadrust::objects::UnderlayDefinition::pdf("plan.pdf", "1"); + definition.handle = handle; + host.document_mut().objects.insert(handle, acadrust::objects::ObjectType::UnderlayDefinition(definition)); + create_script = create_script.replace("DEFHANDLE", &handle.value().to_string()); + } + run_script(&mut host, "create", &create_script); + let handle = host.document().entities().find(|entity| (case.is_kind)(entity)) + .map(|entity| entity.common().handle) + .unwrap_or_else(|| panic!("Python did not create the mesh: {}", last_output(&host))); + let created = host.document().get_entity(handle).unwrap().clone(); + assert_eq!((case.digest)(&created), case.expect_created); + // A paper-space viewport draws only on its layout tab, so the canvas + // oracle first activates the layout that owns it. + if matches!(created, EntityType::Viewport(_)) { + let owner = created.common().owner_handle; + let layout = host.document().objects.values().find_map(|object| match object { + acadrust::objects::ObjectType::Layout(l) if l.block_record == owner => Some(l.name.clone()), + _ => None, + }).expect("the viewport's owner is a layout block"); + host.app.tabs[0].scene.set_current_layout(layout); + } + let has_wires = true; + assert!(!host.app.tabs[0].scene.wire_models_for(&[handle]).is_empty(), "no canvas geometry"); + + run_script(&mut host, "edit", &case.edit.replace("HANDLE", &handle.value().to_string()).replace("TMPDIR", &tmp).replace("LAYER0", &layer0.unwrap_or_default().to_string())); + let expected = host.document().get_entity(handle).unwrap().clone(); + assert_eq!((case.digest)(&expected), case.expect_edited, "edit failed: {}", last_output(&host)); + assert_eq!(expected.common(), created.common()); + assert_eq!(host.selection(), vec![handle]); + if has_wires { + assert!(!host.app.tabs[0].scene.wire_models_for(&[handle]).is_empty()); + } + + for (patch, message) in case.rejects { + dispatch(&mut host, &format!( + "PY_EVAL ocs.update_many('Reject mesh', [{{'handle':{}, {patch}}}])", handle.value())); + assert_eq!(host.document().get_entity(handle), Some(&expected), "{patch}"); + assert!(last_output(&host).contains(message), "{patch}: {}", last_output(&host)); + } + let (first_patch, _) = case.rejects[0]; + dispatch(&mut host, &format!( + "PY_EVAL ocs.update_many('Atomic', [{{'handle':{h}, 'layer':'Other'}}, {{'handle':{h}, {first_patch}}}])", + h = handle.value())); + assert_eq!(host.document().get_entity(handle), Some(&expected)); + + let dwg = crate::io::load_bytes("mesh.dwg", acadrust::DwgWriter::write_to_vec(host.document()).unwrap()).unwrap(); + let dxf = crate::io::load_bytes("mesh.dxf", acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()).unwrap(); + let mut gaps = Vec::new(); + for (format, document) in [("DWG", &dwg), ("DXF", &dxf)] { + let expected_digest = if format == "DXF" && !case.expect_edited_dxf.is_empty() { + case.expect_edited_dxf + } else if format == "DWG" && !case.expect_edited_dwg.is_empty() { + case.expect_edited_dwg + } else { + case.expect_edited + }; + match document.get_entity(handle) { + Some(entity) if (case.digest)(entity) == expected_digest => {} + Some(entity) => gaps.push(format!("{format}: {}", (case.digest)(entity))), + None => gaps.push(format!("{format}: entity missing")), + } + } + assert!(gaps.is_empty(), "persistence gaps (expected {} / DXF {:?}): {gaps:#?}", case.expect_edited, case.expect_edited_dxf); + for (name, mut document) in [("mesh-reedit.dwg", dwg), ("mesh-reedit.dxf", dxf)] { + let before = document.get_entity(handle).unwrap().clone(); + let mut after = before.clone(); + (case.reedit)(&mut after); + ocs_plugin_api::entity_coverage::validate_entity_mutation(&before, &after).unwrap(); + ocs_plugin_api::entity_coverage::validate_canvas_entity_references(&document, &after).unwrap(); + *document.get_entity_mut(handle).unwrap() = after; + let bytes = if name.ends_with("dwg") { + acadrust::DwgWriter::write_to_vec(&document).unwrap() + } else { + acadrust::DxfWriter::new(&document).write_to_vec().unwrap() + }; + let reopened = crate::io::load_bytes(name, bytes).unwrap(); + let expected_digest = if name.ends_with("dxf") && !case.expect_reedited_dxf.is_empty() { + case.expect_reedited_dxf + } else if name.ends_with("dwg") && !case.expect_reedited_dwg.is_empty() { + case.expect_reedited_dwg + } else { + case.expect_reedited + }; + assert_eq!((case.digest)(reopened.get_entity(handle).unwrap()), expected_digest, "{name}"); + } + + dispatch(&mut host, &format!("PY_EVAL ocs.active_document.delete_entity({})", handle.value())); + assert!(host.document().get_entity(handle).is_none()); + for (name, bytes) in [ + ("mesh-deleted.dwg", acadrust::DwgWriter::write_to_vec(host.document()).unwrap()), + ("mesh-deleted.dxf", acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()), + ] { + assert!(crate::io::load_bytes(name, bytes).unwrap().get_entity(handle).is_none()); + } + drop(process); + drop(host); + app.finish_pending_history(0); + assert_eq!(app.tabs[0].history.undo_stack.len(), if update_only { 2 } else { 3 }); + app.undo_steps(1); + assert_eq!(app.tabs[0].scene.document.get_entity(handle), Some(&expected)); + app.undo_steps(1); + assert_eq!(app.tabs[0].scene.document.get_entity(handle), Some(&created)); + if !update_only { + app.undo_steps(1); + assert!(app.tabs[0].scene.document.get_entity(handle).is_none()); + } + app.redo_steps(if update_only { 2 } else { 3 }); + assert!(app.tabs[0].scene.document.get_entity(handle).is_none()); + } + + #[test] + fn staged_python_polygon_mesh_lifecycle_over_real_ipc() { + run_mesh_lifecycle(&MeshCase { + create: concat!( + "verts = [{'location': P(i, j, 0.0)} for i in range(3) for j in range(3)]\n", + "doc.create_entity('PolygonMesh', m_vertex_count=3, n_vertex_count=3, vertices=verts)\n"), + edit: concat!( + "mesh = doc.entities[HANDLE]\n", + "verts = mesh.vertices\n", + "verts[4]['location'] = P(1.0, 1.0, 2.5)\n", + "with doc.transaction('Edit mesh'):\n", + " mesh.vertices = verts\n", + " mesh.m_smooth_density = 6\n", + " mesh.n_smooth_density = 6\n", + " mesh.smooth_type = 'Cubic'\n", + "doc.selection = [mesh]\n"), + rejects: &[ + ("'m_vertex_count':4", "requires 12"), + ("'n_vertex_count':1", "at least 2"), + ("'m_smooth_density':-1", "non-negative"), + ("'elevation':float('nan')", "finite"), + ("'normal':{'x':0.0,'y':0.0,'z':0.0}", "nonzero"), + ("'vertices':[{'location':{'x':float('nan'),'y':0.0,'z':0.0}}]*9", "finite"), + ("'smooth_type':'Spline'", "unsupported"), + ], + is_kind: |entity| matches!(entity, EntityType::PolygonMesh(_)), + digest: |entity| match entity { + EntityType::PolygonMesh(m) => format!("{}x{} v{} z{} d{}/{} {:?}", m.m_vertex_count, m.n_vertex_count, + m.vertices.len(), m.vertices[4].location.z, m.m_smooth_density, m.n_smooth_density, m.smooth_type), + _ => "wrong kind".into(), + }, + reedit: |entity| if let EntityType::PolygonMesh(m) = entity { m.vertices[0].location.z = 1.0; }, + expect_created: "3x3 v9 z0 d0/0 NoSmooth", + expect_edited: "3x3 v9 z2.5 d6/6 Cubic", + expect_reedited: "3x3 v9 z2.5 d6/6 Cubic", + expect_edited_dwg: "", + expect_reedited_dwg: "", + expect_edited_dxf: "", + expect_reedited_dxf: "", + }); + } + + #[test] + fn staged_python_polyface_mesh_lifecycle_over_real_ipc() { + run_mesh_lifecycle(&MeshCase { + create: concat!( + "verts = [{'location': P(0.0, 0.0, 0.0)}, {'location': P(4.0, 0.0, 0.0)},\n", + " {'location': P(4.0, 4.0, 0.0)}, {'location': P(0.0, 4.0, 0.0)}]\n", + "faces = [{'index1': 1, 'index2': 2, 'index3': 3}, {'index1': 1, 'index2': 3, 'index3': 4}]\n", + "doc.create_entity('PolyfaceMesh', vertices=verts, faces=faces)\n"), + edit: concat!( + "mesh = doc.entities[HANDLE]\n", + "verts = mesh.vertices\n", + "verts[2]['location'] = P(4.0, 4.0, 3.0)\n", + "verts.append({'location': P(2.0, 6.0, 0.0)})\n", + "faces = mesh.faces\n", + "faces.append({'index1': 3, 'index2': -4, 'index3': 5})\n", + "with doc.transaction('Edit mesh'):\n", + " mesh.vertices = verts\n", + " mesh.faces = faces\n", + "doc.selection = [mesh]\n"), + rejects: &[ + ("'faces':[{'index1':1,'index2':2,'index3':9}]", "references vertex 9"), + ("'faces':[{'index1':1,'index2':2}]", "at least 3 vertex indices"), + ("'faces':[]", "at least 1 face"), + ("'vertices':[{'location':{'x':0.0,'y':0.0,'z':0.0}}]", "at least 3"), + ("'thickness':float('inf')", "finite"), + ("'seqend_handle':5", "read-only"), + ], + is_kind: |entity| matches!(entity, EntityType::PolyfaceMesh(_)), + digest: |entity| match entity { + EntityType::PolyfaceMesh(m) => format!("v{} f{} z{} last {:?}", m.vertices.len(), m.faces.len(), + m.vertices[2].location.z, + m.faces.last().map(|f| (f.index1, f.index2, f.index3))), + _ => "wrong kind".into(), + }, + reedit: |entity| if let EntityType::PolyfaceMesh(m) = entity { m.vertices[0].location.z = 1.0; }, + expect_created: "v4 f2 z0 last Some((1, 3, 4))", + expect_edited: "v5 f3 z3 last Some((3, -4, 5))", + expect_reedited: "v5 f3 z3 last Some((3, -4, 5))", + expect_edited_dwg: "", + expect_reedited_dwg: "", + expect_edited_dxf: "", + expect_reedited_dxf: "", + }); + } + + #[test] + fn staged_python_mesh_lifecycle_over_real_ipc() { + run_mesh_lifecycle(&MeshCase { + create: concat!( + "verts = [P(0.0, 0.0, 0.0), P(4.0, 0.0, 0.0), P(4.0, 4.0, 0.0), P(0.0, 4.0, 0.0)]\n", + "doc.create_entity('Mesh', vertices=verts, faces=[{'vertices': [0, 1, 2, 3]}],\n", + " edges=[{'start': 0, 'end': 1, 'crease': 1.5}])\n"), + edit: concat!( + "mesh = doc.entities[HANDLE]\n", + "verts = mesh.vertices\n", + "verts.append(P(2.0, 6.0, 0.0))\n", + "faces = mesh.faces\n", + "faces.append({'vertices': [3, 2, 4]})\n", + "with doc.transaction('Edit mesh'):\n", + " mesh.vertices = verts\n", + " mesh.faces = faces\n", + " mesh.subdivision_level = 1\n", + "doc.selection = [mesh]\n"), + rejects: &[ + ("'faces':[{'vertices':[0,1,9]}]", "references vertex 9"), + ("'faces':[{'vertices':[0,1]}]", "at least 3 vertices"), + ("'edges':[{'start':0,'end':9}]", "out of range"), + ("'edges':[{'start':2,'end':2}]", "identical endpoints"), + ("'edges':[{'start':0,'end':1,'crease':-1.0}]", "non-negative"), + ("'subdivision_level':-1", "non-negative"), + ("'version':3", "read-only"), + ], + is_kind: |entity| matches!(entity, EntityType::Mesh(_)), + digest: |entity| match entity { + EntityType::Mesh(m) => format!("v{} f{} e{} sub{} crease {:?}", m.vertices.len(), m.faces.len(), + m.edges.len(), m.subdivision_level, m.edges.first().and_then(|e| e.crease)), + _ => "wrong kind".into(), + }, + reedit: |entity| if let EntityType::Mesh(m) = entity { m.vertices[0].z = 1.0; }, + expect_created: "v4 f1 e1 sub0 crease Some(1.5)", + expect_edited: "v5 f2 e1 sub1 crease Some(1.5)", + expect_reedited: "v5 f2 e1 sub1 crease Some(1.5)", + expect_edited_dwg: "", + expect_reedited_dwg: "", + expect_edited_dxf: "", + expect_reedited_dxf: "", + }); + } + + #[test] + fn staged_python_helix_lifecycle_over_real_ipc() { + run_mesh_lifecycle(&MeshCase { + create: concat!( + "doc.create_entity('Helix', axis_base_point=P(0.0, 0.0, 0.0), axis_vector=P(0.0, 0.0, 1.0),\n", + " start_point=P(5.0, 0.0, 0.0), radius=5.0, turns=3.0, turn_height=2.0)\n"), + edit: concat!( + "helix = doc.entities[HANDLE]\n", + "with doc.transaction('Edit helix'):\n", + " helix.turns = 5.0\n", + " helix.handedness = False\n", + "doc.selection = [helix]\n"), + rejects: &[ + ("'radius':0.0", "greater than zero"), + ("'turns':-1.0", "greater than zero"), + ("'turn_height':float('nan')", "greater than zero"), + ("'axis_vector':{'x':0.0,'y':0.0,'z':0.0}", "nonzero"), + ("'start_point':{'x':0.0,'y':0.0,'z':3.0}", "on the axis"), + ("'spline':{}", "read-only"), + ("'major_version':1", "read-only"), + ], + is_kind: |entity| matches!(entity, EntityType::Helix(_)), + digest: |entity| match entity { + EntityType::Helix(h) => format!("r{} t{} h{} endz{:.1} ccw{} cp{}", h.radius, h.turns, h.turn_height, + h.spline.control_points.last().map_or(f64::NAN, |p| p.z), h.handedness, + !h.spline.control_points.is_empty()), + _ => "wrong kind".into(), + }, + reedit: |entity| if let EntityType::Helix(h) = entity { h.turns = 6.0; }, + expect_created: "r5 t3 h2 endz6.0 ccwtrue cptrue", + expect_edited: "r5 t5 h2 endz10.0 ccwfalse cptrue", + expect_reedited: "r5 t6 h2 endz10.0 ccwfalse cptrue", + // BLOCKER: the DXF reader parses boolean group 290 as an i16 and + // never applies it, so handedness always reopens counter-clockwise. + expect_edited_dwg: "", + expect_reedited_dwg: "", + expect_edited_dxf: "r5 t5 h2 endz10.0 ccwtrue cptrue", + expect_reedited_dxf: "r5 t6 h2 endz10.0 ccwtrue cptrue", + }); + } + + #[test] + fn staged_python_raster_image_lifecycle_over_real_ipc() { + image::RgbaImage::from_pixel(8, 4, image::Rgba([200, 30, 30, 255])) + .save(std::env::temp_dir().join("ocs_raster_test.png")) + .unwrap(); + run_mesh_lifecycle(&MeshCase { + create: concat!( + "doc.create_entity('RasterImage', file_path='TMPDIR/ocs_raster_test.png',\n", + " insertion_point=P(10.0, 20.0, 0.0), u_vector=P(0.5, 0.0, 0.0), v_vector=P(0.0, 0.5, 0.0))\n"), + edit: concat!( + "img = doc.entities[HANDLE]\n", + "cb = img.clip_boundary\n", + "cb['vertices'] = [{'x': 1.0, 'y': 1.0}, {'x': 7.0, 'y': 3.0}]\n", + "with doc.transaction('Edit image'):\n", + " img.insertion_point = (12.0, 22.0, 0.0)\n", + " img.u_vector = (1.0, 0.0, 0.0)\n", + " img.brightness = 70\n", + " img.contrast = 40\n", + " img.fade = 10\n", + " img.clip_boundary = cb\n", + " img.clipping_enabled = True\n", + "doc.selection = [img]\n"), + rejects: &[ + ("'file_path':'other.png'", "fixed at creation"), + ("'brightness':101", "within 0..=100"), + ("'u_vector':{'x':0.0,'y':0.0,'z':0.0}", "not parallel"), + ("'v_vector':{'x':2.0,'y':0.0,'z':0.0}", "not parallel"), + ("'size':{'x':5.0,'y':5.0}", "read-only"), + ("'definition_handle':5", "read-only"), + ("'flags':64", "unknown or out-of-range bits"), + ("'clip_boundary':{'vertices':[]}", "at least 2"), + ], + is_kind: |entity| matches!(entity, EntityType::RasterImage(_)), + digest: |entity| match entity { + EntityType::RasterImage(i) => format!("{} {:.1},{:.1} u{:.1} b{} c{} f{} clip{} n{} {}x{} def{}", + std::path::Path::new(&i.file_path).file_name().unwrap().to_string_lossy(), + i.insertion_point.x, i.insertion_point.y, i.u_vector.x, i.brightness, i.contrast, i.fade, + i.clipping_enabled, i.clip_boundary.vertices.len(), i.size.x, i.size.y, i.definition_handle.is_some()), + _ => "wrong kind".into(), + }, + reedit: |entity| if let EntityType::RasterImage(i) = entity { i.brightness = 80; }, + expect_created: "ocs_raster_test.png 10.0,20.0 u0.5 b50 c50 f0 clipfalse n2 8x4 deftrue", + expect_edited: "ocs_raster_test.png 12.0,22.0 u1.0 b70 c40 f10 cliptrue n2 8x4 deftrue", + expect_reedited: "ocs_raster_test.png 12.0,22.0 u1.0 b80 c40 f10 cliptrue n2 8x4 deftrue", + expect_edited_dwg: "", + expect_reedited_dwg: "", + expect_edited_dxf: "", + expect_reedited_dxf: "", + }); + } + + #[test] + fn staged_python_wipeout_lifecycle_over_real_ipc() { + run_mesh_lifecycle(&MeshCase { + create: concat!( + "doc.create_entity('Wipeout', insertion_point=P(0.0, 0.0, 0.0),\n", + " u_vector=P(10.0, 0.0, 0.0), v_vector=P(0.0, 6.0, 0.0))\n"), + edit: concat!( + "w = doc.entities[HANDLE]\n", + "with doc.transaction('Edit wipeout'):\n", + " w.clip_type = 'Polygonal'\n", + " w.clip_boundary_vertices = [{'x': -0.5, 'y': -0.5}, {'x': 0.5, 'y': -0.5}, {'x': 0.0, 'y': 0.5}]\n", + " w.insertion_point = (2.0, 3.0, 0.0)\n", + " w.clip_mode = 'Inside'\n", + "doc.selection = [w]\n"), + rejects: &[ + ("'clip_boundary_vertices':[{'x':0.0,'y':0.0}]", "at least 3"), + ("'clip_type':'Rectangular'", "exactly 2"), + ("'u_vector':{'x':0.0,'y':0.0,'z':0.0}", "not parallel"), + ("'brightness':200", "within 0..=100"), + ("'size':{'x':0.0,'y':1.0}", "greater than zero"), + ("'insertion_point':{'x':float('nan'),'y':0.0,'z':0.0}", "finite"), + ("'definition_handle':5", "read-only"), + ("'clip_type':'Circle'", "unsupported"), + ], + is_kind: |entity| matches!(entity, EntityType::Wipeout(_)), + digest: |entity| match entity { + EntityType::Wipeout(w) => format!("{:?} {:?} n{} at{:.1},{:.1} u{:.1} v{:.1}", w.clip_type, w.clip_mode, + w.clip_boundary_vertices.len(), w.insertion_point.x, w.insertion_point.y, w.u_vector.x, w.v_vector.y), + _ => "wrong kind".into(), + }, + reedit: |entity| if let EntityType::Wipeout(w) = entity { w.u_vector.x = 12.0; }, + expect_created: "Rectangular Outside n2 at0.0,0.0 u10.0 v6.0", + expect_edited: "Polygonal Inside n3 at2.0,3.0 u10.0 v6.0", + expect_reedited: "Polygonal Inside n3 at2.0,3.0 u12.0 v6.0", + expect_edited_dwg: "", + expect_reedited_dwg: "", + expect_edited_dxf: "", + expect_reedited_dxf: "", + }); + } + + #[test] + fn staged_python_underlay_lifecycle_over_real_ipc() { + run_mesh_lifecycle(&MeshCase { + create: concat!( + "doc.create_entity('Underlay', underlay_type='Pdf', definition_handle=DEFHANDLE,\n", + " insertion_point=P(5.0, 5.0, 0.0), x_scale=2.0, y_scale=2.0)\n"), + edit: concat!( + "u = doc.entities[HANDLE]\n", + "with doc.transaction('Edit underlay'):\n", + " u.insertion_point = (8.0, 9.0, 0.0)\n", + " u.x_scale = 3.0\n", + " u.y_scale = 3.0\n", + " u.rotation = 0.5\n", + " u.contrast = 70\n", + " u.fade = 20\n", + " u.clip_boundary_vertices = [{'x': 0.0, 'y': 0.0}, {'x': 4.0, 'y': 3.0}]\n", + "doc.selection = [u]\n"), + rejects: &[ + ("'definition_handle':999999", "does not exist"), + ("'underlay_type':'Dwf'", "does not match its definition"), + ("'x_scale':0.0", "nonzero"), + ("'rotation':float('nan')", "finite"), + ("'contrast':101", "within 0..=100"), + ("'normal':{'x':0.0,'y':0.0,'z':0.0}", "nonzero"), + ("'clip_boundary_vertices':[{'x':1.0,'y':1.0}]", "at least 2"), + ("'flags':64", "unknown or out-of-range bits"), + ], + is_kind: |entity| matches!(entity, EntityType::Underlay(_)), + digest: |entity| match entity { + EntityType::Underlay(u) => format!("{:?} at{:.1},{:.1} s{:.1} r{:.1} c{} f{} clip{}", u.underlay_type, + u.insertion_point.x, u.insertion_point.y, u.x_scale, u.rotation, u.contrast, u.fade, + u.clip_boundary_vertices.len()), + _ => "wrong kind".into(), + }, + reedit: |entity| if let EntityType::Underlay(u) = entity { u.x_scale = 4.0; }, + expect_created: "Pdf at5.0,5.0 s2.0 r0.0 c100 f0 clip0", + expect_edited: "Pdf at8.0,9.0 s3.0 r0.5 c70 f20 clip2", + expect_reedited: "Pdf at8.0,9.0 s4.0 r0.5 c70 f20 clip2", + // BLOCKER: DXF stores the rotation in degrees and the reader returns + // it unconverted, so 0.5 rad reopens as 28.6 and a second save-reopen compounds it to 1641.4. + expect_edited_dwg: "", + expect_reedited_dwg: "", + expect_edited_dxf: "Pdf at8.0,9.0 s3.0 r28.6 c70 f20 clip2", + expect_reedited_dxf: "Pdf at8.0,9.0 s4.0 r1641.4 c70 f20 clip2", + }); + } + + #[test] + fn staged_python_viewport_lifecycle_over_real_ipc() { + run_mesh_lifecycle(&MeshCase { + create: concat!( + "doc.create_entity('Viewport', owner_handle=PAPERSPACE, center=P(100.0, 100.0, 0.0),\n", + " width=80.0, height=60.0, view_center=P(0.0, 0.0, 0.0), view_height=50.0)\n"), + edit: concat!( + "vp = doc.entities[HANDLE]\n", + "with doc.transaction('Edit viewport'):\n", + " vp.center = (120.0, 110.0, 0.0)\n", + " vp.width = 90.0\n", + " vp.view_height = 75.0\n", + " vp.twist_angle = 0.25\n", + " vp.frozen_layers = [LAYER0]\n", + "doc.selection = [vp]\n"), + rejects: &[ + ("'width':0.0", "greater than zero"), + ("'view_height':float('nan')", "greater than zero"), + ("'view_direction':{'x':0.0,'y':0.0,'z':0.0}", "nonzero"), + ("'frozen_layers':[999999]", "does not exist"), + ("'circle_sides':2", "at least 3"), + ("'id':7", "read-only"), + ("'clip_boundary_handle':5", "read-only"), + ], + is_kind: |entity| matches!(entity, EntityType::Viewport(_)), + digest: |entity| match entity { + EntityType::Viewport(v) => format!("at{:.1},{:.1} {:.1}x{:.1} vh{:.1} tw{:.2} frozen{}", v.center.x, v.center.y, + v.width, v.height, v.view_height, v.twist_angle, v.frozen_layers.len()), + _ => "wrong kind".into(), + }, + reedit: |entity| if let EntityType::Viewport(v) = entity { v.width = 95.0; }, + expect_created: "at100.0,100.0 80.0x60.0 vh50.0 tw0.00 frozen0", + expect_edited: "at120.0,110.0 90.0x60.0 vh75.0 tw0.25 frozen1", + expect_reedited: "at120.0,110.0 95.0x60.0 vh75.0 tw0.25 frozen1", + expect_edited_dwg: "", + expect_reedited_dwg: "", + expect_edited_dxf: "", + expect_reedited_dxf: "", + }); + } + + #[test] + fn staged_python_view_border_lifecycle_over_real_ipc() { + run_mesh_lifecycle(&MeshCase { + create: concat!( + "doc.create_entity('ViewBorder', min=[0.0, 0.0], max=[40.0, 30.0], center=[20.0, 15.0], scale=1.0,\n", + " active_viewport=VIEWPORTHANDLE, scale_handle=SCALEHANDLE)\n"), + edit: concat!( + "vb = doc.entities[HANDLE]\n", + "with doc.transaction('Edit view border'):\n", + " vb.max = [60.0, 45.0]\n", + " vb.center = [30.0, 22.5]\n", + " vb.scale = 2.0\n", + " vb.rotation_angle = 0.3\n", + "doc.selection = [vb]\n"), + rejects: &[ + ("'max':[-1.0, 5.0]", "below max"), + ("'scale':0.0", "greater than zero"), + ("'rotation_angle':float('nan')", "finite"), + ("'center':[float('inf'), 0.0]", "finite"), + ("'active_viewport':999999", "does not exist"), + ("'scale_handle':999999", "does not exist"), + ("'version':3", "read-only"), + ], + is_kind: |entity| matches!(entity, EntityType::ViewBorder(_)), + digest: |entity| match entity { + EntityType::ViewBorder(v) => format!("max{:.1},{:.1} c{:.1},{:.1} s{:.1} r{:.1}", v.max[0], v.max[1], + v.center[0], v.center[1], v.scale, v.rotation_angle), + _ => "wrong kind".into(), + }, + reedit: |entity| if let EntityType::ViewBorder(v) = entity { v.scale = 3.0; }, + expect_created: "max40.0,30.0 c20.0,15.0 s1.0 r0.0", + expect_edited: "max60.0,45.0 c30.0,22.5 s2.0 r0.3", + expect_reedited: "max60.0,45.0 c30.0,22.5 s3.0 r0.3", + // BLOCKER: a DXF DRAWINGVIEW reopens as a different (opaque) entity + // kind, not as a ViewBorder; only DWG restores the typed record. + expect_edited_dwg: "", + expect_reedited_dwg: "", + expect_edited_dxf: "wrong kind", + expect_reedited_dxf: "wrong kind", + }); + } + + #[test] + fn staged_python_light_lifecycle_over_real_ipc() { + run_mesh_lifecycle(&MeshCase { + create: concat!( + "doc.create_entity('Light', name='Key', light_type=3, position=P(10.0, 10.0, 10.0),\n", + " target=P(0.0, 0.0, 0.0), intensity=1.5, hotspot_angle=0.4, falloff_angle=0.8,\n", + " light_color={'kind': 'Rgb', 'value': {'r': 255, 'g': 240, 'b': 200}})\n"), + edit: concat!( + "lt = doc.entities[HANDLE]\n", + "with doc.transaction('Edit light'):\n", + " lt.intensity = 3.0\n", + " lt.target = (5.0, 5.0, 0.0)\n", + " lt.position = (12.0, 8.0, 10.0)\n", + " lt.cast_shadows = True\n", + "doc.selection = [lt]\n"), + rejects: &[ + ("'light_type':9", "must be 1 (distant)"), + ("'intensity':-1.0", "non-negative"), + ("'target':{'x':12.0,'y':8.0,'z':10.0}", "different from its position"), + ("'hotspot_angle':2.0", "must not exceed falloff_angle"), + ("'position':{'x':float('nan'),'y':0.0,'z':0.0}", "finite"), + ("'name':''", "empty"), + ("'class_version':2", "read-only"), + ("'light_color':{'kind':'Index','value':300}", "Color"), + ], + is_kind: |entity| matches!(entity, EntityType::Light(_)), + digest: |entity| match entity { + EntityType::Light(l) => format!("{} t{} i{:.1} pos{:.0},{:.0},{:.0} tgt{:.0},{:.0} sh{} {:?}", l.name, l.light_type, + l.intensity, l.position.x, l.position.y, l.position.z, l.target.x, l.target.y, l.cast_shadows, l.light_color), + _ => "wrong kind".into(), + }, + reedit: |entity| if let EntityType::Light(l) = entity { l.intensity = 4.0; }, + expect_created: "Key t3 i1.5 pos10,10,10 tgt0,0 shfalse Rgb { r: 255, g: 240, b: 200 }", + expect_edited: "Key t3 i3.0 pos12,8,10 tgt5,5 shtrue Rgb { r: 255, g: 240, b: 200 }", + expect_reedited: "Key t3 i4.0 pos12,8,10 tgt5,5 shtrue Rgb { r: 255, g: 240, b: 200 }", + // BLOCKER: DXF does not restore `cast_shadows` (it reopens false), + // like the other boolean groups the reader parses as integers. + expect_edited_dwg: "", + expect_reedited_dwg: "", + expect_edited_dxf: "Key t3 i3.0 pos12,8,10 tgt5,5 shfalse Rgb { r: 255, g: 240, b: 200 }", + expect_reedited_dxf: "Key t3 i4.0 pos12,8,10 tgt5,5 shfalse Rgb { r: 255, g: 240, b: 200 }", + }); + } + + #[test] + fn audit_python_point_lifecycle_over_real_ipc() { + run_mesh_lifecycle(&MeshCase { + create: "doc.create_entity('Point', location=P(1.0, 2.0, 3.0))", + edit: concat!("e = doc.entities[HANDLE]\n", "with doc.transaction('Edit'):\n", " e.location = (4.0, 5.0, 6.0)\n", " e.thickness = 2.0\n", "doc.selection = [e]\n"), + rejects: &[ + ("'location':{'x':float('nan'),'y':0.0,'z':0.0}", "finite"), + ("'thickness':float('inf')", "finite"), + ("'normal':{'x':0.0,'y':0.0,'z':0.0}", "nonzero"), + ], + is_kind: |entity| matches!(entity, EntityType::Point(_)), + digest: |entity| match entity { + EntityType::Point(v) => format!("{:.1},{:.1},{:.1} th{:.1}", v.location.x, v.location.y, v.location.z, v.thickness), + _ => "wrong kind".into(), + }, + reedit: |entity| if let EntityType::Point(v) = entity { v.location.z = 9.0; }, + expect_created: "1.0,2.0,3.0 th0.0", + expect_edited: "4.0,5.0,6.0 th2.0", + expect_reedited: "4.0,5.0,9.0 th2.0", + expect_edited_dwg: "", + expect_reedited_dwg: "", + expect_edited_dxf: "", + expect_reedited_dxf: "", + }); + } + + #[test] + fn audit_python_line_lifecycle_over_real_ipc() { + run_mesh_lifecycle(&MeshCase { + create: "doc.create_entity('Line', start=P(0.0, 0.0, 0.0), end=P(10.0, 0.0, 0.0))", + edit: concat!("e = doc.entities[HANDLE]\n", "with doc.transaction('Edit'):\n", " e.end = (10.0, 5.0, 0.0)\n", " e.thickness = 1.5\n", "doc.selection = [e]\n"), + rejects: &[ + ("'start':{'x':float('nan'),'y':0.0,'z':0.0}", "finite"), + ("'thickness':float('inf')", "finite"), + ("'normal':{'x':0.0,'y':0.0,'z':0.0}", "nonzero"), + ], + is_kind: |entity| matches!(entity, EntityType::Line(_)), + digest: |entity| match entity { + EntityType::Line(v) => format!("{:.1},{:.1} th{:.1}", v.end.x, v.end.y, v.thickness), + _ => "wrong kind".into(), + }, + reedit: |entity| if let EntityType::Line(v) = entity { v.start.x = 1.0; }, + expect_created: "10.0,0.0 th0.0", + expect_edited: "10.0,5.0 th1.5", + expect_reedited: "10.0,5.0 th1.5", + expect_edited_dwg: "", + expect_reedited_dwg: "", + expect_edited_dxf: "", + expect_reedited_dxf: "", + }); + } + + #[test] + fn audit_python_circle_lifecycle_over_real_ipc() { + run_mesh_lifecycle(&MeshCase { + create: "doc.create_entity('Circle', center=P(0.0, 0.0, 0.0), radius=5.0)", + edit: concat!("e = doc.entities[HANDLE]\n", "with doc.transaction('Edit'):\n", " e.radius = 7.0\n", " e.center = (1.0, 1.0, 0.0)\n", "doc.selection = [e]\n"), + rejects: &[ + ("'radius':0.0", "radius"), + ("'radius':-3.0", "radius"), + ("'center':{'x':float('nan'),'y':0.0,'z':0.0}", "finite"), + ], + is_kind: |entity| matches!(entity, EntityType::Circle(_)), + digest: |entity| match entity { + EntityType::Circle(v) => format!("{:.1},{:.1} r{:.1}", v.center.x, v.center.y, v.radius), + _ => "wrong kind".into(), + }, + reedit: |entity| if let EntityType::Circle(v) = entity { v.radius = 8.0; }, + expect_created: "0.0,0.0 r5.0", + expect_edited: "1.0,1.0 r7.0", + expect_reedited: "1.0,1.0 r8.0", + expect_edited_dwg: "", + expect_reedited_dwg: "", + expect_edited_dxf: "", + expect_reedited_dxf: "", + }); + } + + #[test] + fn audit_python_arc_lifecycle_over_real_ipc() { + run_mesh_lifecycle(&MeshCase { + create: "doc.create_entity('Arc', center=P(0.0, 0.0, 0.0), radius=5.0, start_angle=0.0, end_angle=1.5)", + edit: concat!("e = doc.entities[HANDLE]\n", "with doc.transaction('Edit'):\n", " e.end_angle = 2.0\n", " e.radius = 6.0\n", "doc.selection = [e]\n"), + rejects: &[ + ("'radius':0.0", "radius"), + ("'end_angle':float('nan')", "finite"), + ("'center':{'x':float('nan'),'y':0.0,'z':0.0}", "finite"), + ], + is_kind: |entity| matches!(entity, EntityType::Arc(_)), + digest: |entity| match entity { + EntityType::Arc(v) => format!("r{:.1} {:.1}..{:.1}", v.radius, v.start_angle, v.end_angle), + _ => "wrong kind".into(), + }, + reedit: |entity| if let EntityType::Arc(v) = entity { v.start_angle = 0.5; }, + expect_created: "r5.0 0.0..1.5", + expect_edited: "r6.0 0.0..2.0", + expect_reedited: "r6.0 0.5..2.0", + expect_edited_dwg: "", + expect_reedited_dwg: "", + expect_edited_dxf: "", + expect_reedited_dxf: "", + }); + } + + #[test] + fn audit_python_ellipse_lifecycle_over_real_ipc() { + run_mesh_lifecycle(&MeshCase { + create: "doc.create_entity('Ellipse', center=P(0.0, 0.0, 0.0), major_axis=P(10.0, 0.0, 0.0), minor_axis_ratio=0.5)", + edit: concat!("e = doc.entities[HANDLE]\n", "with doc.transaction('Edit'):\n", " e.minor_axis_ratio = 0.25\n", " e.end_parameter = 3.0\n", "doc.selection = [e]\n"), + rejects: &[ + ("'minor_axis_ratio':0.0", "ratio"), + ("'minor_axis_ratio':2.0", "ratio"), + ("'major_axis':{'x':0.0,'y':0.0,'z':0.0}", "axis"), + ], + is_kind: |entity| matches!(entity, EntityType::Ellipse(_)), + digest: |entity| match entity { + EntityType::Ellipse(v) => format!("q{:.2} ..{:.1}", v.minor_axis_ratio, v.end_parameter), + _ => "wrong kind".into(), + }, + reedit: |entity| if let EntityType::Ellipse(v) = entity { v.minor_axis_ratio = 0.75; }, + expect_created: "q0.50 ..6.3", + expect_edited: "q0.25 ..3.0", + expect_reedited: "q0.75 ..3.0", + expect_edited_dwg: "", + expect_reedited_dwg: "", + expect_edited_dxf: "", + expect_reedited_dxf: "", + }); + } + + #[test] + fn audit_python_ray_lifecycle_over_real_ipc() { + run_mesh_lifecycle(&MeshCase { + create: "doc.create_entity('Ray', base_point=P(0.0, 0.0, 0.0), direction=P(1.0, 0.0, 0.0))", + edit: concat!("e = doc.entities[HANDLE]\n", "with doc.transaction('Edit'):\n", " e.base_point = (2.0, 2.0, 0.0)\n", " e.direction = (0.0, 1.0, 0.0)\n", "doc.selection = [e]\n"), + rejects: &[ + ("'direction':{'x':2.0,'y':0.0,'z':0.0}", "unit"), + ("'base_point':{'x':float('nan'),'y':0.0,'z':0.0}", "finite"), + ], + is_kind: |entity| matches!(entity, EntityType::Ray(_)), + digest: |entity| match entity { + EntityType::Ray(v) => format!("{:.1},{:.1} d{:.1},{:.1}", v.base_point.x, v.base_point.y, v.direction.x, v.direction.y), + _ => "wrong kind".into(), + }, + reedit: |entity| if let EntityType::Ray(v) = entity { v.base_point.x = 3.0; }, + expect_created: "0.0,0.0 d1.0,0.0", + expect_edited: "2.0,2.0 d0.0,1.0", + expect_reedited: "3.0,2.0 d0.0,1.0", + expect_edited_dwg: "", + expect_reedited_dwg: "", + expect_edited_dxf: "", + expect_reedited_dxf: "", + }); + } + + #[test] + fn audit_python_xline_lifecycle_over_real_ipc() { + run_mesh_lifecycle(&MeshCase { + create: "doc.create_entity('XLine', base_point=P(0.0, 0.0, 0.0), direction=P(1.0, 0.0, 0.0))", + edit: concat!("e = doc.entities[HANDLE]\n", "with doc.transaction('Edit'):\n", " e.base_point = (2.0, 2.0, 0.0)\n", " e.direction = (0.0, 1.0, 0.0)\n", "doc.selection = [e]\n"), + rejects: &[ + ("'direction':{'x':2.0,'y':0.0,'z':0.0}", "unit"), + ("'base_point':{'x':float('nan'),'y':0.0,'z':0.0}", "finite"), + ], + is_kind: |entity| matches!(entity, EntityType::XLine(_)), + digest: |entity| match entity { + EntityType::XLine(v) => format!("{:.1},{:.1} d{:.1},{:.1}", v.base_point.x, v.base_point.y, v.direction.x, v.direction.y), + _ => "wrong kind".into(), + }, + reedit: |entity| if let EntityType::XLine(v) = entity { v.base_point.x = 3.0; }, + expect_created: "0.0,0.0 d1.0,0.0", + expect_edited: "2.0,2.0 d0.0,1.0", + expect_reedited: "3.0,2.0 d0.0,1.0", + expect_edited_dwg: "", + expect_reedited_dwg: "", + expect_edited_dxf: "", + expect_reedited_dxf: "", + }); + } + + #[test] + fn audit_python_solid_lifecycle_over_real_ipc() { + run_mesh_lifecycle(&MeshCase { + create: "doc.create_entity('Solid', first_corner=P(0.0, 0.0, 0.0), second_corner=P(4.0, 0.0, 0.0), third_corner=P(0.0, 4.0, 0.0), fourth_corner=P(4.0, 4.0, 0.0))", + edit: concat!("e = doc.entities[HANDLE]\n", "with doc.transaction('Edit'):\n", " e.fourth_corner = (5.0, 5.0, 0.0)\n", " e.thickness = 2.0\n", "doc.selection = [e]\n"), + rejects: &[ + ("'first_corner':{'x':float('nan'),'y':0.0,'z':0.0}", "finite"), + ("'thickness':float('inf')", "finite"), + ("'normal':{'x':0.0,'y':0.0,'z':0.0}", "nonzero"), + ], + is_kind: |entity| matches!(entity, EntityType::Solid(_)), + digest: |entity| match entity { + EntityType::Solid(v) => format!("{:.1},{:.1} th{:.1}", v.fourth_corner.x, v.fourth_corner.y, v.thickness), + _ => "wrong kind".into(), + }, + reedit: |entity| if let EntityType::Solid(v) = entity { v.first_corner.x = 1.0; }, + expect_created: "4.0,4.0 th0.0", + expect_edited: "5.0,5.0 th2.0", + expect_reedited: "5.0,5.0 th2.0", + expect_edited_dwg: "", + expect_reedited_dwg: "", + expect_edited_dxf: "", + expect_reedited_dxf: "", + }); + } + + #[test] + fn audit_python_face3d_lifecycle_over_real_ipc() { + run_mesh_lifecycle(&MeshCase { + create: "doc.create_entity('Face3D', first_corner=P(0.0, 0.0, 0.0), second_corner=P(4.0, 0.0, 0.0), third_corner=P(4.0, 4.0, 1.0), fourth_corner=P(0.0, 4.0, 1.0))", + edit: concat!("e = doc.entities[HANDLE]\n", "with doc.transaction('Edit'):\n", " e.third_corner = (4.0, 4.0, 2.0)\n", " e.invisible_edges = 3\n", "doc.selection = [e]\n"), + rejects: &[ + ("'first_corner':{'x':float('nan'),'y':0.0,'z':0.0}", "finite"), + ("'invisible_edges':64", "bits"), + ], + is_kind: |entity| matches!(entity, EntityType::Face3D(_)), + digest: |entity| match entity { + EntityType::Face3D(v) => format!("z{:.1} inv{}", v.third_corner.z, v.invisible_edges.bits()), + _ => "wrong kind".into(), + }, + reedit: |entity| if let EntityType::Face3D(v) = entity { v.first_corner.x = 1.0; }, + expect_created: "z1.0 inv0", + expect_edited: "z2.0 inv3", + expect_reedited: "z2.0 inv3", + expect_edited_dwg: "", + expect_reedited_dwg: "", + expect_edited_dxf: "", + expect_reedited_dxf: "", + }); + } + + #[test] + fn audit_python_text_lifecycle_over_real_ipc() { + run_mesh_lifecycle(&MeshCase { + create: "doc.create_entity('Text', text='Hello', insertion=P(1.0, 2.0, 0.0), height=2.5)", + edit: concat!("e = doc.entities[HANDLE]\n", "with doc.transaction('Edit'):\n", " e.text = 'World'\n", " e.rotation = 0.5\n", " e.height = 3.0\n", "doc.selection = [e]\n"), + rejects: &[ + ("'height':0.0", "height"), + ("'height':-1.0", "height"), + ("'insertion':{'x':float('nan'),'y':0.0,'z':0.0}", "finite"), + ], + is_kind: |entity| matches!(entity, EntityType::Text(_)), + digest: |entity| match entity { + EntityType::Text(v) => format!("{} h{:.1} r{:.1}", v.value, v.height, v.rotation), + _ => "wrong kind".into(), + }, + reedit: |entity| if let EntityType::Text(v) = entity { v.height = 4.0; }, + expect_created: "Hello h2.5 r0.0", + expect_edited: "World h3.0 r0.5", + expect_reedited: "World h4.0 r0.5", + expect_edited_dwg: "", + expect_reedited_dwg: "", + expect_edited_dxf: "", + expect_reedited_dxf: "", + }); + } + + #[test] + fn audit_python_mtext_lifecycle_over_real_ipc() { + run_mesh_lifecycle(&MeshCase { + create: "doc.create_entity('MText', text='Line one', insertion=P(1.0, 2.0, 0.0), height=2.5, rectangle_width=30.0)", + edit: concat!("e = doc.entities[HANDLE]\n", "with doc.transaction('Edit'):\n", " e.text = 'Line two'\n", " e.rectangle_width = 40.0\n", " e.rotation = 0.5\n", "doc.selection = [e]\n"), + rejects: &[ + ("'height':0.0", "height"), + ("'rectangle_width':-1.0", "width"), + ("'insertion':{'x':float('nan'),'y':0.0,'z':0.0}", "finite"), + ], + is_kind: |entity| matches!(entity, EntityType::MText(_)), + digest: |entity| match entity { + EntityType::MText(v) => format!("{} w{:.1} r{:.1}", v.value, v.rectangle_width, v.rotation), + _ => "wrong kind".into(), + }, + reedit: |entity| if let EntityType::MText(v) = entity { v.rectangle_width = 50.0; }, + expect_created: "Line one w30.0 r0.0", + expect_edited: "Line two w40.0 r0.5", + expect_reedited: "Line two w50.0 r0.5", + expect_edited_dwg: "", + expect_reedited_dwg: "", + expect_edited_dxf: "", + expect_reedited_dxf: "", + }); + } + + #[test] + fn audit_python_lwpolyline_lifecycle_over_real_ipc() { + run_mesh_lifecycle(&MeshCase { + create: concat!("doc.create_entity('LwPolyline', vertices=[{'location': {'x': 0.0, 'y': 0.0}}, {'location': {'x': 10.0, 'y': 0.0}}, {'location': {'x': 10.0, 'y': 5.0}}])\n"), + edit: concat!("e = doc.entities[HANDLE]\n", "with doc.transaction('Edit'):\n", " e.vertices = [{'location': {'x': 0.0, 'y': 0.0}}, {'location': {'x': 10.0, 'y': 0.0}}, {'location': {'x': 10.0, 'y': 8.0}, 'bulge': 0.5}, {'location': {'x': 0.0, 'y': 8.0}}]\n", " e.is_closed = True\n", " e.constant_width = 0.5\n", "doc.selection = [e]\n"), + rejects: &[ + ("'vertices':[{'location':{'x':0.0,'y':0.0}}]", "at least 2"), + ("'constant_width':-1.0", "non-negative"), + ("'vertices':[{'location':{'x':float('nan'),'y':0.0}},{'location':{'x':1.0,'y':0.0}}]", "finite"), + ("'normal':{'x':0.0,'y':0.0,'z':0.0}", "nonzero"), + ], + is_kind: |entity| matches!(entity, EntityType::LwPolyline(_)), + digest: |entity| match entity { + EntityType::LwPolyline(v) => format!("n{} closed{} w{:.1} bulge{:.1}", v.vertices.len(), v.is_closed, v.constant_width, v.vertices.get(2).map_or(0.0, |x| x.bulge)), + _ => "wrong kind".into(), + }, + reedit: |entity| if let EntityType::LwPolyline(v) = entity { v.constant_width = 1.0; }, + expect_created: "n3 closedfalse w0.0 bulge0.0", + expect_edited: "n4 closedtrue w0.5 bulge0.5", + expect_reedited: "n4 closedtrue w1.0 bulge0.5", + expect_edited_dwg: "", + expect_reedited_dwg: "", + expect_edited_dxf: "", + expect_reedited_dxf: "", + }); + } + + #[test] + fn audit_python_polyline2d_lifecycle_over_real_ipc() { + run_mesh_lifecycle(&MeshCase { + create: concat!("doc.create_entity('Polyline2D', vertices=[{'location': {'x': 0.0, 'y': 0.0, 'z': 0.0}}, {'location': {'x': 10.0, 'y': 0.0, 'z': 0.0}}, {'location': {'x': 10.0, 'y': 5.0, 'z': 0.0}}])\n"), + edit: concat!("e = doc.entities[HANDLE]\n", "with doc.transaction('Edit'):\n", " e.vertices = [{'location': {'x': 0.0, 'y': 0.0, 'z': 0.0}}, {'location': {'x': 10.0, 'y': 0.0, 'z': 0.0}}, {'location': {'x': 10.0, 'y': 8.0, 'z': 0.0}}, {'location': {'x': 0.0, 'y': 8.0, 'z': 0.0}}]\n", " e.closed = True\n", " e.thickness = 1.0\n", "doc.selection = [e]\n"), + rejects: &[ + ("'vertices':[{'location':{'x':0.0,'y':0.0,'z':0.0}}]", "at least 2"), + ("'thickness':float('inf')", "finite"), + ("'vertices':[{'location':{'x':float('nan'),'y':0.0,'z':0.0}},{'location':{'x':1.0,'y':0.0,'z':0.0}}]", "finite"), + ("'normal':{'x':0.0,'y':0.0,'z':0.0}", "nonzero"), + ], + is_kind: |entity| matches!(entity, EntityType::Polyline2D(_)), + digest: |entity| match entity { + EntityType::Polyline2D(v) => format!("n{} closed{} th{:.1}", v.vertices.len(), v.flags.is_closed(), v.thickness), + other => format!("wrong kind {}", format!("{other:?}").split('(').next().unwrap()), + }, + reedit: |entity| if let EntityType::Polyline2D(v) = entity { v.thickness = 2.0; }, + expect_created: "n3 closedfalse th0.0", + expect_edited: "n4 closedtrue th1.0", + expect_reedited: "n4 closedtrue th2.0", + expect_edited_dwg: "", + expect_reedited_dwg: "", + expect_edited_dxf: "wrong kind LwPolyline", + expect_reedited_dxf: "wrong kind LwPolyline", + }); + } + + #[test] + fn audit_python_polyline_lifecycle_over_real_ipc() { + run_mesh_lifecycle(&MeshCase { + create: "# MAKEPOLYLINE +", + edit: concat!("e = doc.entities[HANDLE]\n", "with doc.transaction('Edit'):\n", " e.vertices = [{'location': {'x': 0.0, 'y': 0.0, 'z': 0.0}}, {'location': {'x': 10.0, 'y': 0.0, 'z': 2.0}}, {'location': {'x': 10.0, 'y': 8.0, 'z': 6.0}}, {'location': {'x': 0.0, 'y': 8.0, 'z': 6.0}}]\n", " e.closed = True\n", "doc.selection = [e]\n"), + rejects: &[ + ("'vertices':[{'location':{'x':0.0,'y':0.0,'z':0.0}}]", "at least 2"), + ("'vertices':[{'location':{'x':float('nan'),'y':0.0,'z':0.0}},{'location':{'x':1.0,'y':0.0,'z':0.0}}]", "finite"), + ], + is_kind: |entity| matches!(entity, EntityType::Polyline(_)), + digest: |entity| match entity { + EntityType::Polyline(v) => format!("n{} closed{} lastz{:.1}", v.vertices.len(), v.flags.is_closed(), v.vertices.last().map_or(0.0, |x| x.location.z)), + other => format!("wrong kind {}", format!("{other:?}").split('(').next().unwrap()), + }, + reedit: |entity| if let EntityType::Polyline(v) = entity { v.vertices[0].location.z = 1.0; }, + expect_created: "n3 closedfalse lastz4.0", + expect_edited: "n4 closedtrue lastz6.0", + expect_reedited: "n4 closedtrue lastz6.0", + expect_edited_dwg: "wrong kind Polyline3D", + expect_reedited_dwg: "wrong kind Polyline3D", + expect_edited_dxf: "wrong kind Polyline3D", + expect_reedited_dxf: "wrong kind Polyline3D", + }); + } + + #[test] + fn audit_python_polyline3d_lifecycle_over_real_ipc() { + run_mesh_lifecycle(&MeshCase { + create: concat!("doc.create_entity('Polyline3D', vertices=[{'position': {'x': 0.0, 'y': 0.0, 'z': 0.0}}, {'position': {'x': 10.0, 'y': 0.0, 'z': 2.0}}, {'position': {'x': 10.0, 'y': 5.0, 'z': 4.0}}])\n"), + edit: concat!("e = doc.entities[HANDLE]\n", "with doc.transaction('Edit'):\n", " e.vertices = [{'position': {'x': 0.0, 'y': 0.0, 'z': 0.0}}, {'position': {'x': 10.0, 'y': 0.0, 'z': 2.0}}, {'position': {'x': 10.0, 'y': 8.0, 'z': 6.0}}, {'position': {'x': 0.0, 'y': 8.0, 'z': 6.0}}]\n", " e.elevation = 1.5\n", "doc.selection = [e]\n"), + rejects: &[ + ("'vertices':[{'position':{'x':0.0,'y':0.0,'z':0.0}}]", "at least 2"), + ("'elevation':float('inf')", "finite"), + ("'vertices':[{'position':{'x':float('nan'),'y':0.0,'z':0.0}},{'position':{'x':1.0,'y':0.0,'z':0.0}}]", "finite"), + ("'normal':{'x':0.0,'y':0.0,'z':0.0}", "nonzero"), + ], + is_kind: |entity| matches!(entity, EntityType::Polyline3D(_)), + digest: |entity| match entity { + EntityType::Polyline3D(v) => format!("n{} el{:.1} lastz{:.1}", v.vertices.len(), v.elevation, v.vertices.last().map_or(0.0, |x| x.position.z)), + _ => "wrong kind".into(), + }, + reedit: |entity| if let EntityType::Polyline3D(v) = entity { v.elevation = 2.5; }, + expect_created: "n3 el0.0 lastz4.0", + expect_edited: "n4 el1.5 lastz6.0", + expect_reedited: "n4 el2.5 lastz6.0", + expect_edited_dwg: "n4 el0.0 lastz6.0", + expect_reedited_dwg: "n4 el0.0 lastz6.0", + expect_edited_dxf: "", + expect_reedited_dxf: "", + }); + } + + #[test] + fn audit_python_spline_lifecycle_over_real_ipc() { + run_mesh_lifecycle(&MeshCase { + create: concat!("doc.create_entity('Spline', degree=2, knots=[0.0, 0.0, 0.0, 1.0, 1.0, 1.0], control_points=[{'x': 0.0, 'y': 0.0, 'z': 0.0}, {'x': 5.0, 'y': 5.0, 'z': 0.0}, {'x': 10.0, 'y': 0.0, 'z': 0.0}])\n"), + edit: concat!("e = doc.entities[HANDLE]\n", "with doc.transaction('Edit'):\n", " e.control_points = [{'x': 0.0, 'y': 0.0, 'z': 0.0}, {'x': 5.0, 'y': 8.0, 'z': 0.0}, {'x': 10.0, 'y': 0.0, 'z': 0.0}]\n", " e.weights = [1.0, 2.0, 1.0]\n", "doc.selection = [e]\n"), + rejects: &[ + ("'degree':0", "at least 1"), + ("'knots':[0.0,1.0]", "must hold"), + ("'weights':[1.0,0.0,1.0]", "greater than zero"), + ("'weights':[1.0]", "match"), + ("'control_points':[{'x':float('nan'),'y':0.0,'z':0.0},{'x':1.0,'y':0.0,'z':0.0},{'x':2.0,'y':0.0,'z':0.0}]", "finite"), + ("'knots':[0.0,0.0,1.0,0.5,1.0,1.0]", "nondecreasing"), + ], + is_kind: |entity| matches!(entity, EntityType::Spline(_)), + digest: |entity| match entity { + EntityType::Spline(v) => format!("deg{} cp{} y{:.1} w{:?}", v.degree, v.control_points.len(), v.control_points[1].y, v.weights), + _ => "wrong kind".into(), + }, + reedit: |entity| if let EntityType::Spline(v) = entity { v.control_points[1].y = 9.0; }, + expect_created: "deg2 cp3 y5.0 w[]", + expect_edited: "deg2 cp3 y8.0 w[1.0, 2.0, 1.0]", + expect_reedited: "deg2 cp3 y9.0 w[1.0, 2.0, 1.0]", + expect_edited_dwg: "", + expect_reedited_dwg: "", + expect_edited_dxf: "", + expect_reedited_dxf: "", + }); + } + + #[test] + fn audit_python_insert_lifecycle_over_real_ipc() { + run_mesh_lifecycle(&MeshCase { + create: concat!("# MAKEBLOCK\n", "doc.create_entity('Insert', block_name='AUDITBLK', insert_point=P(1.0, 2.0, 0.0))\n"), + edit: concat!( + "e = doc.entities[HANDLE]\n", + "with doc.transaction('Edit'):\n", + " e.insert_point = (4.0, 5.0, 0.0)\n", + " e.x_scale = 2.0\n", + " e.y_scale = 3.0\n", + " e.rotation = 0.5\n", + " e.column_count = 2\n", + " e.column_spacing = 10.0\n", + "doc.selection = [e]\n"), + rejects: &[ + ("'x_scale':0.0", "nonzero"), + ("'rotation':float('nan')", "finite"), + ("'column_count':0", "greater than zero"), + ("'insert_point':{'x':float('nan'),'y':0.0,'z':0.0}", "finite"), + ("'block_name':'OTHER'", "does not exist"), + ("'normal':{'x':0.0,'y':0.0,'z':0.0}", "nonzero"), + ], + is_kind: |entity| matches!(entity, EntityType::Insert(_)), + digest: |entity| match entity { + EntityType::Insert(v) => format!("{} at{:.1},{:.1} s{:.1},{:.1} r{:.1} cols{}x{:.1}", v.block_name, v.insert_point.x, + v.insert_point.y, v.x_scale(), v.y_scale(), v.rotation, v.column_count, v.column_spacing), + _ => "wrong kind".into(), + }, + reedit: |entity| if let EntityType::Insert(v) = entity { v.rotation = 1.0; }, + expect_created: "AUDITBLK at1.0,2.0 s1.0,1.0 r0.0 cols1x0.0", + expect_edited: "AUDITBLK at4.0,5.0 s2.0,3.0 r0.5 cols2x10.0", + expect_reedited: "AUDITBLK at4.0,5.0 s2.0,3.0 r1.0 cols2x10.0", + expect_edited_dxf: "", + expect_reedited_dxf: "", + expect_edited_dwg: "", + expect_reedited_dwg: "", + }); + } + + #[test] + fn audit_python_attribute_definition_lifecycle_over_real_ipc() { + run_mesh_lifecycle(&MeshCase { + create: concat!( + "# MAKEBLOCK\n", + "doc.create_entity('AttributeDefinition', owner_handle=BLOCKRECORD, tag='PART_NO', prompt='Part number',\n", + " default_value='PN-001', insertion_point=P(1.0, 2.0, 0.0), height=2.5, width_factor=1.25, oblique_angle=0.1,\n", + " rotation=0.25, horizontal_alignment='Center', vertical_alignment='Top', alignment_point=P(3.0, 4.0, 0.0),\n", + " flags={'invisible': True, 'constant': False, 'verify': True, 'preset': False, 'locked_position': False, 'annotative': False},\n", + " field_length=12, text_generation_flags=2, lock_position=True)\n"), + edit: concat!( + "e = doc.entities[HANDLE]\n", + "with doc.transaction('Edit'):\n", + " e.insertion_point = (4.0, 5.0, 0.0)\n", + " e.default_value = 'PN-002'\n", + " e.prompt = 'Serial'\n", + " e.height = 3.0\n", + " e.width_factor = 0.8\n", + " e.rotation = 0.5\n", + " e.oblique_angle = 0.2\n", + " e.horizontal_alignment = 'Right'\n", + " e.vertical_alignment = 'Middle'\n", + " e.flags = {'invisible': False, 'constant': True, 'verify': False, 'preset': True, 'locked_position': False, 'annotative': False}\n", + " e.field_length = 20\n", + "doc.selection = [e]\n"), + rejects: &[ + ("'tag':'BAD TAG'", "whitespace"), + ("'height':0.0", "greater than zero"), + ("'width_factor':0.0", "width_factor"), + ("'text_style':'Missing'", "does not exist"), + ("'owner_handle':999999", "read-only"), + ("'normal':{'x':0.0,'y':0.0,'z':0.0}", "nonzero"), + ("'rotation':float('nan')", "finite"), + ("'horizontal_alignment':'Nowhere'", "unsupported"), + ], + is_kind: |entity| matches!(entity, EntityType::AttributeDefinition(_)), + digest: |entity| match entity { + EntityType::AttributeDefinition(v) => format!( + "{}|{}|{} ins{:.1},{:.1} al{:.1},{:.1} h{:.1} r{:.2} wf{:.2} ob{:.2} {:?}/{:?} f{}{}{}{} fl{} tg{} lock{}", + v.tag, v.prompt, v.default_value, v.insertion_point.x, v.insertion_point.y, + v.alignment_point.x, v.alignment_point.y, v.height, v.rotation, v.width_factor, v.oblique_angle, + v.horizontal_alignment, v.vertical_alignment, + u8::from(v.flags.invisible), u8::from(v.flags.constant), u8::from(v.flags.verify), u8::from(v.flags.preset), + v.field_length, v.text_generation_flags, u8::from(v.lock_position)), + _ => "wrong kind".into(), + }, + reedit: |entity| if let EntityType::AttributeDefinition(v) = entity { v.default_value = "PN-003".into(); }, + expect_created: "PART_NO|Part number|PN-001 ins1.0,2.0 al3.0,4.0 h2.5 r0.25 wf1.25 ob0.10 Center/Top f1010 fl12 tg2 lock1", + expect_edited: "PART_NO|Serial|PN-002 ins4.0,5.0 al3.0,4.0 h3.0 r0.50 wf0.80 ob0.20 Right/Middle f0101 fl20 tg2 lock1", + expect_reedited: "PART_NO|Serial|PN-003 ins4.0,5.0 al3.0,4.0 h3.0 r0.50 wf0.80 ob0.20 Right/Middle f0101 fl20 tg2 lock1", + // BLOCKER: acadrust's DXF ATTDEF reader handles only groups 1, 2, 3, + // 10, 40, 50, 280 and 101, so the alignment point and alignments, + // width factor, oblique angle, attribute flags, field length, + // generation flags, lock and text style (groups 7, 11, 41, 51, 70-74, + // 210, 280 lock) reopen as defaults. The writer emits all of them. + expect_edited_dxf: "PART_NO|Serial|PN-002 ins4.0,5.0 al0.0,0.0 h3.0 r0.50 wf1.00 ob0.00 Left/Baseline f0000 fl0 tg0 lock0", + expect_reedited_dxf: "PART_NO|Serial|PN-003 ins4.0,5.0 al0.0,0.0 h3.0 r0.50 wf1.00 ob0.00 Left/Baseline f0000 fl0 tg0 lock0", + expect_edited_dwg: "", + expect_reedited_dwg: "", + }); + } + + #[test] + fn audit_python_insert_creation_rules_over_real_ipc() { + let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { + return; + }; + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let mut records = std::collections::BTreeMap::new(); + for name in ["OUTER", "INNER"] { + let next = host.document().next_handle(); + let (record_handle, block_handle, end_handle) = (Handle::new(next), Handle::new(next + 1), Handle::new(next + 2)); + let mut record = acadrust::tables::BlockRecord::new(name); + record.handle = record_handle; + record.block_entity_handle = block_handle; + record.block_end_handle = end_handle; + host.document_mut().block_records.add(record).unwrap(); + let mut block = acadrust::entities::Block::new(name, acadrust::types::Vector3::ZERO); + block.common.handle = block_handle; + block.common.owner_handle = record_handle; + host.document_mut().add_entity(EntityType::Block(block)).unwrap(); + let mut end = acadrust::entities::BlockEnd::new(); + end.common.handle = end_handle; + end.common.owner_handle = record_handle; + host.document_mut().add_entity(EntityType::BlockEnd(end)).unwrap(); + records.insert(name, record_handle); + } + let process = ocs_plugin_api::process::PluginProcess::spawn( + std::path::Path::new(&plugin_path), &mut host, crate::plugin::v4_support::notification_handler(), + ).unwrap(); + let dispatch = |host: &mut HostSession<'_>, command: &str| { + assert!(process.dispatch(host, command, &mut |_| {}).expect("Python dispatch")); + }; + let last = |host: &HostSession<'_>| host.app.command_line.history.last().unwrap().text.clone(); + let count = |host: &HostSession<'_>| host.document().entities().filter(|e| matches!(e, EntityType::Insert(_))).count(); + let create = |name: &str, extra: &str| format!( + "PY_EVAL ocs.active_document.create_entity('Insert', block_name='{name}', insert_point={{'x':0.0,'y':0.0,'z':0.0}}{extra})"); + + // A valid insert of INNER into the drawing works. + dispatch(&mut host, &create("INNER", "")); + assert_eq!(count(&host), 1, "{}", last(&host)); + // Refused: unknown block, model space, bad scale, zero array count. + for (command, message) in [ + (create("MISSING", ""), "does not exist"), + (create("*Model_Space", ""), "model-space or paper-space"), + (create("INNER", ", x_scale=0.0"), "nonzero"), + (create("INNER", ", column_count=0"), "greater than zero"), + (create("", ""), "empty"), + ] { + dispatch(&mut host, &command); + assert_eq!(count(&host), 1, "{command}"); + assert!(last(&host).contains(message), "{command}: {}", last(&host)); + } + // A block cannot contain itself, directly or through another block. + let owner = |name: &str| format!(", owner_handle={}", records[name].value()); + dispatch(&mut host, &create("OUTER", &owner("OUTER"))); + assert_eq!(count(&host), 1); + assert!(last(&host).contains("contain itself"), "{}", last(&host)); + dispatch(&mut host, &create("INNER", &owner("OUTER"))); + assert_eq!(count(&host), 2, "{}", last(&host)); + dispatch(&mut host, &create("OUTER", &owner("INNER"))); + assert_eq!(count(&host), 2); + assert!(last(&host).contains("contain itself"), "{}", last(&host)); + // The legacy Polyline cannot be created and says what to use instead. + dispatch(&mut host, "PY_EVAL ocs.active_document.create_entity('Polyline', vertices=[])"); + assert!(last(&host).contains("Polyline2D or Polyline3D"), "{}", last(&host)); + } + + #[test] + fn audit_python_ole2frame_lifecycle_over_real_ipc() { + image::RgbaImage::from_pixel(4, 3, image::Rgba([10, 120, 200, 255])) + .save(std::env::temp_dir().join("ocs_ole_audit.png")) + .unwrap(); + run_mesh_lifecycle(&MeshCase { + create: "doc.embed_picture('TMPDIR/ocs_ole_audit.png', origin=(10, 10, 0), width=20)\n", + edit: concat!( + "e = doc.entities[HANDLE]\n", + "with doc.transaction('Edit'):\n", + " e.upper_left_corner = (12.0, 40.0, 0.0)\n", + " e.lower_right_corner = (42.0, 10.0, 0.0)\n", + " e.lock_aspect = 1\n", + "doc.selection = [e]\n"), + rejects: &[ + ("'upper_left_corner':{'x':42.0,'y':40.0,'z':0.0}", "nonzero width and height"), + ("'lock_aspect':2", "0 or 1"), + ("'lower_right_corner':{'x':float('nan'),'y':0.0,'z':0.0}", "finite"), + ("'version':3", "read-only"), + ("'storage':{}", "outside the editable schema"), + ], + is_kind: |entity| matches!(entity, EntityType::Ole2Frame(_)), + digest: |entity| match entity { + EntityType::Ole2Frame(v) => { + let picture = match acadrust::entities::extract_presentation(&v.encoded_payload()) { + Some(acadrust::entities::OlePresentation::Raster(bytes)) => bytes.len(), + _ => 0, + }; + format!("ul{:.1},{:.1} lr{:.1},{:.1} lock{} picture{}", v.upper_left_corner.x, v.upper_left_corner.y, + v.lower_right_corner.x, v.lower_right_corner.y, v.lock_aspect, picture) + } + _ => "wrong kind".into(), + }, + reedit: |entity| if let EntityType::Ole2Frame(v) = entity { v.lower_right_corner.x = 50.0; }, + expect_created: "ul10.0,25.0 lr30.0,10.0 lock0 picture119", + expect_edited: "ul12.0,40.0 lr42.0,10.0 lock1 picture119", + expect_reedited: "ul12.0,40.0 lr50.0,10.0 lock1 picture119", + expect_edited_dxf: "", + expect_reedited_dxf: "", + expect_edited_dwg: "", + expect_reedited_dwg: "", + }); + } + + /// Evidence for docs/cadkernel-body-path.md: primitive, transform and + /// boolean bodies become Solid3D/Body payloads that lift back losslessly + /// and survive DWG and DXF. Slow in a debug build (booleans take ~23 s). + #[test] + #[ignore = "kernel spike; slow in debug builds"] + fn spike_kernel_body_round_trip() { + use crate::scene::model::solid_model as sm; + use crate::scene::convert::solid3d_tess::{kernel_acis_body, kernel_body}; + let make = |body: &cadkernel::brep::Body| -> acadrust::entities::Solid3D { + let sat = crate::scene::convert::acis_export::solid_to_sat(body).expect("solid_to_sat"); + let mut solid = acadrust::entities::Solid3D::new(); + solid.wires = sm::edge_wires(body); + solid.set_sat_document(&sat); + solid + }; + let report = |label: &str, solid: &acadrust::entities::Solid3D| { + let body = kernel_body(solid); + eprintln!("SPIKE {label}: lift={} volume={:?} wires={} sat_bytes={} binary={}", + body.is_some(), body.as_ref().map(sm::volume), solid.wires.len(), + solid.acis_data.sat_data.len(), solid.acis_data.is_binary); + body + }; + let boxed = sm::box_solid([0.0, 0.0, 0.0], 10.0, 6.0, 4.0).unwrap(); + let mut doc = acadrust::CadDocument::new(); + let solid = make(&boxed); + report("created box", &solid); + let handle = doc.add_entity(EntityType::Solid3D(solid.clone())).unwrap(); + + // Round trip both formats and compare payloads. + let dwg = crate::io::load_bytes("s.dwg", acadrust::DwgWriter::write_to_vec(&doc).unwrap()).unwrap(); + let dxf = crate::io::load_bytes("s.dxf", acadrust::DxfWriter::new(&doc).write_to_vec().unwrap()).unwrap(); + for (label, reopened) in [("DWG", &dwg), ("DXF", &dxf)] { + let Some(EntityType::Solid3D(back)) = reopened.get_entity(handle) else { panic!("{label} lost the solid") }; + report(&format!("{label} reopen"), back); + eprintln!("SPIKE {label}: acis_data equal={} wires equal={} silhouettes {}", back.acis_data == solid.acis_data, + back.wires.len() == solid.wires.len(), back.silhouettes.len()); + } + + // Transforms: translate, rotate, mirror, matrix. + let moved = sm::by_matrix(&boxed, [1.,0.,0.,0., 0.,1.,0.,0., 0.,0.,1.,0., 5.,5.,5.,1.]).unwrap(); + let turned = sm::turned(&boxed, 2, std::f64::consts::FRAC_PI_4, [0.0, 0.0, 0.0]).unwrap(); + let mirrored = sm::mirrored(&boxed, 0, [0.0, 0.0, 0.0]).unwrap(); + for (label, body) in [("moved", &moved), ("turned", &turned), ("mirrored", &mirrored)] { + let solid = make(body); + let extent = sm::extent(body); + eprintln!("SPIKE {label}: volume={:.3} extent={:?} lifts_back={}", sm::volume(body), extent, kernel_body(&solid).is_some()); + } + + // Booleans and primitives. + let cyl = sm::cylinder_solid([0.0, 0.0, -3.0], 2.0, 10.0).unwrap(); + for (label, op) in [("union", sm::Bool::Union), ("subtract", sm::Bool::Subtract), ("intersect", sm::Bool::Intersect)] { + match sm::boolean_result(op, &boxed, &cyl) { + Ok(result) => { let solid = make(&result); eprintln!("SPIKE box {label} cylinder: volume={:.3} faces_wires={} lifts_back={}", sm::volume(&result), solid.wires.len(), kernel_body(&solid).is_some()); } + Err(snag) => eprintln!("SPIKE box {label} cylinder: refused {snag:?}"), + } + } + for (label, body) in [ + ("sphere", sm::sphere_solid([0.0; 3], 3.0)), ("torus", sm::torus_solid([0.0; 3], 5.0, 1.0)), + ("wedge", sm::wedge_solid([0.0; 3], 4.0, 3.0, 2.0)), ("pyramid", sm::pyramid_solid([0.0; 3], 3.0, 4.0, 5)), + ] { + match body { + Some(body) => { let solid = make(&body); eprintln!("SPIKE {label}: volume={:.3} lifts_back={}", sm::volume(&body), kernel_body(&solid).is_some()); } + None => eprintln!("SPIKE {label}: kernel returned None"), + } + } + + // The same payload inside a Body entity and a Region-like container. + let mut body_entity = acadrust::entities::Body::new(); + body_entity.set_sat_document(&crate::scene::convert::acis_export::solid_to_sat(&boxed).unwrap()); + let lifted = kernel_acis_body(&body_entity.acis_data); + eprintln!("SPIKE Body entity: lift={} volume={:?}", lifted.is_some(), lifted.as_ref().map(sm::volume)); + let bh = doc.add_entity(EntityType::Body(body_entity)).unwrap(); + let dwg = crate::io::load_bytes("b.dwg", acadrust::DwgWriter::write_to_vec(&doc).unwrap()).unwrap(); + let dxf = crate::io::load_bytes("b.dxf", acadrust::DxfWriter::new(&doc).write_to_vec().unwrap()).unwrap(); + for (label, reopened) in [("DWG", &dwg), ("DXF", &dxf)] { + match reopened.get_entity(bh) { + Some(EntityType::Body(back)) => eprintln!("SPIKE Body {label}: lift={}", kernel_acis_body(&back.acis_data).is_some()), + other => eprintln!("SPIKE Body {label}: came back as {:?}", other.map(|e| format!("{e:?}").chars().take(30).collect::())), + } + } + } + + /// Per-operation kernel timings for docs/cadkernel-body-path.md. + #[test] + #[ignore = "kernel spike; slow in debug builds"] + fn spike_kernel_timings() { + use crate::scene::model::solid_model as sm; + use std::time::Instant; + let time = |label: &str, f: &mut dyn FnMut() -> String| { + let start = Instant::now(); + let note = f(); + eprintln!("TIMING {label}: {:.2}s {note}", start.elapsed().as_secs_f64()); + }; + let boxed = sm::box_solid([0.0; 3], 10.0, 6.0, 4.0).unwrap(); + let cyl = sm::cylinder_solid([0.0, 0.0, -3.0], 2.0, 10.0).unwrap(); + time("box primitive", &mut || format!("{}", sm::box_solid([0.0; 3], 10.0, 6.0, 4.0).is_some())); + time("sphere primitive", &mut || format!("{}", sm::sphere_solid([0.0; 3], 3.0).is_some())); + time("solid_to_sat(box)", &mut || format!("{}", crate::scene::convert::acis_export::solid_to_sat(&boxed).is_some())); + time("edge_wires(box)", &mut || format!("{}", sm::edge_wires(&boxed).len())); + time("transform (matrix)", &mut || format!("{}", sm::by_matrix(&boxed, [1.,0.,0.,0., 0.,1.,0.,0., 0.,0.,1.,0., 5.,5.,5.,1.]).is_some())); + time("union", &mut || format!("{}", sm::boolean_result(sm::Bool::Union, &boxed, &cyl).is_ok())); + time("subtract", &mut || format!("{}", sm::boolean_result(sm::Bool::Subtract, &boxed, &cyl).is_ok())); + time("intersect", &mut || format!("{}", sm::boolean_result(sm::Bool::Intersect, &boxed, &cyl).is_ok())); + let result = sm::boolean_result(sm::Bool::Union, &boxed, &cyl).unwrap(); + time("solid_to_sat(union)", &mut || format!("{}", crate::scene::convert::acis_export::solid_to_sat(&result).is_some())); + time("edge_wires(union)", &mut || format!("{}", sm::edge_wires(&result).len())); + time("volume(union)", &mut || format!("{:.1}", sm::volume(&result))); + } + + #[test] + fn audit_python_solid3d_lifecycle_over_real_ipc() { + use crate::scene::convert::solid3d_tess::kernel_body; + use crate::scene::model::solid_model as sm; + let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { + return; + }; + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let process = ocs_plugin_api::process::PluginProcess::spawn( + std::path::Path::new(&plugin_path), &mut host, crate::plugin::v4_support::notification_handler(), + ).unwrap(); + let dispatch = |host: &mut HostSession<'_>, command: &str| { + assert!(process.dispatch(host, command, &mut |_| {}).expect("Python dispatch")); + }; + let last = |host: &HostSession<'_>| host.app.command_line.history.last().unwrap().text.clone(); + let solids = |host: &HostSession<'_>| -> Vec { + let mut list: Vec<_> = host.document().entities().filter_map(|e| match e { + EntityType::Solid3D(s) => Some(s.common.handle), _ => None }).collect(); + list.sort_by_key(|h| h.value()); + list + }; + let body_of = |host: &HostSession<'_>, handle: Handle| { + let Some(EntityType::Solid3D(solid)) = host.document().get_entity(handle) else { panic!("no solid {handle:?}") }; + kernel_body(solid).expect("payload lifts losslessly") + }; + let volume = |host: &HostSession<'_>, handle: Handle| sm::volume(&body_of(host, handle)); + let extent = |host: &HostSession<'_>, handle: Handle| sm::extent(&body_of(host, handle)).unwrap(); + let close = |a: f64, b: f64, tolerance: f64| (a - b).abs() <= tolerance * b.abs().max(1.0); + + // C: every primitive, checked against its analytic volume. + let script = std::env::temp_dir().join(format!("ocs_solid_create_{}.py", std::process::id())); + std::fs::write(&script, concat!( + "s = ocs.active_document.solids\n", + "s.box(center=(0, 0, 0), size=(10, 6, 4))\n", + "s.cylinder(center=(20, 0, 0), radius=2, height=5)\n", + "s.sphere(center=(40, 0, 0), radius=3)\n", + "s.torus(center=(60, 0, 0), major=5, minor=1)\n", + "s.wedge(origin=(80, 0, 0), size=(4, 3, 2))\n", + "s.pyramid(center=(100, 0, 0), radius=3, height=4, sides=5)\n", + "s.box(center=(0, 20, 0), size=(2, 2, 2), layer='SOLIDS')\n", + )).unwrap(); + dispatch(&mut host, &format!("PY_RUN {}", script.display())); + let _ = std::fs::remove_file(&script); + let list = solids(&host); + assert_eq!(list.len(), 7, "{}", last(&host)); + let pi = std::f64::consts::PI; + let five_gon = 0.5 * 5.0 * 3.0f64.powi(2) * (2.0 * pi / 5.0).sin(); + for (index, expected, tolerance) in [ + (0, 240.0, 1e-9), (1, pi * 4.0 * 5.0, 0.02), (2, 4.0 / 3.0 * pi * 27.0, 0.02), + (3, 2.0 * pi * pi * 5.0 * 1.0, 0.02), (4, 12.0, 1e-9), (5, five_gon * 4.0 / 3.0, 0.02), (6, 8.0, 1e-9), + ] { + let actual = volume(&host, list[index]); + assert!(close(actual, expected, tolerance), "solid {index}: volume {actual}, expected {expected}"); + } + let Some(EntityType::Solid3D(first)) = host.document().get_entity(list[0]) else { unreachable!() }; + assert_eq!((first.wires.len(), first.common.layer.as_str()), (12, "0")); + assert!(matches!(host.document().get_entity(list[6]), Some(EntityType::Solid3D(s)) if s.common.layer == "SOLIDS")); + assert!(!host.app.tabs[0].scene.wire_models_for(&[list[0]]).is_empty(), "no canvas geometry"); + let read = |host: &mut HostSession<'_>, expression: &str| { + dispatch(host, &format!("PY_EVAL {expression}")); + last(host) + }; + assert!(read(&mut host, &format!("ocs.active_document.entities[{}].kind", list[0].value())).contains("Solid3D")); + + // E: rigid moves keep the volume and move the extent exactly. + let box_handle = list[0]; + let before = host.document().get_entity(box_handle).unwrap().clone(); + let undo_before = host.app.tabs[0].history.undo_stack.len(); + let (low, high) = extent(&host, box_handle); + assert!(close(low[0], -5.0, 1e-6) && close(high[2], 2.0, 1e-6), "{low:?} {high:?}"); + dispatch(&mut host, &format!("PY_EVAL ocs.active_document.solids.translate({}, (5, 5, 5)).handle", box_handle.value())); + let (low, high) = extent(&host, box_handle); + assert!(close(low[0], 0.0, 1e-6) && close(high[1], 8.0, 1e-6) && close(high[2], 7.0, 1e-6), "{low:?} {high:?}"); + dispatch(&mut host, &format!("PY_EVAL ocs.active_document.solids.rotate({}, 'z', 0.7853981633974483, (5, 5, 5)).handle", box_handle.value())); + dispatch(&mut host, &format!("PY_EVAL ocs.active_document.solids.mirror({}, 'x', (0, 0, 0)).handle", box_handle.value())); + assert!(close(volume(&host, box_handle), 240.0, 1e-9), "moves preserve volume"); + let Some(EntityType::Solid3D(moved)) = host.document().get_entity(box_handle) else { unreachable!() }; + assert_eq!((moved.wires.len(), moved.history_handle, moved.common.handle), (12, None, box_handle)); + assert_eq!(moved.common.layer, "0"); + assert_eq!(host.app.tabs[0].history.undo_stack.len() - undo_before, 3, "one undo step per move"); + + // V: refused operations change nothing and leave no undo step. + let snapshot = host.document().get_entity(box_handle).unwrap().clone(); + let count_before = solids(&host).len(); + let undo_now = host.app.tabs[0].history.undo_stack.len(); + let empty_payload = acadrust::entities::Solid3D::new(); + let unliftable = host.add_entity(EntityType::Solid3D(empty_payload.clone())); + let unliftable_before = host.document().get_entity(unliftable).unwrap().clone(); + for (command, message) in [ + ("ocs.active_document.solids.box(size=(0, 1, 1))".to_string(), "greater than zero"), + ("ocs.active_document.solids.box(center=(float('nan'), 0, 0))".to_string(), "must be finite"), + ("ocs.active_document.solids.torus(major=1, minor=2)".to_string(), "smaller than the major"), + ("ocs.active_document.solids.pyramid(sides=2)".to_string(), "between 3 and 1024"), + ("ocs.active_document.solids.sphere(radius=-1)".to_string(), "greater than zero"), + ("ocs.active_document.solids.box(layer='')".to_string(), "layer name is empty"), + ("ocs.solid_create('blob', [1.0], None)".to_string(), "unknown primitive"), + ("ocs.solid_create('box', [1.0], None)".to_string(), "takes 6 numbers"), + (format!("ocs.active_document.solids.transform({}, [2,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1])", box_handle.value()), "rigid moves"), + (format!("ocs.active_document.solids.transform({}, [1]*16)", box_handle.value()), "rigid moves"), + (format!("ocs.active_document.solids.translate({}, (float('nan'), 0, 0))", box_handle.value()), "must be finite"), + (format!("ocs.active_document.solids.translate({}, (1, 0, 0))", unliftable.value()), "cannot be lifted losslessly"), + ("ocs.active_document.solids.translate(999999, (1, 0, 0))".to_string(), "does not exist"), + ] { + dispatch(&mut host, &format!("PY_EVAL {command}")); + assert!(last(&host).contains(message), "{command}: {}", last(&host)); + } + assert_eq!(host.document().get_entity(box_handle), Some(&snapshot)); + assert_eq!(host.document().get_entity(unliftable), Some(&unliftable_before)); + assert_eq!(solids(&host).len(), count_before + 1, "only the fixture was added"); + assert_eq!(host.app.tabs[0].history.undo_stack.len(), undo_now, "refusals record no undo step"); + // Transforming a non-solid is refused. + let line = host.add_entity(EntityType::Line(acadrust::entities::Line::from_points( + acadrust::types::Vector3::ZERO, acadrust::types::Vector3::new(1.0, 0.0, 0.0)))); + dispatch(&mut host, &format!("PY_EVAL ocs.active_document.solids.translate({}, (1, 0, 0))", line.value())); + assert!(last(&host).contains("outside the Python schema") || last(&host).contains("applies to Solid3D"), "{}", last(&host)); + + // W: both formats reopen the moved solid losslessly; the reopened + // payload can be moved again. + let moved_volume = volume(&host, box_handle); + let moved_extent = extent(&host, box_handle); + let dwg = crate::io::load_bytes("solid.dwg", acadrust::DwgWriter::write_to_vec(host.document()).unwrap()).unwrap(); + let dxf = crate::io::load_bytes("solid.dxf", acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()).unwrap(); + for (format, document) in [("DWG", &dwg), ("DXF", &dxf)] { + let Some(EntityType::Solid3D(back)) = document.get_entity(box_handle) else { panic!("{format} lost the solid") }; + let body = kernel_body(back).unwrap_or_else(|| panic!("{format} payload does not lift")); + assert!(close(sm::volume(&body), moved_volume, 1e-6), "{format}"); + let reopened_extent = sm::extent(&body).unwrap(); + for axis in 0..3 { + assert!(close(reopened_extent.0[axis], moved_extent.0[axis], 1e-6) && close(reopened_extent.1[axis], moved_extent.1[axis], 1e-6), "{format} extent"); + } + let again = host.add_entity(EntityType::Solid3D(back.clone())); + dispatch(&mut host, &format!("PY_EVAL ocs.active_document.solids.translate({}, (100, 0, 0)).handle", again.value())); + let (low, _) = extent(&host, again); + assert!(close(low[0], reopened_extent.0[0] + 100.0, 1e-6), "{format} re-edit"); + } + + // D and U: delete, then undo and redo restore the moved state. + dispatch(&mut host, &format!("PY_EVAL ocs.active_document.delete_entity({})", box_handle.value())); + assert!(host.document().get_entity(box_handle).is_none()); + drop(process); + drop(host); + app.finish_pending_history(0); + // Steps after creation: 3 moves, 2 re-edits of reopened copies, 1 delete. + app.undo_steps(1); + assert!(app.tabs[0].scene.document.get_entity(box_handle).is_some(), "undo restores the deleted solid"); + app.undo_steps(5); + assert_eq!(app.tabs[0].scene.document.get_entity(box_handle), Some(&before), "undoing every move restores the original solid exactly"); + app.redo_steps(6); + assert!(app.tabs[0].scene.document.get_entity(box_handle).is_none(), "redo replays the moves and the delete"); + } + + #[test] + fn audit_python_body_transform_over_real_ipc() { + use crate::scene::model::solid_model as sm; + let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { + return; + }; + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + // A Body entity carrying a kernel box; scripts cannot create one. + let boxed = sm::box_solid([0.0; 3], 4.0, 4.0, 4.0).unwrap(); + let mut body = acadrust::entities::Body::new(); + body.set_sat_document(&crate::scene::convert::acis_export::solid_to_sat(&boxed).unwrap()); + body.wires = sm::edge_wires(&boxed); + let handle = host.add_entity(EntityType::Body(body)); + let process = ocs_plugin_api::process::PluginProcess::spawn( + std::path::Path::new(&plugin_path), &mut host, crate::plugin::v4_support::notification_handler(), + ).unwrap(); + let dispatch = |host: &mut HostSession<'_>, command: &str| { + assert!(process.dispatch(host, command, &mut |_| {}).expect("Python dispatch")); + }; + let extent = |host: &HostSession<'_>| { + let Some(EntityType::Body(b)) = host.document().get_entity(handle) else { panic!("no body") }; + sm::extent(&crate::scene::convert::solid3d_tess::kernel_acis_body(&b.acis_data).unwrap()).unwrap() + }; + assert!((extent(&host).0[0] + 2.0).abs() < 1e-6); + dispatch(&mut host, &format!("PY_EVAL ocs.active_document.entities[{}].kind", handle.value())); + assert!(host.app.command_line.history.last().unwrap().text.contains("Body")); + dispatch(&mut host, &format!("PY_EVAL ocs.active_document.solids.translate({}, (10, 0, 0)).handle", handle.value())); + assert!((extent(&host).0[0] - 8.0).abs() < 1e-6, "{:?}", extent(&host)); + let bytes = acadrust::DwgWriter::write_to_vec(host.document()).unwrap(); + let reopened = crate::io::load_bytes("body.dwg", bytes).unwrap(); + let Some(EntityType::Body(back)) = reopened.get_entity(handle) else { panic!("DWG lost the body") }; + let lifted = crate::scene::convert::solid3d_tess::kernel_acis_body(&back.acis_data).expect("DWG body lifts"); + assert!((sm::volume(&lifted) - 64.0).abs() < 1e-6); + // Scripts cannot create a Body and get the explanatory message. + dispatch(&mut host, "PY_EVAL ocs.active_document.create_entity('Body')"); + assert!(host.app.command_line.history.last().unwrap().text.contains("use doc.solids"), "{}", host.app.command_line.history.last().unwrap().text); + } + + #[test] + fn audit_python_region_lifecycle_over_real_ipc() { + use crate::scene::convert::solid3d_tess::kernel_acis_body; + use crate::scene::model::solid_model as sm; + let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { + return; + }; + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let process = ocs_plugin_api::process::PluginProcess::spawn( + std::path::Path::new(&plugin_path), &mut host, crate::plugin::v4_support::notification_handler(), + ).unwrap(); + let dispatch = |host: &mut HostSession<'_>, command: &str| { + assert!(process.dispatch(host, command, &mut |_| {}).expect("Python dispatch")); + }; + let last = |host: &HostSession<'_>| host.app.command_line.history.last().unwrap().text.clone(); + let regions = |host: &HostSession<'_>| -> Vec { + let mut list: Vec<_> = host.document().entities().filter_map(|e| match e { + EntityType::Region(r) => Some(r.common.handle), _ => None }).collect(); + list.sort_by_key(|h| h.value()); + list + }; + let extent_in = |document: &CadDocument, handle: Handle| { + let Some(EntityType::Region(region)) = document.get_entity(handle) else { panic!("no region {handle:?}") }; + let body = kernel_acis_body(®ion.acis_data).expect("region payload lifts losslessly"); + sm::extent(&body).unwrap() + }; + let close = |a: f64, b: f64| (a - b).abs() < 1e-6; + + // C: regions from a circle (source kept), a closed rectangle (source + // deleted) and a second circle on its own layer. + let script = std::env::temp_dir().join(format!("ocs_region_create_{}.py", std::process::id())); + std::fs::write(&script, concat!( + "doc = ocs.active_document\n", + "circle = doc.create_entity('Circle', center=(0, 0, 0), radius=5)\n", + "rect = doc.create_entity('LwPolyline', is_closed=True, vertices=[{'location': {'x': 20.0, 'y': 0.0}}, {'location': {'x': 30.0, 'y': 0.0}}, {'location': {'x': 30.0, 'y': 6.0}}, {'location': {'x': 20.0, 'y': 6.0}}])\n", + "doc.solids.region(circle)\n", + "doc.solids.region(rect, delete_source=True)\n", + "other = doc.create_entity('Circle', center=(50, 0, 0), radius=2, layer='PROFILES')\n", + "doc.solids.region(other, layer='REGIONS')\n", + )).unwrap(); + dispatch(&mut host, &format!("PY_RUN {}", script.display())); + let _ = std::fs::remove_file(&script); + let list = regions(&host); + assert_eq!(list.len(), 3, "{}", last(&host)); + let (low, high) = extent_in(host.document(), list[0]); + assert!(close(low[0], -5.0) && close(high[0], 5.0) && close(low[1], -5.0) && close(high[1], 5.0) && close(low[2], 0.0) && close(high[2], 0.0), "{low:?} {high:?}"); + let (low, high) = extent_in(host.document(), list[1]); + assert!(close(low[0], 20.0) && close(high[0], 30.0) && close(high[1], 6.0), "{low:?} {high:?}"); + let layer_of = |host: &HostSession<'_>, handle: Handle| host.document().get_entity(handle).unwrap().common().layer.clone(); + assert_eq!(layer_of(&host, list[0]), "0", "defaults to the profile's layer"); + assert_eq!(layer_of(&host, list[2]), "REGIONS"); + let circles = host.document().entities().filter(|e| matches!(e, EntityType::Circle(_))).count(); + let polylines = host.document().entities().filter(|e| matches!(e, EntityType::LwPolyline(_))).count(); + assert_eq!((circles, polylines), (2, 0), "delete_source removed only the rectangle"); + let Some(EntityType::Region(first)) = host.document().get_entity(list[0]) else { unreachable!() }; + assert_eq!((first.wires.is_empty(), first.point_of_reference.z), (false, 0.0)); + assert!(!host.app.tabs[0].scene.wire_models_for(&[list[0]]).is_empty(), "no canvas geometry"); + dispatch(&mut host, &format!("PY_EVAL ocs.active_document.entities[{}].kind", list[0].value())); + assert!(last(&host).contains("Region")); + + // V: refusals change nothing and leave no undo step. + let open_line = host.add_entity(EntityType::Line(acadrust::entities::Line::from_points( + acadrust::types::Vector3::ZERO, acadrust::types::Vector3::new(5.0, 0.0, 0.0)))); + let mut open_poly = acadrust::entities::LwPolyline::new(); + open_poly.vertices = vec![ + acadrust::entities::LwVertex::new(acadrust::types::Vector2::new(0.0, 0.0)), + acadrust::entities::LwVertex::new(acadrust::types::Vector2::new(4.0, 0.0)), + acadrust::entities::LwVertex::new(acadrust::types::Vector2::new(4.0, 4.0)), + ]; + let open_poly = host.add_entity(EntityType::LwPolyline(open_poly)); + let ray = host.add_entity(EntityType::Ray(acadrust::entities::Ray::default())); + let entity_count = host.document().entities().count(); + let undo_now = host.app.tabs[0].history.undo_stack.len(); + for (command, message) in [ + (format!("ocs.active_document.solids.region({})", open_line.value()), "must be closed"), + (format!("ocs.active_document.solids.region({})", open_poly.value()), "must be closed"), + (format!("ocs.active_document.solids.region({})", ray.value()), "planar profile"), + (format!("ocs.active_document.solids.region({})", list[0].value()), "already a region"), + ("ocs.active_document.solids.region(999999)".to_string(), "does not exist"), + (format!("ocs.active_document.solids.region({}, layer='')", open_line.value()), "layer name is empty"), + ] { + dispatch(&mut host, &format!("PY_EVAL {command}")); + assert!(last(&host).contains(message), "{command}: {}", last(&host)); + } + assert_eq!(host.document().entities().count(), entity_count); + assert_eq!(host.app.tabs[0].history.undo_stack.len(), undo_now, "refusals record no undo step"); + + // E: a region moves through the same kernel channel. + let before = host.document().get_entity(list[0]).unwrap().clone(); + dispatch(&mut host, &format!("PY_EVAL ocs.active_document.solids.translate({}, (10, 5, 0)).handle", list[0].value())); + let (low, high) = extent_in(host.document(), list[0]); + assert!(close(low[0], 5.0) && close(high[0], 15.0) && close(low[1], 0.0) && close(high[1], 10.0), "{low:?} {high:?}"); + + // W: both formats reopen a region that lifts with the same extent, and + // a reopened region moves again. + let dwg = crate::io::load_bytes("region.dwg", acadrust::DwgWriter::write_to_vec(host.document()).unwrap()).unwrap(); + let dxf = crate::io::load_bytes("region.dxf", acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()).unwrap(); + let moved = extent_in(host.document(), list[0]); + for (format, document) in [("DWG", &dwg), ("DXF", &dxf)] { + assert_eq!(regions_in(document).len(), 3, "{format}"); + let reopened = extent_in(document, list[0]); + for axis in 0..3 { + assert!(close(reopened.0[axis], moved.0[axis]) && close(reopened.1[axis], moved.1[axis]), "{format} extent {reopened:?} vs {moved:?}"); + } + let Some(EntityType::Region(back)) = document.get_entity(list[0]) else { unreachable!() }; + let again = host.add_entity(EntityType::Region(back.clone())); + dispatch(&mut host, &format!("PY_EVAL ocs.active_document.solids.translate({}, (100, 0, 0)).handle", again.value())); + assert!(close(extent_in(host.document(), again).0[0], reopened.0[0] + 100.0), "{format} re-edit"); + } + fn regions_in(document: &CadDocument) -> Vec { + document.entities().filter_map(|e| match e { EntityType::Region(r) => Some(r.common.handle), _ => None }).collect() + } + + // D and U: delete, undo, and undo the creation steps exactly. + dispatch(&mut host, &format!("PY_EVAL ocs.active_document.delete_entity({})", list[2].value())); + assert!(host.document().get_entity(list[2]).is_none()); + drop(process); + drop(host); + app.finish_pending_history(0); + app.undo_steps(1); + assert!(app.tabs[0].scene.document.get_entity(list[2]).is_some(), "undo restores the deleted region"); + // Two re-edits of reopened copies, then the move of the first region. + app.undo_steps(3); + assert_eq!(app.tabs[0].scene.document.get_entity(list[0]), Some(&before), "undoing the move restores the region exactly"); + } + + #[test] + fn audit_python_surface_lifecycle_over_real_ipc() { + use crate::scene::convert::solid3d_tess::kernel_acis_body; + use crate::scene::model::solid_model as sm; + use acadrust::entities::SurfaceKind; + let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { + return; + }; + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let process = ocs_plugin_api::process::PluginProcess::spawn( + std::path::Path::new(&plugin_path), &mut host, crate::plugin::v4_support::notification_handler(), + ).unwrap(); + let dispatch = |host: &mut HostSession<'_>, command: &str| { + assert!(process.dispatch(host, command, &mut |_| {}).expect("Python dispatch")); + }; + let last = |host: &HostSession<'_>| host.app.command_line.history.last().unwrap().text.clone(); + let sorted = |mut list: Vec| { list.sort_by_key(|h| h.value()); list }; + let surfaces_in = |document: &CadDocument| sorted(document.entities().filter_map(|e| match e { + EntityType::Surface(s) => Some(s.common.handle), _ => None }).collect()); + let solids_in = |document: &CadDocument| sorted(document.entities().filter_map(|e| match e { + EntityType::Solid3D(s) => Some(s.common.handle), _ => None }).collect()); + let body_of = |document: &CadDocument, handle: Handle| match document.get_entity(handle) { + Some(EntityType::Surface(s)) => kernel_acis_body(&s.acis_data).expect("surface payload lifts losslessly"), + Some(EntityType::Solid3D(s)) => kernel_acis_body(&s.acis_data).expect("solid payload lifts losslessly"), + other => panic!("no surface or solid {handle:?}: {other:?}"), + }; + let extent_in = |document: &CadDocument, handle: Handle| sm::extent(&body_of(document, handle)).unwrap(); + let close = |a: f64, b: f64| (a - b).abs() < 1e-6; + let kind_in = |document: &CadDocument, handle: Handle| match document.get_entity(handle) { + Some(EntityType::Surface(s)) => s.kind, other => panic!("no surface {handle:?}: {other:?}") }; + + // C: plane surfaces and extrusions from real profile entities. + let script = std::env::temp_dir().join(format!("ocs_surface_create_{}.py", std::process::id())); + std::fs::write(&script, concat!( + "doc = ocs.active_document\n", + "s = doc.solids\n", + "circle = doc.create_entity('Circle', center=(0, 0, 0), radius=3)\n", + "rect = doc.create_entity('LwPolyline', is_closed=True, vertices=[{'location': {'x': 20.0, 'y': 0.0}}, {'location': {'x': 30.0, 'y': 0.0}}, {'location': {'x': 30.0, 'y': 6.0}}, {'location': {'x': 20.0, 'y': 6.0}}])\n", + "open_path = doc.create_entity('LwPolyline', vertices=[{'location': {'x': 50.0, 'y': 0.0}}, {'location': {'x': 54.0, 'y': 0.0}}, {'location': {'x': 54.0, 'y': 4.0}}])\n", + "temp = doc.create_entity('Circle', center=(70, 0, 0), radius=2)\n", + "s.surface(circle)\n", + "s.surface(rect, layer='SURFACES')\n", + "s.extrude(rect, (0, 0, 4))\n", + "s.extrude(circle, (0, 0, 5))\n", + "s.extrude(open_path, (0, 0, 3))\n", + "s.extrude(temp, (0, 0, 2), delete_source=True)\n", + )).unwrap(); + dispatch(&mut host, &format!("PY_RUN {}", script.display())); + let _ = std::fs::remove_file(&script); + let surfaces = surfaces_in(host.document()); + let solids = solids_in(host.document()); + assert_eq!((surfaces.len(), solids.len()), (3, 3), "{}", last(&host)); + // surfaces[0] plane of the circle, [1] plane of the rectangle, [2] extruded open path. + assert_eq!((kind_in(host.document(), surfaces[0]), kind_in(host.document(), surfaces[1]), kind_in(host.document(), surfaces[2])), + (SurfaceKind::Plane, SurfaceKind::Plane, SurfaceKind::Generic)); + let (low, high) = extent_in(host.document(), surfaces[0]); + assert!(close(low[0], -3.0) && close(high[1], 3.0) && close(low[2], 0.0) && close(high[2], 0.0), "{low:?} {high:?}"); + let (low, high) = extent_in(host.document(), surfaces[2]); + assert!(close(low[0], 50.0) && close(high[0], 54.0) && close(low[2], 0.0) && close(high[2], 3.0), "open path extrusion {low:?} {high:?}"); + assert_eq!(host.document().get_entity(surfaces[1]).unwrap().common().layer, "SURFACES"); + let pi = std::f64::consts::PI; + for (index, expected, tolerance) in [(0, 240.0, 1e-9), (1, pi * 9.0 * 5.0, 0.02), (2, pi * 4.0 * 2.0, 0.02)] { + let volume = sm::volume(&body_of(host.document(), solids[index])); + assert!((volume - expected).abs() <= tolerance * expected, "solid {index}: {volume} vs {expected}"); + } + assert_eq!(host.document().entities().filter(|e| matches!(e, EntityType::Circle(_))).count(), 1, + "delete_source removed only the extruded temporary circle"); + assert!(!host.app.tabs[0].scene.wire_models_for(&[surfaces[0]]).is_empty(), "no canvas geometry"); + dispatch(&mut host, &format!("PY_EVAL ocs.active_document.entities[{}].kind", surfaces[0].value())); + assert!(last(&host).contains("Surface")); + + // V: refusals change nothing and leave no undo step. + let ray = host.add_entity(EntityType::Ray(acadrust::entities::Ray::default())); + let circle = host.document().entities().find_map(|e| match e { EntityType::Circle(c) => Some(c.common.handle), _ => None }).unwrap(); + let counts = (host.document().entities().count(), host.app.tabs[0].history.undo_stack.len()); + for (command, message) in [ + (format!("ocs.active_document.solids.extrude({}, (0, 0, 0))", circle.value()), "nonzero"), + (format!("ocs.active_document.solids.extrude({}, (float('nan'), 0, 1))", circle.value()), "must be finite"), + (format!("ocs.active_document.solids.extrude({}, (0, 0, 1))", ray.value()), "planar profile"), + (format!("ocs.active_document.solids.extrude({}, (0, 0, 1))", solids[0].value()), "curve profile"), + (format!("ocs.active_document.solids.surface({})", surfaces[0].value()), "curve profile"), + (format!("ocs.active_document.solids.surface({})", ray.value()), "planar profile"), + (format!("ocs.active_document.solids.surface({}, layer='')", circle.value()), "layer name is empty"), + ("ocs.active_document.solids.surface(999999)".to_string(), "does not exist"), + ("ocs.solid_extrude(1, [1.0], None, False)".to_string(), "3 numbers"), + ] { + dispatch(&mut host, &format!("PY_EVAL {command}")); + assert!(last(&host).contains(message), "{command}: {}", last(&host)); + } + assert_eq!((host.document().entities().count(), host.app.tabs[0].history.undo_stack.len()), counts, + "refusals change nothing and record no undo step"); + + // E: isoline density is scriptable and validated; kind and payload are not. + let plane = surfaces[0]; + let plane_before = host.document().get_entity(plane).unwrap().clone(); + let script = std::env::temp_dir().join(format!("ocs_surface_edit_{}.py", std::process::id())); + std::fs::write(&script, format!(concat!( + "doc = ocs.active_document\n", + "e = doc.entities[{}]\n", + "with doc.transaction('Edit surface'):\n", + " e.u_isolines = 8\n", + " e.v_isolines = 6\n", + ), plane.value())).unwrap(); + dispatch(&mut host, &format!("PY_RUN {}", script.display())); + let _ = std::fs::remove_file(&script); + let Some(EntityType::Surface(edited)) = host.document().get_entity(plane) else { unreachable!() }; + assert_eq!((edited.u_isolines, edited.v_isolines, edited.kind), (8, 6, SurfaceKind::Plane), "{}", last(&host)); + let edited_state = host.document().get_entity(plane).unwrap().clone(); + for (patch, message) in [("'u_isolines':500", "between 0 and 200"), ("'surface_kind':'Extruded'", "read-only"), + ("'surface_data':{}", "outside the editable schema"), ("'v_isolines':-1", "between 0 and 200")] { + dispatch(&mut host, &format!("PY_EVAL ocs.update_many('Reject surface', [{{'handle':{}, {patch}}}])", plane.value())); + assert_eq!(host.document().get_entity(plane), Some(&edited_state), "{patch}"); + assert!(last(&host).contains(message), "{patch}: {}", last(&host)); + } + // Moves keep a plane a plane and turn a swept surface generic. + dispatch(&mut host, &format!("PY_EVAL ocs.active_document.solids.translate({}, (10, 5, 0)).handle", plane.value())); + dispatch(&mut host, &format!("PY_EVAL ocs.active_document.solids.translate({}, (0, 0, 7)).handle", surfaces[2].value())); + assert_eq!(kind_in(host.document(), plane), SurfaceKind::Plane); + let (low, high) = extent_in(host.document(), plane); + assert!(close(low[0], 7.0) && close(high[0], 13.0) && close(low[1], 2.0) && close(high[1], 8.0), "{low:?} {high:?}"); + let (low, _) = extent_in(host.document(), surfaces[2]); + assert!(close(low[2], 7.0), "{low:?}"); + + // W: both formats reopen surfaces that lift with the same extents, keep + // the isolines, and a reopened surface moves again. + let dwg = crate::io::load_bytes("surface.dwg", acadrust::DwgWriter::write_to_vec(host.document()).unwrap()).unwrap(); + let dxf = crate::io::load_bytes("surface.dxf", acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()).unwrap(); + let moved = extent_in(host.document(), plane); + for (format, document) in [("DWG", &dwg), ("DXF", &dxf)] { + assert_eq!((surfaces_in(document).len(), solids_in(document).len()), (3, 3), "{format}"); + let reopened = extent_in(document, plane); + for axis in 0..3 { + assert!(close(reopened.0[axis], moved.0[axis]) && close(reopened.1[axis], moved.1[axis]), "{format} extent"); + } + let Some(EntityType::Surface(back)) = document.get_entity(plane) else { unreachable!() }; + assert_eq!((back.u_isolines, back.v_isolines), (8, 6), "{format} isolines"); + assert_eq!(back.kind, SurfaceKind::Plane, "{format} keeps the plane kind"); + let again = host.add_entity(EntityType::Surface(back.clone())); + dispatch(&mut host, &format!("PY_EVAL ocs.active_document.solids.translate({}, (100, 0, 0)).handle", again.value())); + assert!(close(extent_in(host.document(), again).0[0], reopened.0[0] + 100.0), "{format} re-edit"); + } + + // D and U. + dispatch(&mut host, &format!("PY_EVAL ocs.active_document.delete_entity({})", surfaces[1].value())); + assert!(host.document().get_entity(surfaces[1]).is_none()); + drop(process); + drop(host); + app.finish_pending_history(0); + app.undo_steps(1); + assert!(app.tabs[0].scene.document.get_entity(surfaces[1]).is_some(), "undo restores the deleted surface"); + // Two re-edits of reopened copies, two moves, and the isoline edit come before. + app.undo_steps(2 + 2 + 1); + assert_eq!(app.tabs[0].scene.document.get_entity(plane), Some(&plane_before), "undoing the edits restores the surface exactly"); + } + + #[test] + fn audit_python_boolean_lifecycle_over_real_ipc() { + use crate::scene::convert::solid3d_tess::kernel_body; + use crate::scene::model::solid_model as sm; + let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { + return; + }; + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let process = ocs_plugin_api::process::PluginProcess::spawn( + std::path::Path::new(&plugin_path), &mut host, crate::plugin::v4_support::notification_handler(), + ).unwrap(); + let dispatch = |host: &mut HostSession<'_>, command: &str| { + assert!(process.dispatch(host, command, &mut |_| {}).expect("Python dispatch")); + }; + let last = |host: &HostSession<'_>| host.app.command_line.history.last().unwrap().text.clone(); + let solids_in = |document: &CadDocument| { + let mut list: Vec = document.entities().filter_map(|e| match e { + EntityType::Solid3D(s) => Some(s.common.handle), _ => None }).collect(); + list.sort_by_key(|h| h.value()); + list + }; + let body_in = |document: &CadDocument, handle: Handle| { + let Some(EntityType::Solid3D(solid)) = document.get_entity(handle) else { panic!("no solid {handle:?}") }; + kernel_body(solid).expect("payload lifts losslessly") + }; + let volume_in = |document: &CadDocument, handle: Handle| sm::volume(&body_in(document, handle)); + let extent_in = |document: &CadDocument, handle: Handle| sm::extent(&body_in(document, handle)).unwrap(); + let close = |a: f64, b: f64| (a - b).abs() < 1e-6 * b.abs().max(1.0); + + // C: all three operations on two overlapping boxes, keeping the + // operands. A: x 0..10, B: x 5..15, both 6 by 4; the overlap is 120. + let script = std::env::temp_dir().join(format!("ocs_boolean_create_{}.py", std::process::id())); + std::fs::write(&script, concat!( + "s = ocs.active_document.solids\n", + "a = s.box(center=(5, 3, 2), size=(10, 6, 4))\n", + "b = s.box(center=(10, 3, 2), size=(10, 6, 4))\n", + "s.union(a, b, keep_operands=True)\n", + "s.subtract(a, b, keep_operands=True)\n", + "s.intersect(a, b, keep_operands=True)\n", + )).unwrap(); + dispatch(&mut host, &format!("PY_RUN {}", script.display())); + let _ = std::fs::remove_file(&script); + let list = solids_in(host.document()); + assert_eq!(list.len(), 5, "operands kept and three results: {}", last(&host)); + let (a, b) = (list[0], list[1]); + for (index, expected) in [(2, 360.0), (3, 120.0), (4, 120.0)] { + let actual = volume_in(host.document(), list[index]); + assert!(close(actual, expected), "result {index}: volume {actual}, expected {expected}"); + } + let (low, high) = extent_in(host.document(), list[3]); + assert!(close(low[0], 0.0) && close(high[0], 5.0), "subtract keeps x 0..5: {low:?} {high:?}"); + let (low, high) = extent_in(host.document(), list[4]); + assert!(close(low[0], 5.0) && close(high[0], 10.0), "intersect keeps x 5..10: {low:?} {high:?}"); + let Some(EntityType::Solid3D(union)) = host.document().get_entity(list[2]) else { unreachable!() }; + assert_eq!((union.wires.is_empty(), union.history_handle), (false, None)); + assert!(!host.app.tabs[0].scene.wire_models_for(&[list[2]]).is_empty(), "no canvas geometry"); + dispatch(&mut host, &format!("PY_EVAL ocs.active_document.entities[{}].kind", list[2].value())); + assert!(last(&host).contains("Solid3D")); + + // Consuming operands: A minus B leaves only the result, on a new layer. + let before_consume = (host.document().get_entity(a).unwrap().clone(), host.document().get_entity(b).unwrap().clone()); + let undo_before = host.app.tabs[0].history.undo_stack.len(); + dispatch(&mut host, &format!("PY_EVAL ocs.active_document.solids.subtract({}, {}, layer='CUT').handle", a.value(), b.value())); + assert!(host.document().get_entity(a).is_none() && host.document().get_entity(b).is_none(), "operands are consumed"); + let cut = *solids_in(host.document()).last().unwrap(); + assert!(close(volume_in(host.document(), cut), 120.0)); + assert_eq!(host.document().get_entity(cut).unwrap().common().layer, "CUT"); + assert_eq!(host.app.tabs[0].history.undo_stack.len() - undo_before, 1, "one undo step per boolean"); + + // A second planar operation chains on the result: drill a through-hole. + dispatch(&mut host, "PY_EVAL ocs.active_document.solids.box(center=(2.5, 3, 2), size=(2, 2, 6)).handle"); + let hole = *solids_in(host.document()).last().unwrap(); + dispatch(&mut host, &format!("PY_EVAL ocs.active_document.solids.subtract({}, {}).handle", cut.value(), hole.value())); + let drilled = *solids_in(host.document()).last().unwrap(); + assert!(close(volume_in(host.document(), drilled), 104.0), "120 minus a 2x2x4 hole is 104"); + + // V: refusals change nothing and leave no undo step. + let line = host.add_entity(EntityType::Line(acadrust::entities::Line::from_points( + acadrust::types::Vector3::ZERO, acadrust::types::Vector3::new(1.0, 0.0, 0.0)))); + let unliftable = host.add_entity(EntityType::Solid3D(acadrust::entities::Solid3D::new())); + let mut plane = acadrust::entities::Surface::new(acadrust::entities::SurfaceKind::Plane); + plane.acis_data = acadrust::entities::AcisData::new(); + let surface = host.add_entity(EntityType::Surface(plane)); + let far = { dispatch(&mut host, "PY_EVAL ocs.active_document.solids.box(center=(500, 500, 500), size=(1, 1, 1)).handle"); + *solids_in(host.document()).last().unwrap() }; + let counts = (host.document().entities().count(), host.app.tabs[0].history.undo_stack.len()); + let z = drilled.value(); + for (command, message) in [ + (format!("ocs.active_document.solids.union({z}, {z})"), "cannot be combined with itself"), + (format!("ocs.active_document.solids.union({z}, {})", line.value()), "must be a Solid3D"), + (format!("ocs.active_document.solids.union({}, {z})", surface.value()), "must be a Solid3D"), + (format!("ocs.active_document.solids.union({z}, 999999)"), "does not exist"), + (format!("ocs.active_document.solids.union({z}, {})", unliftable.value()), "cannot be lifted losslessly"), + (format!("ocs.active_document.solids.union({z}, {}, layer='')", far.value()), "layer name is empty"), + (format!("ocs.solid_boolean({z}, {}, 'xor', None, False)", far.value()), "unknown operation"), + (format!("ocs.active_document.solids.intersect({z}, {})", far.value()), "changed"), + ] { + dispatch(&mut host, &format!("PY_EVAL {command}")); + assert!(last(&host).contains(message), "{command}: {}", last(&host)); + } + assert_eq!((host.document().entities().count(), host.app.tabs[0].history.undo_stack.len()), counts, + "refusals change nothing and record no undo step"); + + // W: both formats reopen the drilled result losslessly; it moves again. + let expected_volume = volume_in(host.document(), drilled); + let expected_extent = extent_in(host.document(), drilled); + let dwg = crate::io::load_bytes("boolean.dwg", acadrust::DwgWriter::write_to_vec(host.document()).unwrap()).unwrap(); + let dxf = crate::io::load_bytes("boolean.dxf", acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()).unwrap(); + for (format, document) in [("DWG", &dwg), ("DXF", &dxf)] { + assert!(close(volume_in(document, drilled), expected_volume), "{format} volume"); + let reopened = extent_in(document, drilled); + for axis in 0..3 { + assert!(close(reopened.0[axis], expected_extent.0[axis]) && close(reopened.1[axis], expected_extent.1[axis]), "{format} extent"); + } + let Some(EntityType::Solid3D(back)) = document.get_entity(drilled) else { unreachable!() }; + let again = host.add_entity(EntityType::Solid3D(back.clone())); + dispatch(&mut host, &format!("PY_EVAL ocs.active_document.solids.translate({}, (100, 0, 0)).handle", again.value())); + assert!(close(extent_in(host.document(), again).0[0], reopened.0[0] + 100.0), "{format} re-edit"); + } + + // D and U: delete, then undo restores it, and undoing the consuming + // boolean brings both operands back exactly. + dispatch(&mut host, &format!("PY_EVAL ocs.active_document.delete_entity({})", drilled.value())); + assert!(host.document().get_entity(drilled).is_none()); + drop(process); + drop(host); + app.finish_pending_history(0); + app.undo_steps(1); + assert!(app.tabs[0].scene.document.get_entity(drilled).is_some(), "undo restores the deleted result"); + // Two re-edits, the far box, the drilling subtract and the hole box, then the consuming subtract. + app.undo_steps(2 + 1 + 1 + 1 + 1); + assert_eq!(app.tabs[0].scene.document.get_entity(a), Some(&before_consume.0), "undo restores operand A exactly"); + assert_eq!(app.tabs[0].scene.document.get_entity(b), Some(&before_consume.1), "undo restores operand B exactly"); + assert!(app.tabs[0].scene.document.get_entity(cut).is_none(), "undo removes the boolean result"); + } + + /// Release-only evidence for docs/cadkernel-body-path.md: the kernel's + /// refusals reach the script as messages. Curved booleans take about + /// 23 s in a debug build, so this is ignored by default. + #[test] + #[ignore = "curved boolean; slow in debug builds"] + fn audit_python_boolean_kernel_refusal_over_real_ipc() { + let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { + return; + }; + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let process = ocs_plugin_api::process::PluginProcess::spawn( + std::path::Path::new(&plugin_path), &mut host, crate::plugin::v4_support::notification_handler(), + ).unwrap(); + let dispatch = |host: &mut HostSession<'_>, command: &str| { + assert!(process.dispatch(host, command, &mut |_| {}).expect("Python dispatch")); + }; + let script = std::env::temp_dir().join(format!("ocs_boolean_refusal_{}.py", std::process::id())); + std::fs::write(&script, concat!( + "s = ocs.active_document.solids\n", + "box = s.box(center=(0, 0, 0), size=(10, 6, 4))\n", + "sphere = s.sphere(center=(0, 0, 0), radius=3)\n", + "s.subtract(box, sphere)\n", + )).unwrap(); + dispatch(&mut host, &format!("PY_RUN {}", script.display())); + let _ = std::fs::remove_file(&script); + let message = host.app.command_line.history.last().unwrap().text.clone(); + assert!(message.contains("Coincident") && message.contains("nothing was changed"), "{message}"); + } + + #[test] + fn audit_python_section_symbol_lifecycle_over_real_ipc() { + run_mesh_lifecycle(&MeshCase { + create: concat!( + "doc.create_entity('SectionSymbol', symbol_scale=1.0, points=[\n", + " {'point': P(0.0, 0.0, 0.0), 'label': 'A', 'label_offset': P(0.0, 2.0, 0.0)},\n", + " {'point': P(40.0, 0.0, 0.0), 'label': 'A', 'label_offset': P(0.0, 2.0, 0.0)}])\n"), + edit: concat!( + "e = doc.entities[HANDLE]\n", + "with doc.transaction('Edit'):\n", + " e.points = [{'point': P(0.0, 0.0, 0.0), 'label': 'B', 'label_offset': P(0.0, 3.0, 0.0)},\n", + " {'point': P(20.0, 10.0, 0.0), 'bulge': 0.0}, {'point': P(40.0, 0.0, 0.0), 'label': 'B', 'label_offset': P(0.0, -3.0, 0.0)}]\n", + " e.symbol_scale = 2.0\n", + "doc.selection = [e]\n"), + rejects: &[ + ("'points':[{'point':{'x':0.0,'y':0.0,'z':0.0}}]", "at least 2 points"), + ("'points':[{'point':{'x':float('nan'),'y':0.0,'z':0.0}},{'point':{'x':1.0,'y':0.0,'z':0.0}}]", "finite"), + ("'symbol_scale':0.0", "greater than zero"), + ("'style_handle':999999", "not an existing SectionViewStyle"), + ("'view_rep_handle':999999", "not an existing ViewRep"), + ("'raw_point_count_90':9", "read-only"), + ("'end_a':[1.0, 1.0]", "read-only"), + ], + is_kind: |entity| matches!(entity, EntityType::SectionSymbol(_)), + digest: |entity| match entity { + EntityType::SectionSymbol(v) => format!("n{} ends {:.1},{:.1}->{:.1},{:.1} label{} tick{:.1}/{:.1} scale{:.1} counts{}/{}", + v.points.len(), v.end_a[0], v.end_a[1], v.end_b[0], v.end_b[1], v.label, v.tick_a, v.tick_b, + v.symbol_scale, v.raw_point_count_90, v.raw_point_record_count), + other => format!("wrong kind {}", format!("{other:?}").split('(').next().unwrap()), + }, + reedit: |entity| if let EntityType::SectionSymbol(v) = entity { v.symbol_scale = 3.0; }, + expect_created: "n2 ends 0.0,0.0->40.0,0.0 labelA tick2.0/2.0 scale1.0 counts2/2", + expect_edited: "n3 ends 0.0,0.0->40.0,0.0 labelB tick3.0/-3.0 scale2.0 counts3/3", + expect_reedited: "n3 ends 0.0,0.0->40.0,0.0 labelB tick3.0/-3.0 scale3.0 counts3/3", + // BLOCKER (cadcodec, see docs/cadcodec-reader-gaps.md issue 5): the + // DXF reader dispatches SECTIONLINE only inside blocks, so a symbol + // in the entity list reopens as an unknown entity. + expect_edited_dxf: "wrong kind Unknown", + expect_reedited_dxf: "wrong kind Unknown", + expect_edited_dwg: "", + expect_reedited_dwg: "", + }); + } + + #[test] + fn audit_python_section_symbol_references_over_real_ipc() { + use acadrust::objects::{ClassObject, ClassObjectData, ObjectType}; + let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { + return; + }; + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let object = |host: &mut HostSession<'_>, data: ClassObjectData| { + let handle = host.document_mut().allocate_handle(); + let mut class_object = ClassObject::new(data); + class_object.handle = handle; + host.document_mut().objects.insert(handle, ObjectType::ClassObject(class_object)); + handle + }; + let style = object(&mut host, ClassObjectData::SectionViewStyle(Default::default())); + let view_rep = object(&mut host, ClassObjectData::ViewRep(Default::default())); + let scale = host.document_mut().allocate_handle(); + let mut scale_object = acadrust::objects::Scale::new("1:1", 1.0, 1.0); + scale_object.handle = scale; + host.document_mut().objects.insert(scale, ObjectType::Scale(scale_object)); + let process = ocs_plugin_api::process::PluginProcess::spawn( + std::path::Path::new(&plugin_path), &mut host, crate::plugin::v4_support::notification_handler(), + ).unwrap(); + let dispatch = |host: &mut HostSession<'_>, command: &str| { + assert!(process.dispatch(host, command, &mut |_| {}).expect("Python dispatch")); + }; + let last = |host: &HostSession<'_>| host.app.command_line.history.last().unwrap().text.clone(); + let count = |host: &HostSession<'_>| host.document().entities().filter(|e| matches!(e, EntityType::SectionSymbol(_))).count(); + let create = |extra: &str| format!( + "PY_EVAL ocs.active_document.create_entity('SectionSymbol', points=[{{'point': {{'x': 0.0, 'y': 0.0, 'z': 0.0}}}}, {{'point': {{'x': 10.0, 'y': 0.0, 'z': 0.0}}}}]{extra}).handle"); + // A symbol may reference the drawing's own style and view representation. + dispatch(&mut host, &create(&format!(", style_handle={}, view_rep_handle={}", style.value(), view_rep.value()))); + assert_eq!(count(&host), 1, "{}", last(&host)); + let Some(EntityType::SectionSymbol(symbol)) = host.document().entities().find(|e| matches!(e, EntityType::SectionSymbol(_))) else { unreachable!() }; + assert_eq!((symbol.style_handle, symbol.view_rep_handle), (style, view_rep)); + // A standalone symbol needs neither. + dispatch(&mut host, &create("")); + assert_eq!(count(&host), 2, "{}", last(&host)); + // Wrong object kinds are refused: a style slot cannot hold a view rep, a + // scale object is neither, and a swapped pair fails. + for (extra, message) in [ + (format!(", style_handle={}", view_rep.value()), "not an existing SectionViewStyle"), + (format!(", style_handle={}", scale.value()), "not an existing SectionViewStyle"), + (format!(", view_rep_handle={}", style.value()), "not an existing ViewRep"), + (", view_rep_handle=424242".to_string(), "not an existing ViewRep"), + ] { + dispatch(&mut host, &create(&extra)); + assert_eq!(count(&host), 2, "{extra}"); + assert!(last(&host).contains(message), "{extra}: {}", last(&host)); + } + } + + #[test] + fn audit_python_ole2frame_creation_rules_over_real_ipc() { + let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { + return; + }; + let dir = std::env::temp_dir(); + let png = dir.join("ocs_ole_rules.png"); + let jpeg = dir.join("ocs_ole_rules.jpg"); + let tiff = dir.join("ocs_ole_rules.tif"); + let text = dir.join("ocs_ole_rules.txt"); + let picture = image::RgbImage::from_pixel(8, 4, image::Rgb([200, 40, 40])); + picture.save(&png).unwrap(); + picture.save(&jpeg).unwrap(); + picture.save(&tiff).unwrap(); + std::fs::write(&text, "not a picture").unwrap(); + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let process = ocs_plugin_api::process::PluginProcess::spawn( + std::path::Path::new(&plugin_path), &mut host, crate::plugin::v4_support::notification_handler(), + ).unwrap(); + let dispatch = |host: &mut HostSession<'_>, command: &str| { + assert!(process.dispatch(host, command, &mut |_| {}).expect("Python dispatch")); + }; + let last = |host: &HostSession<'_>| host.app.command_line.history.last().unwrap().text.clone(); + let frames = |host: &HostSession<'_>| host.document().entities().filter(|e| matches!(e, EntityType::Ole2Frame(_))).count(); + let picture_len = |host: &HostSession<'_>, index: usize| { + let mut all: Vec<_> = host.document().entities().filter_map(|e| match e { + EntityType::Ole2Frame(f) => Some(f.clone()), _ => None }).collect(); + all.sort_by_key(|f| f.common.handle.value()); + match acadrust::entities::extract_presentation(&all[index].encoded_payload()) { + Some(acadrust::entities::OlePresentation::Raster(bytes)) => bytes, + other => panic!("no embedded raster: {other:?}"), + } + }; + let embed = |file: &std::path::Path, extra: &str| format!( + "PY_EVAL ocs.active_document.embed_picture({:?}, origin=(1, 2, 3), width=16{extra}).handle", file.display().to_string()); + + // C: PNG and JPEG are stored as read, a TIFF is re-encoded as PNG, and + // the frame keeps the picture's 2:1 aspect and the requested layer. + dispatch(&mut host, &embed(&png, ", layer='PICTURES'")); + dispatch(&mut host, &embed(&jpeg, "")); + dispatch(&mut host, &embed(&tiff, "")); + assert_eq!(frames(&host), 3, "{}", last(&host)); + assert_eq!(picture_len(&host, 0), std::fs::read(&png).unwrap(), "PNG is stored as read"); + assert_eq!(picture_len(&host, 1), std::fs::read(&jpeg).unwrap(), "JPEG is stored as read"); + assert_eq!(&picture_len(&host, 2)[..4], b"\x89PNG", "TIFF is re-encoded as PNG"); + let mut all: Vec<_> = host.document().entities().filter_map(|e| match e { + EntityType::Ole2Frame(f) => Some(f.clone()), _ => None }).collect(); + all.sort_by_key(|f| f.common.handle.value()); + assert_eq!((all[0].common.layer.as_str(), all[1].common.layer.as_str()), ("PICTURES", "0")); + let frame = &all[0]; + assert!((frame.lower_right_corner.x - frame.upper_left_corner.x - 16.0).abs() < 1e-9, "width 16"); + assert!((frame.upper_left_corner.y - frame.lower_right_corner.y - 8.0).abs() < 1e-9, "height 8 keeps the 2:1 aspect"); + assert!((frame.lower_right_corner.y - 2.0).abs() < 1e-9 && (frame.upper_left_corner.x - 1.0).abs() < 1e-9, "origin is the bottom-left corner"); + + // V: refusals change nothing and record no undo step. + let undo = host.app.tabs[0].history.undo_stack.len(); + let missing = dir.join("ocs_ole_rules_missing.png"); + for (command, message) in [ + (embed(&missing, ""), "cannot read the picture"), + (embed(&text, ""), "cannot read the picture"), + (embed(&png, ", layer=''").replace("width=16", "width=16"), "layer name is empty"), + (embed(&png, "").replace("width=16", "width=0"), "must be finite and greater than zero"), + (embed(&png, "").replace("origin=(1, 2, 3)", "origin=(float('nan'), 0, 0)"), "must be finite"), + ("ocs.embed_picture('x.png', [1.0], 5.0, None)".to_string(), "origin needs 3 numbers"), + ("ocs.active_document.create_entity('Ole2Frame', upper_left_corner=(0, 0, 0), lower_right_corner=(1, 1, 0))".to_string(), "doc.embed_picture"), + ] { + dispatch(&mut host, &format!("PY_EVAL {}", command.trim_start_matches("PY_EVAL "))); + assert!(last(&host).contains(message), "{command}: {}", last(&host)); + } + assert_eq!(frames(&host), 3); + assert_eq!(host.app.tabs[0].history.undo_stack.len(), undo, "refusals record no undo step"); + for file in [&png, &jpeg, &tiff, &text] { + let _ = std::fs::remove_file(file); + } + } + + #[test] + fn audit_python_viewport_ids_and_scale_over_real_ipc() { + let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { + return; + }; + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let paper = host.document().block_records.iter().find(|r| r.is_paper_space()).map(|r| r.handle).unwrap(); + let layout = host.document().objects.values().find_map(|o| match o { + acadrust::objects::ObjectType::Layout(l) if l.block_record == paper => Some(l.name.clone()), _ => None }).unwrap(); + host.app.tabs[0].scene.set_current_layout(layout); + let process = ocs_plugin_api::process::PluginProcess::spawn( + std::path::Path::new(&plugin_path), &mut host, crate::plugin::v4_support::notification_handler(), + ).unwrap(); + let dispatch = |host: &mut HostSession<'_>, command: &str| { + assert!(process.dispatch(host, command, &mut |_| {}).expect("Python dispatch")); + }; + let last = |host: &HostSession<'_>| host.app.command_line.history.last().unwrap().text.clone(); + let viewports = |host: &HostSession<'_>| { + let mut list: Vec<_> = host.document().entities().filter_map(|e| match e { + EntityType::Viewport(v) => Some((v.common.handle, v.id)), _ => None }).collect(); + list.sort_by_key(|(h, _)| h.value()); + list + }; + let create = |width: f64, height: f64, view_height: f64| format!( + "PY_EVAL ocs.active_document.create_entity('Viewport', owner_handle={}, center=(50, 50, 0), width={width}, height={height}, view_height={view_height}).handle", paper.value()); + dispatch(&mut host, &create(80.0, 60.0, 50.0)); + dispatch(&mut host, &create(40.0, 30.0, 10.0)); + dispatch(&mut host, &create(20.0, 20.0, 20.0)); + let list = viewports(&host); + assert_eq!(list.len(), 3, "{}", last(&host)); + let ids: Vec = list.iter().map(|(_, id)| *id).collect(); + assert_eq!(ids, vec![2, 3, 4], "scripted viewports get unique ids of at least 2: {ids:?}"); + for (handle, expected) in [(list[0].0, 60.0 / 50.0), (list[1].0, 30.0 / 10.0), (list[2].0, 1.0)] { + let Some(EntityType::Viewport(v)) = host.document().get_entity(handle) else { unreachable!() }; + assert!((v.custom_scale - expected).abs() < 1e-12, "custom_scale {} vs {expected}", v.custom_scale); + assert!(!host.app.tabs[0].scene.wire_models_for(&[handle]).is_empty(), "viewport {handle:?} draws a frame"); + } + // The scale follows the geometry; it cannot be set directly. + let second = list[0].0; + dispatch(&mut host, &format!("PY_EVAL ocs.update_many('Zoom', [{{'handle':{}, 'view_height':25.0}}])", second.value())); + let Some(EntityType::Viewport(zoomed)) = host.document().get_entity(second) else { unreachable!() }; + assert!((zoomed.custom_scale - 60.0 / 25.0).abs() < 1e-12, "{}", last(&host)); + assert_eq!(zoomed.id, 2, "an edit keeps the id"); + dispatch(&mut host, &format!("PY_EVAL ocs.update_many('Resize', [{{'handle':{}, 'height':90.0}}])", second.value())); + let Some(EntityType::Viewport(resized)) = host.document().get_entity(second) else { unreachable!() }; + assert!((resized.custom_scale - 90.0 / 25.0).abs() < 1e-12); + let before = host.document().get_entity(second).unwrap().clone(); + dispatch(&mut host, &format!("PY_EVAL ocs.update_many('Scale', [{{'handle':{}, 'custom_scale':3.0}}])", second.value())); + assert!(last(&host).contains("read-only"), "{}", last(&host)); + assert_eq!(host.document().get_entity(second), Some(&before)); + // Control: the engine's default id 0 is not invisible here (there is no + // sheet viewport to confuse it with), so the derived id matters for + // uniqueness, not for visibility. + let mut bare = acadrust::entities::Viewport::new(); + bare.common.owner_handle = paper; + bare.width = 40.0; + bare.height = 30.0; + let bare = host.document_mut().add_entity(EntityType::Viewport(bare)).unwrap(); + assert_eq!(host.document().get_entity(bare).map(|e| match e { EntityType::Viewport(v) => v.id, _ => -1 }), Some(0)); + assert!(!host.app.tabs[0].scene.wire_models_for(&[bare]).is_empty()); + } + + /// Evidence for the RasterImage ledger row: what a save does with a bare + /// image (OCS's native IMAGE command) and with a definition-linked one. + #[test] + #[ignore = "exploratory evidence"] + fn spike_raster_image_definition_persistence() { + let dir = std::env::temp_dir(); + let path = dir.join("ocs_spike_image.png").to_string_lossy().into_owned(); + image::RgbaImage::from_pixel(8, 4, image::Rgba([1, 2, 3, 255])).save(&path).unwrap(); + for linked in [false, true] { + let mut doc = acadrust::CadDocument::new(); + let mut img = acadrust::entities::RasterImage::with_size(&path, acadrust::types::Vector3::ZERO, 8.0, 4.0, 16.0, 8.0); + if linked { + let h = doc.allocate_handle(); + let mut def = acadrust::objects::ImageDefinition::with_dimensions(path.clone(), 8, 4); + def.handle = h; + def.is_loaded = true; + doc.objects.insert(h, acadrust::objects::ObjectType::ImageDefinition(def)); + img.definition_handle = Some(h); + } + let handle = doc.add_entity(EntityType::RasterImage(img)).unwrap(); + for (label, document) in [ + ("DWG", crate::io::load_bytes("i.dwg", acadrust::DwgWriter::write_to_vec(&doc).unwrap()).unwrap()), + ("DXF", crate::io::load_bytes("i.dxf", acadrust::DxfWriter::new(&doc).write_to_vec().unwrap()).unwrap()), + ] { + let entity = document.get_entity(handle); + let defs = document.objects.values().filter(|o| matches!(o, acadrust::objects::ObjectType::ImageDefinition(_))).count(); + let reactors = document.objects.values().filter(|o| matches!(o, acadrust::objects::ObjectType::ImageDefinitionReactor(_))).count(); + let dicts = document.objects.values().filter(|o| matches!(o, acadrust::objects::ObjectType::Dictionary(d) if d.entries.iter().any(|(k, _)| k.contains("IMAGE")))).count(); + match entity { + Some(EntityType::RasterImage(i)) => eprintln!("SPIKE linked={linked} {label}: file_path_ok={} definition={:?} reactor={:?} defs={defs} reactors={reactors} image_dicts={dicts}", + i.file_path == path, i.definition_handle.map(|h| h.value()), i.definition_reactor_handle.map(|h| h.value())), + other => eprintln!("SPIKE linked={linked} {label}: came back as {:?}", other.map(|e| format!("{e:?}").chars().take(24).collect::())), + } + } + } + } + + /// Layer table through Python over the real runner: create, modify, rename, + /// delete and make current, with refusals that leave the drawing untouched, + /// one undo step per change, and DWG/DXF persistence of every property. + #[test] + fn audit_python_layer_table_over_real_ipc() { + use acadrust::types::{Color, LineWeight}; + let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { + return; + }; + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let process = ocs_plugin_api::process::PluginProcess::spawn( + std::path::Path::new(&plugin_path), &mut host, crate::plugin::v4_support::notification_handler(), + ).unwrap(); + let dir = std::env::temp_dir(); + let run = |host: &mut HostSession<'_>, tag: &str, body: &str| { + let script = dir.join(format!("ocs_layers_{tag}_{}.py", std::process::id())); + std::fs::write(&script, format!(concat!( + "def P(x, y, z): return {{'x': x, 'y': y, 'z': z}}\n", + "doc = ocs.active_document\nL = doc.layers\n", + "refused, accepted = [], []\n", + "def check(tag, fn):\n", + " try:\n fn()\n except (RuntimeError, TypeError, ValueError):\n refused.append(tag)\n", + " else:\n accepted.append(tag)\n", + "try:\n{body}\n", + "except Exception as error:\n", + " accepted.append('SCRIPTERROR_' + ''.join(c if c.isalnum() else '_' for c in str(error))[:120])\n", + "L.create('REPORT ' + ' '.join(refused) + ' ~ ' + ' '.join(accepted))\n", + ), body = body.lines().map(|line| format!(" {line}")).collect::>().join("\n"))).unwrap(); + assert!(process.dispatch(host, &format!("PY_RUN {}", script.display()), &mut |_| {}).unwrap()); + let _ = std::fs::remove_file(&script); + }; + let report = |document: &CadDocument| -> (Vec, Vec) { + let name = document.layers.iter().map(|l| l.name.clone()).filter(|n| n.starts_with("REPORT")).last() + .expect("script report layer"); + let (refused, accepted) = name["REPORT".len()..].split_once('~').unwrap(); + let words = |s: &str| s.split_whitespace().map(str::to_owned).collect::>(); + (words(refused), words(accepted)) + }; + + // Build: two layers with full properties, a modify, an entity, a rename, and the refusals. + run(&mut host, "build", r#" +L.create('Walls', color=1, lineweight=50, description='load bearing', transparency=30) +L.create('Grid', color=(10, 200, 30), linetype='Continuous', off=True, plottable=False) +L.modify('Walls', color=5, locked=True) +doc.create_entity('Line', start=P(0, 0, 0), end=P(1, 0, 0), layer='Walls') +L.rename('Walls', 'Structure') +check('dup', lambda: L.create('structure')) +check('badname', lambda: L.create('a = document.layers.iter().map(|l| l.name.as_str()).collect(); + assert!(!names.iter().any(|n| n.eq_ignore_ascii_case("Walls")), "{label}: old name gone: {names:?}"); + assert!(!names.iter().any(|n| n.starts_with('C') && n.len() == 2), "{label}: a refused request left a layer: {names:?}"); + let structure = document.layers.get("Structure").unwrap_or_else(|| panic!("{label}: Structure missing")); + assert_eq!(structure.color, Color::Index(5), "{label}"); + assert_eq!(structure.line_weight, LineWeight::Value(50), "{label}"); + assert!(structure.flags.locked, "{label}"); + assert_eq!(structure.description, "load bearing", "{label}"); + assert_ne!(structure.transparency, acadrust::types::Transparency::ByLayer, "{label}: transparency"); + let grid = document.layers.get("Grid").unwrap_or_else(|| panic!("{label}: Grid missing")); + assert_eq!(grid.color, Color::Rgb { r: 10, g: 200, b: 30 }, "{label}"); + assert!(grid.flags.off && !grid.is_plottable, "{label}"); + assert_eq!(grid.line_type.to_uppercase(), "CONTINUOUS", "{label}"); + let line = document.entities().find_map(|e| match e { EntityType::Line(l) => Some(l.clone()), _ => None }) + .unwrap_or_else(|| panic!("{label}: line missing")); + assert_eq!(line.common.layer, "Structure", "{label}: entity followed the rename"); + }; + check_state("live", host.document()); + let dwg = crate::io::load_bytes("layers.dwg", acadrust::DwgWriter::write_to_vec(host.document()).unwrap()).unwrap(); + let dxf = crate::io::load_bytes("layers.dxf", acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()).unwrap(); + check_state("DWG", &dwg); + check_state("DXF", &dxf); + + // Destroy: current layer, delete with and without objects. + run(&mut host, "destroy", r#" +L.set_current('Grid') +check('delcurrent2', lambda: L.delete('Grid')) +check('freezecurrent2', lambda: L.modify('Grid', frozen=True)) +L.set_current('0') +L.delete('Grid') +check('delused2', lambda: L.delete('Structure')) +L.delete('Structure', erase_objects=True) +"#); + let (refused, accepted) = report(host.document()); + assert!(accepted.is_empty(), "{accepted:?}"); + assert_eq!(refused, vec!["delcurrent2", "freezecurrent2", "delused2"]); + let document = host.document(); + assert!(document.layers.get("Grid").is_none() && document.layers.get("Structure").is_none()); + assert_eq!(document.header.current_layer_name, "0"); + assert!(!document.entities().any(|e| matches!(e, EntityType::Line(_))), "erase_objects erased the line"); + + // U: the last two steps are the report layer and the erasing delete; + // undoing them restores the deleted layer and its object together. + drop(process); + drop(host); + app.finish_pending_history(0); + let before = app.tabs[0].history.undo_stack.len(); + app.undo_steps(2); + assert_eq!(app.tabs[0].history.undo_stack.len(), before - 2); + let document = &app.tabs[0].scene.document; + assert!(document.layers.get("Structure").is_some(), "undo restored the layer"); + assert!(document.entities().any(|e| matches!(e, EntityType::Line(_))), "undo restored the line"); + } + + /// Text and dimension styles through Python over the real runner: create, + /// modify, copy, rename (references follow), delete and make current, with + /// refusals that change nothing, DWG/DXF persistence and one-step undo. + #[test] + fn audit_python_text_and_dim_styles_over_real_ipc() { + let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { + return; + }; + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let process = ocs_plugin_api::process::PluginProcess::spawn( + std::path::Path::new(&plugin_path), &mut host, crate::plugin::v4_support::notification_handler(), + ).unwrap(); + let dir = std::env::temp_dir(); + let run = |host: &mut HostSession<'_>, tag: &str, body: &str| { + let script = dir.join(format!("ocs_styles_{tag}_{}.py", std::process::id())); + std::fs::write(&script, format!(concat!( + "doc = ocs.active_document\nS = doc.text_styles\nD = doc.dim_styles\nL = doc.layers\n", + "refused, accepted = [], []\n", + "def check(tag, fn):\n", + " try:\n fn()\n except (RuntimeError, TypeError, ValueError):\n refused.append(tag)\n", + " else:\n accepted.append(tag)\n", + "try:\n{body}\n", + "except Exception as error:\n", + " accepted.append('SCRIPTERROR_' + ''.join(c if c.isalnum() else '_' for c in str(error))[:120])\n", + "L.create('REPORT ' + str(len(refused)) + ' ~ ' + ' '.join(accepted))\n", + ), body = body.lines().map(|line| format!(" {line}")).collect::>().join("\n"))).unwrap(); + assert!(process.dispatch(host, &format!("PY_RUN {}", script.display()), &mut |_| {}).unwrap()); + let _ = std::fs::remove_file(&script); + }; + let report = |document: &CadDocument| -> (usize, Vec) { + let name = document.layers.iter().map(|l| l.name.clone()).filter(|n| n.starts_with("REPORT")).last() + .expect("script report layer"); + let (refused, accepted) = name["REPORT".len()..].split_once('~').unwrap(); + (refused.trim().parse().unwrap(), accepted.split_whitespace().map(str::to_owned).collect()) + }; + + run(&mut host, "build", r#" +S.create('Title', height=5, width_factor=0.8, oblique=15, font='romans', big_font='bigfont', backward=True, annotative=True) +S.create('Notes', font='arial.ttf') +S.modify('Title', height=6, upside_down=True) +D.create('Metric', dimscale=2, dimtxt=3.5, dimasz=2.5, dimtxsty='Title') +D.create('Metric2', copy_from='Metric', dimtxt=4) +D.modify('Metric', dimscale=3) +S.rename('Notes', 'Remarks') +S.rename('Title', 'Heading') +check('t_dup', lambda: S.create('heading')) +check('t_badname', lambda: S.create('a|b')) +check('t_height', lambda: S.create('T1', height=-1)) +check('t_width0', lambda: S.create('T2', width_factor=0)) +check('t_oblique', lambda: S.create('T3', oblique=89)) +check('t_nofont', lambda: S.create('T4', font='')) +check('t_unknown', lambda: S.create('T5', fnt='x')) +check('t_modmissing', lambda: S.modify('Nope', height=1)) +check('t_modnone', lambda: S.modify('Heading')) +check('t_renameStandard', lambda: S.rename('Standard', 'Std2')) +check('t_rename_case', lambda: S.rename('Heading', 'HEADING')) +check('t_rename_clash', lambda: S.rename('Remarks', 'heading')) +check('t_delStandard', lambda: S.delete('Standard')) +check('t_delInUse', lambda: S.delete('Heading')) +check('t_current_missing', lambda: S.set_current('Nope')) +check('d_dup', lambda: D.create('metric')) +check('d_unknown', lambda: D.create('D1', dimbogus=1)) +check('d_handle', lambda: D.create('D2', dimtxsty_handle=5)) +check('d_name', lambda: D.modify('Metric', name='X')) +check('d_type', lambda: D.create('D3', dimscale='big')) +check('d_scale0', lambda: D.create('D4', dimscale=0)) +check('d_txt', lambda: D.create('D5', dimtxt=-1)) +check('d_txsty', lambda: D.create('D6', dimtxsty='Nope')) +check('d_copy_missing', lambda: D.create('D7', copy_from='Nope')) +check('d_delStandard', lambda: D.delete('Standard')) +check('d_modmissing', lambda: D.modify('Nope', dimscale=2)) +"#); + let (refused, accepted) = report(host.document()); + assert!(accepted.is_empty(), "accepted invalid requests: {accepted:?}"); + assert_eq!(refused, 26); + + let check_state = |label: &str, document: &CadDocument| { + assert!(document.text_styles.get("Title").is_none() && document.text_styles.get("Notes").is_none(), "{label}: old names gone"); + let title = document.text_styles.get("Heading").unwrap_or_else(|| panic!("{label}: Heading missing")); + assert_eq!(title.height, 6.0, "{label}"); + assert!((title.width_factor - 0.8).abs() < 1e-9, "{label}"); + assert!((title.oblique_angle - 15f64.to_radians()).abs() < 1e-6, "{label}: oblique {}", title.oblique_angle); + assert_eq!(title.font_file.to_lowercase(), "romans", "{label}"); + assert_eq!(title.big_font_file.to_lowercase(), "bigfont", "{label}"); + if label == "DXF" { + // BLOCKER (cadcodec): the DXF STYLE writer hard-codes group 71 to 0, so the + // backward and upside-down generation flags are lost on a DXF save. + assert!(!title.flags.backward && !title.flags.upside_down, "{label}: cadcodec now keeps the flags; flip this canary"); + } else { + assert!(title.flags.backward && title.flags.upside_down, "{label}"); + } + let remarks = document.text_styles.get("Remarks").unwrap_or_else(|| panic!("{label}: Remarks missing")); + assert_eq!(remarks.font_file.to_lowercase(), "arial.ttf", "{label}: {remarks:?}"); + let metric = document.dim_styles.get("Metric").unwrap_or_else(|| panic!("{label}: Metric missing")); + assert_eq!((metric.dimscale, metric.dimtxt, metric.dimasz), (3.0, 3.5, 2.5), "{label}"); + if label == "DXF" { + // BLOCKER (cadcodec): the DXF DIMSTYLE reader keeps only the text-style handle + // (group 340) and never resolves `dimtxsty` from it, so the name reopens as + // "Standard". The handle survives and identifies the right style. + assert_eq!(metric.dimtxsty, "Standard", "{label}: cadcodec now resolves the name; flip this canary"); + assert_eq!(metric.dimtxsty_handle, title.handle, "{label}: text style link by handle"); + } else { + assert_eq!(metric.dimtxsty, "Heading", "{label}: dimtxsty followed the rename"); + } + let copy = document.dim_styles.get("Metric2").unwrap_or_else(|| panic!("{label}: Metric2 missing")); + assert_eq!((copy.dimscale, copy.dimtxt), (2.0, 4.0), "{label}: copied before Metric changed"); + }; + check_state("live", host.document()); + let live_title = host.document().text_styles.get("Heading").unwrap().clone(); + assert!(live_title.annotative, "annotative is kept in the live style"); + assert_eq!(host.document().dim_styles.get("Metric").unwrap().dimtxsty_handle, live_title.handle, "dim style links the text style handle"); + let dwg = crate::io::load_bytes("styles.dwg", acadrust::DwgWriter::write_to_vec(host.document()).unwrap()).unwrap(); + let dxf = crate::io::load_bytes("styles.dxf", acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()).unwrap(); + check_state("DWG", &dwg); + check_state("DXF", &dxf); + + run(&mut host, "destroy", r#" +S.set_current('Remarks') +D.set_current('Metric2') +check('t_delcurrent', lambda: S.delete('Remarks')) +check('d_delcurrent', lambda: D.delete('Metric2')) +check('d_delInUseByCurrentOnly', lambda: D.delete('Metric2')) +S.set_current('Standard') +D.set_current('Standard') +S.delete('Remarks') +D.delete('Metric2') +D.delete('Metric') +S.delete('Heading') +"#); + let (refused, accepted) = report(host.document()); + assert!(accepted.is_empty(), "{accepted:?}"); + assert_eq!(refused, 3); + let document = host.document(); + for name in ["Heading", "Remarks"] { + assert!(document.text_styles.get(name).is_none(), "{name} deleted"); + } + for name in ["Metric", "Metric2"] { + assert!(document.dim_styles.get(name).is_none(), "{name} deleted"); + } + assert_eq!(document.header.current_text_style_name, "Standard"); + assert_eq!(document.header.current_dimstyle_name, "Standard"); + + // U: the final report layer and the four deletes are five steps; undoing + // them all brings the styles back. + drop(process); + drop(host); + app.finish_pending_history(0); + app.undo_steps(5); + let document = &app.tabs[0].scene.document; + assert!(document.text_styles.get("Heading").is_some() && document.text_styles.get("Remarks").is_some(), "undo restored the text styles"); + assert!(document.dim_styles.get("Metric").is_some() && document.dim_styles.get("Metric2").is_some(), "undo restored the dimension styles"); + } + + /// Block definitions through Python over the real runner: create from + /// entities (with and without erasing them), modify, rename with inserts + /// following, guarded delete, DWG/DXF persistence and undo. + #[test] + fn audit_python_blocks_over_real_ipc() { + let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { + return; + }; + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let process = ocs_plugin_api::process::PluginProcess::spawn( + std::path::Path::new(&plugin_path), &mut host, crate::plugin::v4_support::notification_handler(), + ).unwrap(); + let dir = std::env::temp_dir(); + let run = |host: &mut HostSession<'_>, tag: &str, body: &str| { + let script = dir.join(format!("ocs_blocks_{tag}_{}.py", std::process::id())); + std::fs::write(&script, format!(concat!( + "def P(x, y, z): return {{'x': x, 'y': y, 'z': z}}\n", + "doc = ocs.active_document\nB = doc.blocks\nL = doc.layers\n", + "refused, accepted = [], []\n", + "def check(tag, fn):\n", + " try:\n fn()\n except (RuntimeError, TypeError, ValueError):\n refused.append(tag)\n", + " else:\n accepted.append(tag)\n", + "try:\n{body}\n", + "except Exception as error:\n", + " accepted.append('SCRIPTERROR_' + ''.join(c if c.isalnum() else '_' for c in str(error))[:120])\n", + "L.create('REPORT ' + str(len(refused)) + ' ~ ' + ' '.join(accepted))\n", + ), body = body.lines().map(|line| format!(" {line}")).collect::>().join("\n"))).unwrap(); + assert!(process.dispatch(host, &format!("PY_RUN {}", script.display()), &mut |_| {}).unwrap()); + let _ = std::fs::remove_file(&script); + }; + let report = |document: &CadDocument| -> (usize, Vec) { + let name = document.layers.iter().map(|l| l.name.clone()).filter(|n| n.starts_with("REPORT")).last() + .expect("script report layer"); + let (refused, accepted) = name["REPORT".len()..].split_once('~').unwrap(); + (refused.trim().parse().unwrap(), accepted.split_whitespace().map(str::to_owned).collect()) + }; + + run(&mut host, "build", r#" +line = doc.create_entity('Line', start=P(0, 0, 0), end=P(4, 0, 0)) +circle = doc.create_entity('Circle', center=P(2, 2, 0), radius=1) +B.create('Widget', [line, circle], base_point=(1, 1, 0), description='a widget') +B.create('Widget2', [line.handle], erase_originals=True) +doc.create_entity('Insert', block_name='Widget', insert_point=P(10, 0, 0)) +doc.create_entity('Insert', block_name='widget', insert_point=P(20, 0, 0)) +B.modify('Widget', explodable=False, description='a gadget') +B.rename('Widget', 'Gadget') +member = B['Gadget']['entities'][0]['handle'] +check('dup', lambda: B.create('gadget', [circle])) +check('empty', lambda: B.create('E1', [])) +check('badname', lambda: B.create('a Vec { + let record = document.block_records.get(name).unwrap_or_else(|| panic!("{label}: block {name} missing")); + document.entities().filter(|e| e.common().owner_handle == record.handle + && !matches!(e, EntityType::Block(_) | EntityType::BlockEnd(_))).cloned().collect() + }; + let gadget = members("Gadget"); + assert_eq!(gadget.len(), 2, "{label}: Gadget members"); + let line = gadget.iter().find_map(|e| match e { EntityType::Line(l) => Some(l.clone()), _ => None }).unwrap_or_else(|| panic!("{label}: line")); + assert_eq!((line.start.x, line.start.y, line.end.x, line.end.y), (-1.0, -1.0, 3.0, -1.0), "{label}: shifted by -base"); + let circle = gadget.iter().find_map(|e| match e { EntityType::Circle(c) => Some(c.clone()), _ => None }).unwrap_or_else(|| panic!("{label}: circle")); + assert_eq!((circle.center.x, circle.center.y, circle.radius), (1.0, 1.0, 1.0), "{label}"); + let record = document.block_records.get("Gadget").unwrap(); + assert_eq!((record.base_point.x, record.base_point.y), (1.0, 1.0), "{label}: base point"); + assert!(!record.explodable, "{label}: explodable"); + if label == "DXF" { + // BLOCKER (cadcodec): the DXF BLOCK_RECORD reader/writer does not carry the + // description, so it is lost on a DXF save (DWG keeps it). + assert_eq!(record.description, "", "{label}: cadcodec now keeps the description; flip this canary"); + } else { + assert_eq!(record.description, "a gadget", "{label}: description"); + } + assert_eq!(members("Widget2").len(), 1, "{label}: Widget2 members"); + let inserts: Vec<_> = document.entities().filter_map(|e| match e { EntityType::Insert(i) => Some(i.block_name.clone()), _ => None }).collect(); + assert_eq!(inserts.iter().filter(|n| n.as_str() == "Gadget").count(), 2, "{label}: inserts follow the rename: {inserts:?}"); + // originals: Widget kept the circle and line in model space, Widget2 erased its line copy's source + let canvas_kinds: Vec<_> = document.entities().filter(|e| { + document.block_records.iter().any(|r| r.handle == e.common().owner_handle && r.is_layout()) + }).map(|e| format!("{:?}", e).chars().take(6).collect::()).collect(); + assert_eq!(canvas_kinds.iter().filter(|k| k.starts_with("Circle")).count(), 1, "{label}: circle original kept: {canvas_kinds:?}"); + assert_eq!(canvas_kinds.iter().filter(|k| k.starts_with("Line(")).count(), 0, "{label}: line original erased: {canvas_kinds:?}"); + }; + check_state("live", host.document()); + assert_eq!(host.document().block_records.get("Gadget").unwrap().description, "a gadget"); + let dwg = crate::io::load_bytes("blocks.dwg", acadrust::DwgWriter::write_to_vec(host.document()).unwrap()).unwrap(); + let dxf = crate::io::load_bytes("blocks.dxf", acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()).unwrap(); + check_state("DWG", &dwg); + check_state("DXF", &dxf); + + run(&mut host, "destroy", r#" +for entity in list(doc.entities): + if entity.kind == 'Insert': + doc.delete_entity(entity) +B.delete('Gadget') +B.delete('Widget2') +"#); + let (refused, accepted) = report(host.document()); + assert!(accepted.is_empty(), "{accepted:?}"); + assert_eq!(refused, 0); + let document = host.document(); + assert!(document.block_records.get("Gadget").is_none() && document.block_records.get("Widget2").is_none()); + assert!(!document.entities().any(|e| matches!(e, EntityType::Line(_))), "block members were removed with their definitions"); + assert_eq!(document.entities().filter(|e| matches!(e, EntityType::Block(_) | EntityType::BlockEnd(_))).count(), 0); + + // U: the report layer and both block deletes; undoing them restores the definitions. + drop(process); + drop(host); + app.finish_pending_history(0); + app.undo_steps(3); + let document = &app.tabs[0].scene.document; + let members = |name: &str| document.block_records.get(name).map(|r| r.entity_handles.len()); + assert_eq!(members("Gadget"), Some(2), "undo restored Gadget with its members"); + assert_eq!(members("Widget2"), Some(1), "undo restored Widget2"); + } + + /// Block contents through Python over the real runner: add entities + /// (including an attribute definition) to a definition, edit and delete them + /// with the ordinary entity API, refuse bad requests, persist through + /// DWG/DXF and undo. + #[test] + fn audit_python_block_contents_over_real_ipc() { + let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { + return; + }; + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let process = ocs_plugin_api::process::PluginProcess::spawn( + std::path::Path::new(&plugin_path), &mut host, crate::plugin::v4_support::notification_handler(), + ).unwrap(); + let dir = std::env::temp_dir(); + let run = |host: &mut HostSession<'_>, tag: &str, body: &str| { + let script = dir.join(format!("ocs_bcontents_{tag}_{}.py", std::process::id())); + std::fs::write(&script, format!(concat!( + "def P(x, y, z): return {{'x': x, 'y': y, 'z': z}}\n", + "doc = ocs.active_document\nB = doc.blocks\nL = doc.layers\n", + "refused, accepted = [], []\n", + "def check(tag, fn):\n", + " try:\n fn()\n except (RuntimeError, TypeError, ValueError):\n refused.append(tag)\n", + " else:\n accepted.append(tag)\n", + "try:\n{body}\n", + "except Exception as error:\n", + " accepted.append('SCRIPTERROR_' + ''.join(c if c.isalnum() else '_' for c in str(error))[:120])\n", + "L.create('REPORT ' + str(len(refused)) + ' ~ ' + ' '.join(accepted))\n", + ), body = body.lines().map(|line| format!(" {line}")).collect::>().join("\n"))).unwrap(); + assert!(process.dispatch(host, &format!("PY_RUN {}", script.display()), &mut |_| {}).unwrap()); + let _ = std::fs::remove_file(&script); + }; + let report = |document: &CadDocument| -> (usize, Vec) { + let name = document.layers.iter().map(|l| l.name.clone()).filter(|n| n.starts_with("REPORT")).last() + .expect("script report layer"); + let (refused, accepted) = name["REPORT".len()..].split_once('~').unwrap(); + (refused.trim().parse().unwrap(), accepted.split_whitespace().map(str::to_owned).collect()) + }; + + run(&mut host, "build", r#" +seed = doc.create_entity('Circle', center=P(0, 0, 0), radius=1) +B.create('Part', [seed], erase_originals=True) +line = doc.create_entity('Line', start=P(0, 0, 0), end=P(5, 0, 0), block='Part') +arc = doc.create_entity('Arc', center=P(0, 0, 0), radius=5.0, start_angle=0.0, end_angle=1.5, block='Part') +tag = doc.create_entity('AttributeDefinition', block='Part', tag='PART_NO', prompt='Part number', default_value='PN-001', insertion_point=P(1, 2, 0), height=2.5) +with doc.transaction('Lengthen'): + line.end = (8, 0, 0) +doc.delete_entity(arc) +B.create('Outer', [doc.create_entity('Circle', center=P(0, 0, 0), radius=1)], erase_originals=True) +doc.create_entity('Insert', block_name='Part', insert_point=P(0, 0, 0), block='Outer') +check('missing_block', lambda: doc.create_entity('Line', start=P(0, 0, 0), end=P(1, 0, 0), block='Nope')) +check('layout_block', lambda: doc.create_entity('Line', start=P(0, 0, 0), end=P(1, 0, 0), block='*Model_Space')) +check('zero_circle', lambda: doc.create_entity('Circle', center=P(1, 1, 0), radius=0, block='Part')) +check('viewport', lambda: doc.create_entity('Viewport', block='Part')) +check('bad_tag', lambda: doc.create_entity('AttributeDefinition', block='Part', tag=' ', prompt='p', default_value='', insertion_point=P(0, 0, 0), height=1)) +check('bad_height', lambda: doc.create_entity('AttributeDefinition', block='Part', tag='T', prompt='p', default_value='', insertion_point=P(0, 0, 0), height=0)) +check('self_insert', lambda: doc.create_entity('Insert', block_name='Part', insert_point=P(0, 0, 0), block='Part')) +check('cycle', lambda: doc.create_entity('Insert', block_name='Outer', insert_point=P(0, 0, 0), block='Part')) +check('unknown_insert_block', lambda: doc.create_entity('Insert', block_name='Ghost', insert_point=P(0, 0, 0), block='Part')) +"#); + let (refused, accepted) = report(host.document()); + assert!(accepted.is_empty(), "accepted invalid requests: {accepted:?}"); + assert_eq!(refused, 9); + + let check_state = |label: &str, document: &CadDocument| { + let record = document.block_records.get("Part").unwrap_or_else(|| panic!("{label}: Part missing")); + let members: Vec = document.entities() + .filter(|e| e.common().owner_handle == record.handle && !matches!(e, EntityType::Block(_) | EntityType::BlockEnd(_))) + .cloned().collect(); + assert_eq!(members.len(), 3, "{label}: circle, line, attribute definition (the arc was deleted): {:?}", + members.iter().map(|e| format!("{e:?}").chars().take(10).collect::()).collect::>()); + let line = members.iter().find_map(|e| match e { EntityType::Line(l) => Some(l.clone()), _ => None }).unwrap_or_else(|| panic!("{label}: line")); + assert_eq!((line.end.x, line.end.y), (8.0, 0.0), "{label}: edited inside the block"); + assert!(members.iter().any(|e| matches!(e, EntityType::Circle(_))), "{label}: circle from the seed"); + let attdef = members.iter().find_map(|e| match e { EntityType::AttributeDefinition(a) => Some(a.clone()), _ => None }).unwrap_or_else(|| panic!("{label}: attdef")); + assert_eq!((attdef.tag.as_str(), attdef.prompt.as_str(), attdef.default_value.as_str()), ("PART_NO", "Part number", "PN-001"), "{label}"); + assert!(!members.iter().any(|e| matches!(e, EntityType::Arc(_))), "{label}: arc deleted"); + let outer = document.block_records.get("Outer").unwrap_or_else(|| panic!("{label}: Outer missing")); + let nested = document.entities().any(|e| matches!(e, EntityType::Insert(i) if i.block_name == "Part" && i.common.owner_handle == outer.handle)); + assert!(nested, "{label}: Part is inserted inside Outer"); + }; + check_state("live", host.document()); + let dwg = crate::io::load_bytes("bc.dwg", acadrust::DwgWriter::write_to_vec(host.document()).unwrap()).unwrap(); + let dxf = crate::io::load_bytes("bc.dxf", acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()).unwrap(); + check_state("DWG", &dwg); + check_state("DXF", &dxf); + + // U: one more add, then the report layer; undoing both leaves the block as before. + run(&mut host, "extra", "doc.create_entity('Circle', center=P(9, 9, 0), radius=2, block='Part')"); + assert_eq!(report(host.document()).1.len(), 0); + { + let document = host.document(); + let record = document.block_records.get("Part").unwrap(); + let live: Vec<_> = record.entity_handles.iter().map(|h| (h.value(), document.get_entity(*h).is_some())).collect(); + assert_eq!(record.entity_handles.len(), 4, "record entity_handles (handle, exists): {live:?}"); + } + drop(process); + drop(host); + app.finish_pending_history(0); + app.undo_steps(2); + // Membership bookkeeping is intrinsic to an add and stays listed across an entity-delta + // undo (see `Scene::add_entity_internal`), so count the entities that really exist. + let document = &app.tabs[0].scene.document; + let part = document.block_records.get("Part").unwrap(); + let existing = part.entity_handles.iter().filter(|h| document.get_entity(**h).is_some()).count(); + assert_eq!(existing, 3, "undo removed the added member"); + } + + /// Linetypes and layouts through Python over the real runner: create, + /// modify, rename (layers follow), guarded delete, page setup, entities + /// placed on a layout, DWG/DXF persistence and undo. + #[test] + fn audit_python_linetypes_and_layouts_over_real_ipc() { + let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { + return; + }; + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let process = ocs_plugin_api::process::PluginProcess::spawn( + std::path::Path::new(&plugin_path), &mut host, crate::plugin::v4_support::notification_handler(), + ).unwrap(); + let dir = std::env::temp_dir(); + let run = |host: &mut HostSession<'_>, tag: &str, body: &str| { + let script = dir.join(format!("ocs_ltlay_{tag}_{}.py", std::process::id())); + std::fs::write(&script, format!(concat!( + "def P(x, y, z): return {{'x': x, 'y': y, 'z': z}}\n", + "doc = ocs.active_document\nLT = doc.linetypes\nLY = doc.layouts\nL = doc.layers\n", + "refused, accepted = [], []\n", + "def check(tag, fn):\n", + " try:\n fn()\n except (RuntimeError, TypeError, ValueError):\n refused.append(tag)\n", + " else:\n accepted.append(tag)\n", + "try:\n{body}\n", + "except Exception as error:\n", + " accepted.append('SCRIPTERROR_' + ''.join(c if c.isalnum() else '_' for c in str(error))[:120])\n", + "L.create('REPORT ' + str(len(refused)) + ' ~ ' + ' '.join(accepted))\n", + ), body = body.lines().map(|line| format!(" {line}")).collect::>().join("\n"))).unwrap(); + assert!(process.dispatch(host, &format!("PY_RUN {}", script.display()), &mut |_| {}).unwrap()); + let _ = std::fs::remove_file(&script); + }; + let report = |document: &CadDocument| -> (usize, Vec) { + let name = document.layers.iter().map(|l| l.name.clone()).filter(|n| n.starts_with("REPORT")).last() + .expect("script report layer"); + let (refused, accepted) = name["REPORT".len()..].split_once('~').unwrap(); + (refused.trim().parse().unwrap(), accepted.split_whitespace().map(str::to_owned).collect()) + }; + + run(&mut host, "build", r#" +LT.create('Bracket', [12, -3, 2, -3], 'Phantom test') +LT.create('Dots', [0, -2], 'dots') +LT.modify('Dots', pattern=[0, -4], description='sparse dots') +LT.create('Temp', [1, -1]) +L.create('Hidden2', linetype='Bracket') +L.create('Dotted2', linetype='Dots') +LT.rename('Dots', 'Sparse') +LY.create('Sheet1') +LY.create('Sheet2') +LY.set_page('Sheet1', paper_size=(420, 297), rotation=0, scale=(1, 50)) +LY.rename('Sheet2', 'Details') +LY.set_current('Sheet1') +doc.create_entity('Circle', center=P(50, 50, 0), radius=5) +LY.set_current('Model') +check('lt_dup', lambda: LT.create('bracket', [1, -1])) +check('lt_standard_dup', lambda: LT.create('Dashed', [1, -1])) +check('lt_badname', lambda: LT.create('a|b', [1, -1])) +check('lt_empty', lambda: LT.create('X1', [])) +check('lt_single', lambda: LT.create('X2', [1])) +check('lt_nogap', lambda: LT.create('X3', [1, 2])) +check('lt_nodash', lambda: LT.create('X4', [-1, -2])) +check('lt_nonfinite', lambda: LT.create('X5', [1e999, -1])) +check('lt_toomany', lambda: LT.create('X6', [1, -1] * 7)) +check('lt_modnone', lambda: LT.modify('Bracket')) +check('lt_modmissing', lambda: LT.modify('Nope', description='x')) +check('lt_modcontinuous', lambda: LT.modify('Continuous', description='x')) +check('lt_renamecontinuous', lambda: LT.rename('Continuous', 'Solid2')) +check('lt_renameclash', lambda: LT.rename('Temp', 'BRACKET')) +check('lt_renamecase', lambda: LT.rename('Temp', 'TEMP')) +check('lt_delcontinuous', lambda: LT.delete('ByLayer')) +check('lt_delinuse', lambda: LT.delete('Bracket')) +check('lt_delmissing', lambda: LT.delete('Nope')) +check('ly_dup', lambda: LY.create('sheet1')) +check('ly_model', lambda: LY.create('Model')) +check('ly_badname', lambda: LY.create('a = phantom.elements.iter().map(|e| e.length).collect(); + assert_eq!(lengths, vec![12.0, -3.0, 2.0, -3.0], "{label}: pattern"); + assert_eq!(phantom.pattern_length, 20.0, "{label}"); + let sparse = document.line_types.get("Sparse").unwrap_or_else(|| panic!("{label}: Sparse missing")); + assert_eq!(sparse.elements.iter().map(|e| e.length).collect::>(), vec![0.0, -4.0], "{label}: modified pattern"); + assert_eq!(document.layers.get("Dotted2").unwrap().line_type, "Sparse", "{label}: layer followed the rename"); + assert_eq!(document.layers.get("Hidden2").unwrap().line_type, "Bracket", "{label}"); + let layouts = |name: &str| document.objects.values().find_map(|o| match o { + acadrust::objects::ObjectType::Layout(l) if l.name == name && !l.block_record.is_null() => Some(l.clone()), + _ => None, + }); + assert!(layouts("Sheet2").is_none(), "{label}: old layout name gone"); + let sheet = layouts("Sheet1").unwrap_or_else(|| panic!("{label}: Sheet1 missing")); + assert_eq!((sheet.paper_width, sheet.paper_height), (420.0, 297.0), "{label}: paper size"); + assert_eq!(sheet.plot_rotation, 0, "{label}: rotation"); + assert_eq!((sheet.plot_scale_numerator, sheet.plot_scale_denominator), (1.0, 50.0), "{label}: scale"); + assert!(layouts("Details").is_some(), "{label}: Details missing"); + let on_sheet = document.entities().filter(|e| e.common().owner_handle == sheet.block_record && matches!(e, EntityType::Circle(_))).count(); + assert_eq!(on_sheet, 1, "{label}: the circle was created on the sheet"); + assert!(!document.entities().any(|e| matches!(e, EntityType::Circle(_)) && document.block_records.iter().any(|r| r.name == "*Model_Space" && r.handle == e.common().owner_handle)), + "{label}: no circle in model space"); + }; + check_state("live", host.document()); + assert_eq!(host.app.tabs[0].scene.current_layout, "Model", "the script switched back to Model"); + let dwg = crate::io::load_bytes("ltl.dwg", acadrust::DwgWriter::write_to_vec(host.document()).unwrap()).unwrap(); + let dxf = crate::io::load_bytes("ltl.dxf", acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()).unwrap(); + check_state("DWG", &dwg); + check_state("DXF", &dxf); + + run(&mut host, "destroy", r#" +LY.set_current('Details') +LY.delete('Details') +LY.delete('Sheet1') +LT.delete('Temp') +"#); + let (refused, accepted) = report(host.document()); + assert!(accepted.is_empty(), "{accepted:?}"); + assert_eq!(refused, 0); + let document = host.document(); + assert!(document.line_types.get("Temp").is_none()); + assert!(!document.objects.values().any(|o| matches!(o, acadrust::objects::ObjectType::Layout(l) if l.name == "Sheet1" || l.name == "Details"))); + assert!(!document.entities().any(|e| matches!(e, EntityType::Circle(_))), "the sheet's circle went with its layout"); + assert_eq!(host.app.tabs[0].scene.current_layout, "Model", "deleting the current layout fell back to Model"); + + // U: the report layer and the three deletes; undoing them restores the layouts and the linetype. + drop(process); + drop(host); + app.finish_pending_history(0); + app.undo_steps(4); + let document = &app.tabs[0].scene.document; + assert!(document.line_types.get("Temp").is_some(), "undo restored the linetype"); + let names = app.tabs[0].scene.layout_names(); + assert!(names.contains(&"Sheet1".to_owned()) && names.contains(&"Details".to_owned()), "undo restored the layouts: {names:?}"); + assert!(document.entities().any(|e| matches!(e, EntityType::Circle(_))), "undo restored the sheet's circle"); + } + + /// The modify wrappers through Python over the real runner: each drives + /// the real OCS command and the resulting geometry is checked exactly. + #[test] + fn audit_python_modify_wrappers_over_real_ipc() { + // PEDIT's line-to-polyline conversion runs deep inside a nested command step; a debug + // build needs more than the 2 MiB a test thread gets (the application's main thread has 8 MiB). + std::thread::Builder::new() + .stack_size(64 * 1024 * 1024) + .spawn(modify_wrappers_audit_body) + .unwrap() + .join() + .unwrap(); + } + + fn modify_wrappers_audit_body() { + let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { + return; + }; + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let process = ocs_plugin_api::process::PluginProcess::spawn( + std::path::Path::new(&plugin_path), &mut host, crate::plugin::v4_support::notification_handler(), + ).unwrap(); + let dir = std::env::temp_dir(); + let run = |host: &mut HostSession<'_>, tag: &str, body: &str| { + let script = dir.join(format!("ocs_modify_{tag}_{}.py", std::process::id())); + std::fs::write(&script, format!(concat!( + "def P(x, y, z=0.0): return {{'x': x, 'y': y, 'z': z}}\n", + "doc = ocs.active_document\nM = doc.modify\nL = doc.layers\n", + "refused, failed = [], []\n", + "def check(tag, fn):\n", + " try:\n fn()\n except (RuntimeError, TypeError, ValueError):\n refused.append(tag)\n", + " else:\n failed.append('ACCEPTED_' + tag)\n", + "def step(tag, fn):\n", + " try:\n return fn()\n except Exception as error:\n", + " failed.append(tag + '_' + ''.join(c if c.isalnum() else '_' for c in str(error))[:45])\n", + "{body}\n", + "L.create(('REPORT ' + str(len(refused)) + ' ~ ' + ' '.join(failed))[:250])\n", + ), body = body)).unwrap(); + assert!(process.dispatch(host, &format!("PY_RUN {}", script.display()), &mut |_| {}).unwrap()); + let _ = std::fs::remove_file(&script); + }; + let report = |document: &CadDocument| -> (usize, Vec) { + let name = document.layers.iter().map(|l| l.name.clone()).filter(|n| n.starts_with("REPORT")).last() + .expect("script report layer"); + let (refused, failed) = name["REPORT".len()..].split_once('~').unwrap(); + (refused.trim().parse().unwrap(), failed.split_whitespace().map(str::to_owned).collect()) + }; + + run(&mut host, "build", r#" +def line(a, b): return doc.create_entity('Line', start=P(*a), end=P(*b)) +def circle(c, r): return doc.create_entity('Circle', center=P(*c), radius=r) +a = line((0, 0), (10, 0)) +step('offset', lambda: M.offset(a, 2, (5, 5))) +h = line((100, 0), (110, 0)); v = line((105, -5), (105, 5)) +step('trim', lambda: M.trim(h, (108, 0))) +h2 = line((200, 0), (209, 0)); w = line((215, -5), (215, 5)) +step('extend', lambda: M.extend(h2, (208, 0))) +f1 = line((300, 0), (310, 0)); f2 = line((300, 0), (300, 10)) +step('fillet', lambda: M.fillet(f1, f2, 2, (308, 0), (300, 8))) +m = circle((400, 0), 1) +step('move', lambda: M.move([m], (0, 0, 0), (5, 5, 0))) +c = circle((500, 0), 1) +step('copy', lambda: M.copy([c], (0, 0, 0), (0, 10, 0))) +r = line((600, 0), (610, 0)) +step('rotate', lambda: M.rotate([r], (600, 0, 0), 90)) +sc = line((700, 0), (710, 0)) +step('scale', lambda: M.scale([sc], (700, 0, 0), 2)) +mi = line((800, 0), (810, 0)) +step('mirror', lambda: M.mirror([mi], (0, 5, 0), (1, 5, 0))) +e = line((900, 0), (910, 0)) +step('erase', lambda: M.erase([e])) +ca = line((1300, 0), (1310, 0)); cb = line((1300, 0), (1300, 10)) +step('chamfer', lambda: M.chamfer(ca, cb, 2, 3, (1308, 0), (1300, 8))) +ar = line((1400, 0), (1401, 0)) +step('array_rect', lambda: M.array_rect([ar], 2, 3, 10, 20)) +ap = line((1500, 0), (1501, 0)) +step('array_polar', lambda: M.array_polar([ap], (1500, 10, 0), 4, 360)) +pl = doc.create_entity('LwPolyline', vertices=[{'location': P(1600, 0)}, {'location': P(1610, 0)}, {'location': P(1610, 10)}]) +step('explode', lambda: M.explode([pl])) +j1 = line((1700, 0), (1705, 0)); j2 = line((1705, 0), (1710, 0)) +step('join', lambda: M.join([j1, j2])) +bk = line((1800, 0), (1810, 0)) +step('break', lambda: M.break_entity(bk, (1802, 0, 0), (1805, 0, 0))) +st = line((1900, 0), (1910, 0)) +step('stretch', lambda: M.stretch((1908, -2, 0), (1912, 2, 0), (1910, 0, 0), (1915, 0, 0))) +ln = line((2000, 0), (2010, 0)) +step('lengthen', lambda: M.lengthen(ln, 5, (2009, 0, 0))) +def lwp(pts, closed=False): return doc.create_entity('LwPolyline', is_closed=closed, vertices=[{'location': P(*p)} for p in pts]) +pc = lwp([(2100, 0), (2110, 0), (2110, 10)]) +step('polyline_close', lambda: M.polyline_close(pc)) +po = lwp([(2150, 0), (2160, 0), (2160, 10)], True) +step('polyline_open', lambda: M.polyline_open(po)) +pw = lwp([(2200, 0), (2210, 0), (2210, 10)]) +step('polyline_width', lambda: M.polyline_width(pw, 0.5)) +pr = lwp([(2300, 0), (2310, 0), (2310, 10)]) +step('polyline_reverse', lambda: M.polyline_reverse(pr)) +pj = lwp([(2400, 0), (2410, 0)]); pjl = line((2410, 0), (2420, 0)) +step('polyline_join', lambda: M.polyline_join(pj, [pjl])) +pline = line((2500, 0), (2510, 0)) +step('polyline_from_line', lambda: M.polyline_width(pline, 1)) +pb = line((2600, 0), (2601, 0)); pp = line((2600, 50), (2650, 50)) +step('array_path', lambda: M.array_path([pb], pp, 5)) +a3 = line((2700, 0), (2701, 0)) +step('array_3d', lambda: M.array_3d([a3], 2, 2, 2, 10, 20, 30)) +step('run_line', lambda: doc.command('LINE 1100,0 1110,10')) +def interactive(): + with doc.start_command('CIRCLE') as c: + c.point((1200, 0, 0)) + c.text(3) + if c.waiting: + raise RuntimeError('circle still waiting') +step('interactive', interactive) +check('quit', lambda: doc.start_command('QUIT')) +check('newdoc', lambda: doc.command('NEW')) +check('py', lambda: doc.command('PY_RUN nothing')) +check('unknown', lambda: doc.start_command('NOSUCHCOMMAND')) +check('incomplete', lambda: doc.command('OFFSET')) +check('input_without_command', lambda: ocs.command_step('point', {'point': [0, 0, 0]})) +open_command = doc.start_command('LINE') +check('second_command', lambda: doc.start_command('CIRCLE')) +check('run_while_active', lambda: doc.command('POINT 1,1')) +open_command.cancel() +check('bad_point', lambda: ocs.command_step('point', {'point': [0, 0, 0]})) +"#); + let (refused, failed) = report(host.document()); + assert!(failed.is_empty(), "wrapper failures: {failed:?}"); + assert_eq!(refused, 9); + + let lines: Vec = host.document().entities().filter_map(|e| match e { EntityType::Line(l) => Some(l.clone()), _ => None }).collect(); + let circles: Vec = host.document().entities().filter_map(|e| match e { EntityType::Circle(c) => Some(c.clone()), _ => None }).collect(); + let arcs: Vec = host.document().entities().filter_map(|e| match e { EntityType::Arc(a) => Some(a.clone()), _ => None }).collect(); + let near = |a: f64, b: f64| (a - b).abs() < 1e-6; + let has_line = |x1: f64, y1: f64, x2: f64, y2: f64| lines.iter().any(|l| { + (near(l.start.x, x1) && near(l.start.y, y1) && near(l.end.x, x2) && near(l.end.y, y2)) + || (near(l.start.x, x2) && near(l.start.y, y2) && near(l.end.x, x1) && near(l.end.y, y1)) + }); + let describe = || lines.iter().map(|l| format!("({},{})-({},{})", l.start.x, l.start.y, l.end.x, l.end.y)).collect::>(); + assert!(has_line(0.0, 0.0, 10.0, 0.0) && has_line(0.0, 2.0, 10.0, 2.0), "offset: {:?}", describe()); + assert!(has_line(100.0, 0.0, 105.0, 0.0), "trim: {:?}", describe()); + assert!(has_line(200.0, 0.0, 215.0, 0.0), "extend: {:?}", describe()); + assert!(arcs.iter().any(|a| near(a.radius, 2.0) && near(a.center.x, 302.0) && near(a.center.y, 2.0)), "fillet arc: {arcs:?}"); + assert!(circles.iter().any(|c| near(c.center.x, 405.0) && near(c.center.y, 5.0)), "move: {circles:?}"); + assert!(!circles.iter().any(|c| near(c.center.x, 400.0)), "move left the original: {circles:?}"); + assert!(circles.iter().any(|c| near(c.center.x, 500.0) && near(c.center.y, 0.0)) && circles.iter().any(|c| near(c.center.x, 500.0) && near(c.center.y, 10.0)), "copy: {circles:?}"); + assert!(has_line(600.0, 0.0, 600.0, 10.0), "rotate: {:?}", describe()); + assert!(has_line(700.0, 0.0, 720.0, 0.0), "scale: {:?}", describe()); + assert!(has_line(800.0, 0.0, 810.0, 0.0) && has_line(800.0, 10.0, 810.0, 10.0), "mirror keeps the source and adds the reflection: {:?}", describe()); + assert!(!has_line(900.0, 0.0, 910.0, 0.0), "erase: {:?}", describe()); + assert!(has_line(1100.0, 0.0, 1110.0, 10.0), "doc.command run: {:?}", describe()); + assert!(has_line(1302.0, 0.0, 1300.0, 3.0), "chamfer: {:?}", describe()); + for (x, y) in [(1400.0, 0.0), (1420.0, 0.0), (1440.0, 0.0), (1400.0, 10.0), (1420.0, 10.0), (1440.0, 10.0)] { + assert!(has_line(x, y, x + 1.0, y), "rectangular array member at ({x}, {y}): {:?}", describe()); + } + assert!(has_line(1500.0, 0.0, 1501.0, 0.0) && has_line(1500.0, 20.0, 1499.0, 20.0), "polar array (start and half turn): {:?}", describe()); + assert_eq!(lines.iter().filter(|l| l.start.x > 1450.0 && l.start.x < 1520.0 || l.end.x > 1450.0 && l.end.x < 1520.0).count(), 4, "polar array has four members"); + assert!(has_line(1600.0, 0.0, 1610.0, 0.0) && has_line(1610.0, 0.0, 1610.0, 10.0), "explode: {:?}", describe()); + assert!(!host.document().entities().any(|e| matches!(e, EntityType::LwPolyline(p) if p.vertices.first().is_some_and(|v| near(v.location.x, 1600.0)))), "explode removed the polyline"); + assert!(has_line(1700.0, 0.0, 1710.0, 0.0) && !has_line(1700.0, 0.0, 1705.0, 0.0), "join: {:?}", describe()); + assert!(has_line(1800.0, 0.0, 1802.0, 0.0) && has_line(1805.0, 0.0, 1810.0, 0.0) && !has_line(1800.0, 0.0, 1810.0, 0.0), "break: {:?}", describe()); + assert!(has_line(1900.0, 0.0, 1915.0, 0.0), "stretch: {:?}", describe()); + assert!(has_line(2000.0, 0.0, 2015.0, 0.0), "lengthen: {:?}", describe()); + let polylines: Vec = host.document().entities().filter_map(|e| match e { EntityType::LwPolyline(p) => Some(p.clone()), _ => None }).collect(); + let at = |x: f64| polylines.iter().find(|p| p.vertices.iter().any(|v| near(v.location.x, x))); + let pc = at(2100.0).expect("closed polyline"); + assert!(pc.is_closed, "polyline_close"); + assert!(!at(2150.0).expect("opened polyline").is_closed, "polyline_open"); + let pw = at(2200.0).expect("widened polyline"); + assert!(pw.constant_width == 0.5 || pw.vertices.iter().all(|v| v.start_width == 0.5 && v.end_width == 0.5), "polyline_width: {pw:?}"); + let pr = at(2300.0).expect("reversed polyline"); + assert_eq!((pr.vertices[0].location.x, pr.vertices[0].location.y), (2310.0, 10.0), "polyline_reverse: {pr:?}"); + let pj = polylines.iter().find(|p| p.vertices.iter().any(|v| near(v.location.x, 2420.0))).expect("joined polyline"); + assert_eq!(pj.vertices.len(), 3, "polyline_join: {pj:?}"); + assert!(!has_line(2410.0, 0.0, 2420.0, 0.0), "polyline_join consumed the line: {:?}", describe()); + let from_line = at(2500.0).expect("a line turned into a polyline"); + assert!(from_line.constant_width == 1.0 || from_line.vertices.iter().all(|v| v.start_width == 1.0), "polyline_from_line: {from_line:?}"); + assert!(!has_line(2500.0, 0.0, 2510.0, 0.0), "the line became the polyline: {:?}", describe()); + let path_copies: Vec<(f64, f64)> = lines.iter().filter(|l| l.start.x > 2599.0 && l.start.x < 2660.0).map(|l| (l.start.x, l.start.y)).collect(); + for x in [2600.0, 2612.5, 2625.0, 2637.5, 2650.0] { + assert!(path_copies.iter().any(|p| near(p.0, x) && near(p.1, 0.0)), "array_path item at x={x} (five evenly spaced along the 50-long path): {path_copies:?}"); + } + let three_d: Vec<(f64, f64, f64)> = lines.iter().filter(|l| l.start.x > 2699.0 && l.start.x < 2725.0).map(|l| (l.start.x, l.start.y, l.start.z)).collect(); + for x in [2700.0, 2720.0] { for y in [0.0, 10.0] { for z in [0.0, 30.0] { + assert!(three_d.iter().any(|p| near(p.0, x) && near(p.1, y) && near(p.2, z)), "array_3d member ({x},{y},{z}): {three_d:?}"); + } } } + assert!(circles.iter().any(|c| near(c.center.x, 1200.0) && near(c.radius, 3.0)), "start_command session: {circles:?}"); + assert!(host.app.tabs[0].active_cmd.is_none(), "no command is left running"); + + // U: a wrapper's change is one undoable step (the report layer is the other). + run(&mut host, "undo", r#" +u = doc.create_entity('Circle', center=P(1000, 0), radius=1) +step('undo_move', lambda: M.move([u], (0, 0, 0), (7, 0, 0))) +"#); + assert!(report(host.document()).1.is_empty()); + let moved = |document: &CadDocument, x: f64| document.entities().any(|e| matches!(e, EntityType::Circle(c) if near(c.center.x, x) && near(c.center.y, 0.0))); + assert!(moved(host.document(), 1007.0)); + drop(process); + drop(host); + app.finish_pending_history(0); + app.undo_steps(2); + assert!(moved(&app.tabs[0].scene.document, 1000.0), "undo put the circle back"); + assert!(!moved(&app.tabs[0].scene.document, 1007.0)); + } + + /// A scripted dict is checked all the way down: a nested record (a polyline + /// vertex, a mesh vertex) may not carry an unknown key or omit the field that + /// defines its geometry, so a typo never turns into a silent zero coordinate. + #[test] + fn audit_python_nested_input_is_validated_over_real_ipc() { + let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { + return; + }; + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let process = ocs_plugin_api::process::PluginProcess::spawn( + std::path::Path::new(&plugin_path), &mut host, crate::plugin::v4_support::notification_handler(), + ).unwrap(); + let script = std::env::temp_dir().join(format!("ocs_nested_{}.py", std::process::id())); + std::fs::write(&script, concat!( + "def P(x, y, z=0.0): return {'x': x, 'y': y, 'z': z}\n", + "doc = ocs.active_document\nL = doc.layers\n", + "refused, problems = [], []\n", + "def check(tag, fn, expect):\n", + " try:\n fn()\n except (RuntimeError, TypeError, ValueError) as error:\n", + " if expect in str(error):\n refused.append(tag)\n", + " else:\n problems.append('WRONGMESSAGE_' + tag)\n", + " else:\n problems.append('ACCEPTED_' + tag)\n", + "def lw(vertices): return lambda: doc.create_entity('LwPolyline', vertices=vertices)\n", + "ok = {'location': P(0, 0)}\n", + "check('lw_xy_keys', lw([ok, {'x': 5, 'y': 5}]), 'no property')\n", + "check('lw_empty_vertex', lw([ok, {}]), 'needs location')\n", + "check('lw_null_location', lw([ok, {'location': None}]), 'needs location')\n", + "check('lw_typo', lw([ok, {'location': P(1, 1), 'bulgee': 0.5}]), 'no property')\n", + "check('lw_message_lists_keys', lw([ok, {'lokation': P(1, 1)}]), 'location')\n", + "check('poly2d_missing_location', lambda: doc.create_entity('Polyline2D', vertices=[{'flags': 0}, {'location': P(1, 1)}]), 'needs location')\n", + "check('poly2d_typo', lambda: doc.create_entity('Polyline2D', vertices=[{'location': P(0, 0)}, {'loc': P(1, 1)}]), 'no property')\n", + "check('mesh_missing_location', lambda: doc.create_entity('PolygonMesh', vertices=[{}]), 'needs location')\n", + "good = doc.create_entity('LwPolyline', vertices=[ok, {'location': P(10, 0), 'bulge': 0.5}, {'location': P(10, 10)}])\n", + "good2 = doc.create_entity('Polyline2D', vertices=[{'location': P(0, 0)}, {'location': P(5, 5)}])\n", + "L.create('REPORT ' + str(len(refused)) + ' ~ ' + ' '.join(problems))\n", + )).unwrap(); + assert!(process.dispatch(&mut host, &format!("PY_RUN {}", script.display()), &mut |_| {}).unwrap()); + let _ = std::fs::remove_file(&script); + let name = host.document().layers.iter().map(|l| l.name.clone()).filter(|n| n.starts_with("REPORT")).last().expect("script report layer"); + let (refused, problems) = name["REPORT".len()..].split_once('~').unwrap(); + assert_eq!(problems.trim(), "", "problems: {problems}"); + assert_eq!(refused.trim(), "8"); + // Nothing invalid reached the drawing; the two valid polylines did. + let document = host.document(); + let lw: Vec<_> = document.entities().filter_map(|e| match e { EntityType::LwPolyline(p) => Some(p.clone()), _ => None }).collect(); + assert_eq!(lw.len(), 1, "only the valid LwPolyline was created"); + assert_eq!(lw[0].vertices.len(), 3); + assert_eq!(lw[0].vertices[1].bulge, 0.5); + assert_eq!(document.entities().filter(|e| matches!(e, EntityType::Polyline2D(_))).count(), 1); + assert!(!document.entities().any(|e| matches!(e, EntityType::PolygonMesh(_)))); + drop(process); + } + + /// The user's SCRIPTCOMMANDS setting gates the command runner, and a script cannot change it. + #[test] + fn script_commands_setting_gates_the_runner() { + use ocs_plugin_api::host::CommandRequest as R; + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + assert!(app.script_commands, "scripts may run commands by default"); + let run = |app: &mut OpenCADStudio, line: &str| HostSession::new(app, 0).run_command(R::Run { line: line.into() }); + assert!(run(&mut app, "LINE 0,0 10,0").is_ok()); + + // A script cannot touch the setting, on or off. + let refused = run(&mut app, "SCRIPTCOMMANDS 0").unwrap_err(); + assert!(refused.contains("cannot be run from a script"), "{refused}"); + assert!(app.script_commands); + + // The user turns it off at the command line. + let reply = app.automation_op(r#"{"op":"run","cmd":"SCRIPTCOMMANDS 0"}"#); + assert_eq!(reply["ok"], true, "{reply}"); + assert!(!app.script_commands); + let refused = run(&mut app, "LINE 0,0 20,0").unwrap_err(); + assert!(refused.contains("turned off"), "{refused}"); + let refused = HostSession::new(&mut app, 0).run_command(R::Start { name: "LINE".into() }).unwrap_err(); + assert!(refused.contains("turned off"), "{refused}"); + assert_eq!(app.tabs[0].scene.document.entities().count(), 1, "nothing ran while it was off"); + assert!(HostSession::new(&mut app, 0).run_command(R::Cancel).is_ok(), "Cancel is still allowed while it is off"); + + // And back on. + app.automation_op(r#"{"op":"run","cmd":"SCRIPTCOMMANDS 1"}"#); + assert!(app.script_commands); + assert!(run(&mut app, "LINE 0,0 20,0").is_ok()); + assert_eq!(app.tabs[0].scene.document.entities().count(), 2); + } + + /// Exploration harness (not a gate): prints how the real commands answer + /// each step so the wrappers are written against observed prompts. + #[test] + #[ignore] + #[allow(unused_must_use)] + fn spike_command_runner_prompts() { + use ocs_plugin_api::host::CommandRequest as R; + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let show = |label: &str, r: &Result| match r { + Ok(o) => eprintln!("SPIKE {label}: {} prompt={:?} accepts={:?} opts={:?} added={} err={:?}", + o.status, o.prompt, o.accepts, o.options, o.added, o.error), + Err(e) => eprintln!("SPIKE {label}: ERR {e}"), + }; + let run = |host: &mut HostSession<'_>, label: &str, r: R| { let out = host.run_command(r); show(label, &out); out }; + let mk = |host: &mut HostSession<'_>, lines: &[&str]| -> Vec { + for l in lines { host.run_command(R::Run { line: (*l).into() }).unwrap(); } + host.document().entities().map(|e| e.common().handle).collect() + }; + let hs = mk(&mut host, &["PLINE 2400,0 2410,0", "LINE 2410,0 2420,0"]); + run(&mut host, "PEDIT start", R::Start { name: "PEDIT".into() }); + run(&mut host, "pick", R::Entity { handle: hs[0], point: [2400.0, 0.0, 0.0] }); + run(&mut host, "J", R::Token { text: "J".into() }); + host.app.tabs[0].scene.replace_selection_exact(&[hs[1]]); + run(&mut host, "selection", R::Selection); + run(&mut host, "enter", R::Enter); + run(&mut host, "X", R::Token { text: "X".into() }); + eprintln!("SPIKE final entity count {}", host.document().entities().count()); + } + + + + + + #[test] + fn audit_python_raster_image_definition_linkage_over_real_ipc() { + use acadrust::objects::ObjectType; + let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { + return; + }; + let dir = std::env::temp_dir(); + let (a, b) = (dir.join("ocs_link_a.png"), dir.join("ocs_link_b.png")); + image::RgbaImage::from_pixel(8, 4, image::Rgba([9, 9, 9, 255])).save(&a).unwrap(); + image::RgbaImage::from_pixel(6, 6, image::Rgba([90, 9, 9, 255])).save(&b).unwrap(); + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let process = ocs_plugin_api::process::PluginProcess::spawn( + std::path::Path::new(&plugin_path), &mut host, crate::plugin::v4_support::notification_handler(), + ).unwrap(); + let script = dir.join(format!("ocs_link_{}.py", std::process::id())); + std::fs::write(&script, format!(concat!( + "doc = ocs.active_document\n", + "doc.create_entity('RasterImage', file_path={a:?}, insertion_point=(0, 0, 0))\n", + "doc.create_entity('RasterImage', file_path={a:?}, insertion_point=(20, 0, 0))\n", + "doc.create_entity('RasterImage', file_path={b:?}, insertion_point=(40, 0, 0))\n", + ), a = a.display().to_string(), b = b.display().to_string())).unwrap(); + assert!(process.dispatch(&mut host, &format!("PY_RUN {}", script.display()), &mut |_| {}).unwrap()); + let _ = std::fs::remove_file(&script); + + // The structure: one definition per file, registered and owned by ACAD_IMAGE_DICT. + let structure = |document: &CadDocument| { + let root = document.header.named_objects_dict_handle; + let Some(ObjectType::Dictionary(root_dict)) = document.objects.get(&root) else { panic!("no root dictionary") }; + let dict_handle = root_dict.get("ACAD_IMAGE_DICT"); + let entries = dict_handle.and_then(|h| match document.objects.get(&h) { + Some(ObjectType::Dictionary(d)) => Some(d.entries.clone()), _ => None }).unwrap_or_default(); + let defs: Vec<_> = document.objects.iter().filter_map(|(h, o)| match o { + ObjectType::ImageDefinition(d) => Some((*h, d.owner, d.file_name.clone())), _ => None }).collect(); + let mut images: Vec<_> = document.entities().filter_map(|e| match e { + EntityType::RasterImage(i) => Some((i.common.handle, i.definition_handle, i.file_path.clone())), _ => None }).collect(); + images.sort_by_key(|(h, _, _)| h.value()); + (dict_handle, entries, defs, images) + }; + let check = |label: &str, document: &CadDocument| { + let (dict, entries, defs, images) = structure(document); + let dict = dict.unwrap_or_else(|| panic!("{label}: no ACAD_IMAGE_DICT")); + assert_eq!(defs.len(), 2, "{label}: one definition per file"); + let mut keys: Vec<_> = entries.iter().map(|(k, _)| k.as_str()).collect(); + keys.sort(); + assert_eq!(keys, vec!["ocs_link_a", "ocs_link_b"], "{label}"); + for (key, handle) in &entries { + let (_, owner, file) = defs.iter().find(|(h, _, _)| h == handle).unwrap_or_else(|| panic!("{label}: dangling entry {key}")); + assert_eq!(*owner, dict, "{label}: {key} is owned by ACAD_IMAGE_DICT"); + assert!(file.contains(key.as_str()), "{label}: {key} -> {file}"); + } + assert_eq!(images.len(), 3, "{label}"); + assert_eq!(images[0].1, images[1].1, "{label}: images of one file share a definition"); + assert_ne!(images[0].1, images[2].1, "{label}"); + for (_, definition, path) in &images { + assert!(defs.iter().any(|(h, _, _)| Some(*h) == *definition), "{label}: definition resolves"); + assert!(path.contains("ocs_link_"), "{label}: file path kept: {path}"); + } + }; + check("live", host.document()); + let dwg = crate::io::load_bytes("link.dwg", acadrust::DwgWriter::write_to_vec(host.document()).unwrap()).unwrap(); + let dxf = crate::io::load_bytes("link.dxf", acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()).unwrap(); + check("DWG", &dwg); + check("DXF", &dxf); + + // U: undoing the last image leaves no dangling dictionary entry. + drop(process); + drop(host); + app.finish_pending_history(0); + app.undo_steps(1); + let (_, entries, defs, images) = structure(&app.tabs[0].scene.document); + assert!(images.len() < 3); + for (key, handle) in &entries { + assert!(defs.iter().any(|(h, _, _)| h == handle), "undo left a dangling entry {key}"); + } + for file in [&a, &b] { + let _ = std::fs::remove_file(file); + } + } + + #[test] + fn staged_python_point_pick_and_cancel_over_real_ipc() { + let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { + return; + }; + let runner_path = std::env::var_os("OCS_PLUGIN_RUNNER_EXE") + .expect("set OCS_PLUGIN_RUNNER_EXE to the built OpenCADStudio executable"); + assert!(std::path::Path::new(&runner_path).is_file()); + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let process = { + let mut host = HostSession::new(&mut app, 0); + std::sync::Arc::new( + ocs_plugin_api::process::PluginProcess::spawn( + std::path::Path::new(&plugin_path), + &mut host, + crate::plugin::v4_support::notification_handler(), + ) + .expect("spawn staged Python plugin"), + ) + }; + let request = |app: &mut OpenCADStudio, prompt: &str, entity: bool| { + let mut started = None; + { + let mut host = HostSession::new(app, 0); + let method = if entity { + "request_entity" + } else { + "request_point" + }; + let cmd = format!("PY_EVAL ocs.active_document.{method}('{prompt}')"); + assert!(process + .dispatch(&mut host, &cmd, &mut |id| started = Some(id)) + .unwrap()); + } + let output = app.command_line.history.last().unwrap().text.clone(); + let token = output + .split_whitespace() + .find_map(|part| part.parse::().ok()) + .unwrap_or_else(|| panic!("Python pick token missing from {output:?}")); + let id = started.expect("Python request started an interactive command"); + app.set_active_command( + 0, + Box::new(PluginProcessInteractiveAdapter::new( + std::sync::Arc::clone(&process), + id, + )), + ); + token + }; + + let picked_token = request(&mut app, "Pick a point", false); + let result = app.tabs[0] + .active_cmd + .as_mut() + .unwrap() + .on_point(glam::DVec3::new(3.0, 4.0, 0.0)); + let _ = app.apply_cmd_result(result); + assert!(app.tabs[0].active_cmd.is_none()); + { + let mut host = HostSession::new(&mut app, 0); + assert!(process + .dispatch( + &mut host, + &format!("PY_EVAL ocs.active_document.poll_input({picked_token})"), + &mut |_| {} + ) + .unwrap()); + } + assert!(app + .command_line + .history + .last() + .unwrap() + .text + .contains("point")); + + let target = { + let mut host = HostSession::new(&mut app, 0); + host.add_entity(EntityType::Line(Line::from_points( + acadrust::types::Vector3::new(6.0, 7.0, 0.0), + acadrust::types::Vector3::new(9.0, 7.0, 0.0), + ))) + }; + let entity_token = request(&mut app, "Pick an entity", true); + assert!(app.tabs[0].active_cmd.as_ref().unwrap().needs_entity_pick()); + let result = app.tabs[0] + .active_cmd + .as_mut() + .unwrap() + .on_entity_pick(target, glam::DVec3::new(6.0, 7.0, 0.0)); + let _ = app.apply_cmd_result(result); + { + let mut host = HostSession::new(&mut app, 0); + assert!(process + .dispatch( + &mut host, + &format!("PY_EVAL ocs.active_document.poll_input({entity_token})"), + &mut |_| {} + ) + .unwrap()); + } + assert!(app + .command_line + .history + .last() + .unwrap() + .text + .contains("entity")); + assert!(app + .command_line + .history + .last() + .unwrap() + .text + .contains(&target.value().to_string())); + + let cancelled_token = request(&mut app, "Cancel a point", false); + let result = app.tabs[0].active_cmd.as_mut().unwrap().on_enter(); + let _ = app.apply_cmd_result(result); + { + let mut host = HostSession::new(&mut app, 0); + assert!(process + .dispatch( + &mut host, + &format!("PY_EVAL ocs.active_document.poll_input({cancelled_token})"), + &mut |_| {} + ) + .unwrap()); + } + assert!(app + .command_line + .history + .last() + .unwrap() + .text + .contains("cancelled")); + } + + #[test] + fn staged_python_tabs_isolate_tokens_and_notifications() { + let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { + return; + }; + let runner_path = std::env::var_os("OCS_PLUGIN_RUNNER_EXE") + .expect("set OCS_PLUGIN_RUNNER_EXE to the built OpenCADStudio executable"); + assert!(std::path::Path::new(&runner_path).is_file()); + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + app.tabs + .push(crate::app::document::DocumentTab::new_drawing(2)); + app.tabs[1].is_start = false; + let tab0 = app.tabs[0].id; + let tab1 = app.tabs[1].id; + assert_ne!(tab0, tab1); + let process = { + let mut host = HostSession::new(&mut app, 0); + std::sync::Arc::new( + ocs_plugin_api::process::PluginProcess::spawn( + std::path::Path::new(&plugin_path), + &mut host, + crate::plugin::v4_support::notification_handler(), + ) + .expect("spawn staged Python plugin"), + ) + }; + let mut started = None; + { + let mut host = HostSession::new(&mut app, 0); + assert!(process + .dispatch( + &mut host, + "PY_EVAL ocs.active_document.request_point('Tab zero')", + &mut |id| started = Some(id) + ) + .unwrap()); + } + let token = app + .command_line + .history + .last() + .unwrap() + .text + .split_whitespace() + .find_map(|part| part.parse::().ok()) + .expect("pick token"); + app.set_active_command( + 0, + Box::new(PluginProcessInteractiveAdapter::new( + std::sync::Arc::clone(&process), + started.unwrap(), + )), + ); + { + let mut host = HostSession::new(&mut app, 1); + assert!(process + .dispatch( + &mut host, + &format!("PY_EVAL ocs.active_document.poll_input({token})"), + &mut |_| {} + ) + .unwrap()); + } + assert!( + app.command_line + .history + .last() + .unwrap() + .text + .contains("None"), + "other tab must not consume the pending token" + ); + let result = app.tabs[0] + .active_cmd + .as_mut() + .unwrap() + .on_point(glam::DVec3::new(2.0, 3.0, 0.0)); + let _ = app.apply_cmd_result(result); + { + let mut host = HostSession::new(&mut app, 1); + assert!(process + .dispatch( + &mut host, + &format!("PY_EVAL ocs.active_document.poll_input({token})"), + &mut |_| {} + ) + .unwrap()); + } + assert!(app + .command_line + .history + .last() + .unwrap() + .text + .contains("None")); + { + let mut host = HostSession::new(&mut app, 0); + assert!(process + .dispatch( + &mut host, + &format!("PY_EVAL ocs.active_document.poll_input({token})"), + &mut |_| {} + ) + .unwrap()); + } + assert!(app + .command_line + .history + .last() + .unwrap() + .text + .contains("point")); + + use ocs_plugin_api::host::HostNotification; + process + .notify_plugin( + None, + HostNotification::DrawingChanged { + tab_id: tab0, + epoch: 101, + }, + ) + .unwrap(); + process + .notify_plugin( + None, + HostNotification::DrawingChanged { + tab_id: tab1, + epoch: 202, + }, + ) + .unwrap(); + // Notifications are best-effort and consumed by the runner's reader + // thread before its next Dispatch. Give that thread a bounded handoff. + std::thread::sleep(std::time::Duration::from_millis(50)); + let poll_events_until = |app: &mut OpenCADStudio, tab: usize, epoch: &str| { + let mut output = String::new(); + for _ in 0..10 { + { + let mut host = HostSession::new(app, tab); + assert!(process + .dispatch( + &mut host, + "PY_EVAL ocs.active_document.poll_events()", + &mut |_| {} + ) + .unwrap()); + } + output = app.command_line.history.last().unwrap().text.clone(); + if output.contains(epoch) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + output + }; + let other_events = poll_events_until(&mut app, 1, "202"); + assert!( + other_events.contains("202") && !other_events.contains("101"), + "{other_events}" + ); + let first_events = poll_events_until(&mut app, 0, "101"); + assert!( + first_events.contains("101") && !first_events.contains("202"), + "{first_events}" + ); + + for epoch in 0..257 { + process + .notify_plugin( + None, + HostNotification::DrawingChanged { + tab_id: tab0, + epoch, + }, + ) + .unwrap(); + } + std::thread::sleep(std::time::Duration::from_millis(100)); + { + let mut host = HostSession::new(&mut app, 0); + assert!(process + .dispatch( + &mut host, + "PY_EVAL ocs.active_document.poll_events()", + &mut |_| {} + ) + .unwrap()); + } + let overflow = &app.command_line.history.last().unwrap().text; + assert!( + overflow.contains("overflow") && overflow.contains("dropped"), + "{overflow}" + ); + + process + .notify_plugin( + None, + HostNotification::DrawingChanged { + tab_id: tab0, + epoch: 303, + }, + ) + .unwrap(); + process + .notify_plugin(None, HostNotification::DocumentTabClosed { tab_id: tab0 }) + .unwrap(); + std::thread::sleep(std::time::Duration::from_millis(50)); + { + let mut host = HostSession::new(&mut app, 0); + assert!(process + .dispatch( + &mut host, + "PY_EVAL ocs.active_document.poll_events()", + &mut |_| {} + ) + .unwrap()); + } + assert!( + app.command_line.history.last().unwrap().text.contains("[]"), + "closing a tab must discard its queued events" + ); + } + + #[test] + fn unmapped_canvas_kind_layer_change_undoes() { + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let handle; + { + let mut host = HostSession::new(&mut app, 0); + handle = host.add_entity(EntityType::MLine(acadrust::entities::MLine::default())); + let original = host.document().get_entity(handle).unwrap(); + let changed = + ocs_plugin_api::entity_coverage::patch_canvas_layer(original, "HATCHES").unwrap(); + host.update_entities_transaction("Move hatch layer", vec![changed]) + .unwrap(); + assert_eq!( + host.document().get_entity(handle).unwrap().common().layer, + "HATCHES" + ); + } + app.finish_pending_history(0); + assert_eq!(app.tabs[0].history.undo_stack.len(), 1); + app.undo_steps(1); + assert_eq!( + app.tabs[0] + .scene + .document + .get_entity(handle) + .unwrap() + .common() + .layer, + "0" + ); + } + + #[test] + fn insert_transform_transaction_undoes_without_changing_block_identity() { + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let handle; + { + let mut host = HostSession::new(&mut app, 0); + let insert = acadrust::entities::Insert::new( + "DOOR", + acadrust::types::Vector3::new(1.0, 2.0, 0.0), + ); + handle = host.add_entity(EntityType::Insert(insert)); + let mut changed = host.document().get_entity(handle).unwrap().clone(); + if let EntityType::Insert(insert) = &mut changed { + insert.insert_point.x = 4.0; + insert.set_x_scale(2.0); + } + host.update_entities_transaction("Move block", vec![changed]) + .unwrap(); + assert!( + matches!(host.document().get_entity(handle), Some(EntityType::Insert(insert)) + if insert.insert_point.x == 4.0 && insert.x_scale() == 2.0 && insert.block_name == "DOOR") + ); + } + app.finish_pending_history(0); + assert_eq!(app.tabs[0].history.undo_stack.len(), 1); + app.undo_steps(1); + assert!( + matches!(app.tabs[0].scene.document.get_entity(handle), Some(EntityType::Insert(insert)) + if insert.insert_point.x == 1.0 && insert.x_scale() == 1.0 && insert.block_name == "DOOR") + ); + } + + #[test] + fn system_variables_change_without_command_reentry() { + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + assert_eq!( + host.system_variable("clayer"), + Some(HostSettingValue::Text("0".into())) + ); + assert_eq!( + host.system_variable("SNAPANG"), + Some(HostSettingValue::Number(0.0)) + ); + assert!(host + .set_system_variable("CLAYER", HostSettingValue::Text("Missing".into())) + .is_err()); + assert_eq!( + host.system_variable("CLAYER"), + Some(HostSettingValue::Text("0".into())) + ); + + let mut point = Point::new(); + point.common.layer = "Annotations".into(); + host.add_entity(EntityType::Point(point)); + assert_eq!( + host.set_system_variable("clayer", HostSettingValue::Text("Annotations".into())), + Ok(HostSettingValue::Text("Annotations".into())) + ); + assert_eq!( + host.system_variable("CLAYER"), + Some(HostSettingValue::Text("Annotations".into())) + ); + assert_eq!(host.app.tabs[0].active_layer, "Annotations"); + assert_eq!(host.app.tabs[0].layers.current_layer, "Annotations"); + assert_eq!(host.app.ribbon.active_layer, "Annotations"); + assert!(host.app.tabs[0].dirty); + + assert_eq!( + host.set_system_variable("snapang", HostSettingValue::Number(450.0)), + Ok(HostSettingValue::Number(90.0)) + ); + assert_eq!( + host.system_variable("SNAPANG"), + Some(HostSettingValue::Number(90.0)) + ); + assert!(host + .set_system_variable("SNAPANG", HostSettingValue::Number(f64::INFINITY)) + .is_err()); + assert!(host + .set_system_variable("SNAPANG", HostSettingValue::Number(f64::MAX)) + .is_err()); + assert_eq!( + host.system_variable("SNAPANG"), + Some(HostSettingValue::Number(90.0)) + ); + } + #[test] fn xdata_record_round_trips_and_registers_appid() { let mut app = OpenCADStudio::new_for_test(); @@ -844,7 +9147,10 @@ mod tests { assert!(host.update_entity(edited)); // Same handle, edit applied, geometry re-tessellated. - let got = host.document().get_entity(h).expect("entity kept its handle"); + let got = host + .document() + .get_entity(h) + .expect("entity kept its handle"); assert_eq!(got.common().layer, "PLUGIN_EDIT"); assert_ne!( host.app.tabs[0].scene.geometry_epoch, epoch_before, @@ -948,12 +9254,18 @@ mod tests { host.start_interactive(Box::new(PlacePoint { got_first: false })); } assert!(app.tabs[0].active_cmd.is_some()); - for pt in [glam::DVec3::new(0.0, 0.0, 0.0), glam::DVec3::new(5.0, 5.0, 0.0)] { + for pt in [ + glam::DVec3::new(0.0, 0.0, 0.0), + glam::DVec3::new(5.0, 5.0, 0.0), + ] { let r = app.tabs[0].active_cmd.as_mut().unwrap().on_point(pt); let _ = app.apply_cmd_result(r); } assert_eq!(app.tabs[0].scene.document.entities().count(), 1); - assert!(app.tabs[0].active_cmd.is_none(), "command should have ended"); + assert!( + app.tabs[0].active_cmd.is_none(), + "command should have ended" + ); } /// A plugin command that picks an existing object, then marks it. @@ -1025,8 +9337,9 @@ mod tests { app.tabs[0].is_start = false; let mut host = HostSession::new(&mut app, 0); let info = host.document_view().unwrap(); - let reader = - ocs_plugin_api::shm::SharedDocumentReader::::open(std::path::Path::new(&info.path)) + let reader = ocs_plugin_api::shm::SharedDocumentReader::< + ocs_plugin_api::shm::DocumentViewData, + >::open(std::path::Path::new(&info.path)) .unwrap(); assert_eq!(reader.entity_count(), 0); @@ -1076,8 +9389,9 @@ mod tests { app.tabs[0].is_start = false; let mut host = HostSession::new(&mut app, 0); let info = host.document_view().unwrap(); - let reader = - ocs_plugin_api::shm::SharedDocumentReader::::open(std::path::Path::new(&info.path)) + let reader = ocs_plugin_api::shm::SharedDocumentReader::< + ocs_plugin_api::shm::DocumentViewData, + >::open(std::path::Path::new(&info.path)) .unwrap(); let h = host.add_entity(EntityType::Point(Point::at(acadrust::types::Vector3::new( diff --git a/src/app/settings.rs b/src/app/settings.rs index 308f3754e..9823e0aac 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -485,6 +485,10 @@ pub struct UserSettings { /// Minutes between autosaves to a `.sv$` recovery file (SAVETIME command). /// 0 disables autosave. pub savetime_min: i32, + /// SCRIPTCOMMANDS: whether a script (the Python plugin) may run OCS commands + /// through the host. On by default; a user turns it off with the + /// `SCRIPTCOMMANDS 0` command, and a script cannot change it. + pub script_commands: bool, /// File type and version used when a new/unsaved drawing is first saved. /// Existing drawings keep their own type and version. pub default_save_format: String, @@ -685,6 +689,7 @@ impl Default for UserSettings { constraint_bar_display: 3, constraint_bar_mode: 4095, savetime_min: 10, + script_commands: true, default_save_format: crate::io::DEFAULT_SAVE_FORMAT.to_string(), pick_add: true, pick_drag_rect: false, @@ -798,6 +803,19 @@ mod tests { assert_eq!(cfg.settings.savetime_min, 42); } + #[test] + fn script_commands_default_on_and_survive_a_round_trip() { + let missing: crate::app::config::AppConfig = + serde_json::from_str(r#"{"settings": {"pick_add": false}}"#).unwrap(); + assert!(missing.settings.script_commands, "an old config keeps scripts allowed"); + let off: crate::app::config::AppConfig = + serde_json::from_str(r#"{"settings": {"script_commands": false}}"#).unwrap(); + assert!(!off.settings.script_commands); + let text = serde_json::to_string(&off).unwrap(); + let back: crate::app::config::AppConfig = serde_json::from_str(&text).unwrap(); + assert!(!back.settings.script_commands); + } + #[test] fn isometric_drafting_never_persists_or_loads() { // A persisted On used to bring the isoplane crosshair (vertical + diff --git a/src/app/style_ops.rs b/src/app/style_ops.rs index e2ec1a2f2..dc645bef1 100644 --- a/src/app/style_ops.rs +++ b/src/app/style_ops.rs @@ -271,7 +271,7 @@ impl OpenCADStudio { } } - fn style_exists(&self, kind: StyleKind, name: &str) -> bool { + pub(super) fn style_exists(&self, kind: StyleKind, name: &str) -> bool { self.style_names(kind) .iter() .any(|n| n.eq_ignore_ascii_case(name)) @@ -308,7 +308,7 @@ impl OpenCADStudio { /// Refresh anything that mirrors the style list / current style after a /// mutation (ribbon dropdowns, geometry that depends on the style). - fn after_style_change(&mut self, kind: StyleKind) { + pub(super) fn after_style_change(&mut self, kind: StyleKind) { if kind.in_ribbon() { self.sync_ribbon_styles(); } @@ -405,7 +405,7 @@ impl OpenCADStudio { false } - fn remove_style_storage(&mut self, kind: StyleKind, name: &str) -> bool { + pub(super) fn remove_style_storage(&mut self, kind: StyleKind, name: &str) -> bool { let doc = &mut self.tabs[self.active_tab].scene.document; match kind { StyleKind::Text => doc.text_styles.remove(name).is_some(), @@ -541,7 +541,7 @@ impl OpenCADStudio { /// Rename `old`→`new` in the backing store, re-keying table entries and /// rewriting name-based references + current-style pointers. - fn rename_style_storage(&mut self, kind: StyleKind, old: &str, new: &str) { + pub(super) fn rename_style_storage(&mut self, kind: StyleKind, old: &str, new: &str) { let i = self.active_tab; match kind { StyleKind::Text => { diff --git a/src/app/update/file.rs b/src/app/update/file.rs index ec707d1f6..b23d252c4 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -619,6 +619,7 @@ impl OpenCADStudio { constraint_bar_display: self.constraint_bar_display, constraint_bar_mode: self.constraint_bar_mode, savetime_min: self.savetime_min, + script_commands: self.script_commands, default_save_format: self.default_save_format.clone(), pick_add: self.pick_add, pick_drag_rect: self.pick_drag_rect, @@ -723,6 +724,7 @@ impl OpenCADStudio { self.constraint_bar_display = s.constraint_bar_display.clamp(0, 3); self.constraint_bar_mode = s.constraint_bar_mode.clamp(0, 4095); self.savetime_min = s.savetime_min; + self.script_commands = s.script_commands; self.default_save_format = crate::io::canonical_save_format(&s.default_save_format).to_string(); self.pick_add = s.pick_add; diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 8f9cb9eb0..a0201728a 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -284,6 +284,24 @@ impl OpenCADStudio { crate::plugin::v4_support::publish_selection_changed_v4(tab_id, handles); } + /// Refresh the shared V4 document after built-in geometry edits. Plugin + /// writes publish through HostSession immediately; the fingerprint avoids + /// sending a duplicate notification at this message boundary. + #[cfg(not(target_arch = "wasm32"))] + pub(super) fn notify_plugins_document_changed(&mut self) { + if self.active_tab >= self.tabs.len() { + return; + } + let tab = &self.tabs[self.active_tab]; + let key = (tab.id, tab.scene.geometry_epoch); + if self.last_plugin_document == Some(key) { + return; + } + self.last_plugin_document = Some(key); + crate::plugin::v4_support::publish_drawing_changed(tab.id, tab.scene.geometry_epoch); + crate::plugin::v4_support::publish_document_view_v4(tab.id, &tab.scene.document); + } + pub fn update(&mut self, msg: Message) -> Task { if let Some(tab) = self.tabs.get(self.active_tab) { crate::entities::common::set_unit_context( @@ -355,7 +373,29 @@ impl OpenCADStudio { return task; } } + // A command's name is `&'static str`, so this runs on every message — + // a mouse move included — without allocating. + #[cfg(not(target_arch = "wasm32"))] + let active_command = |app: &Self| -> Option<(u64, Option<&'static str>)> { + app.tabs + .get(app.active_tab) + .map(|tab| (tab.id, tab.active_cmd.as_ref().map(|command| command.name()))) + }; + #[cfg(not(target_arch = "wasm32"))] + let command_before = active_command(self); let task = self.update_inner(msg); + #[cfg(not(target_arch = "wasm32"))] + { + let command_after = active_command(self); + if command_before != command_after { + if let Some((tab_id, command)) = command_after { + crate::plugin::v4_support::publish_command_state_changed( + tab_id, + command.map(str::to_owned), + ); + } + } + } self.refresh_gpu_status(); self.show_next_startup_modal(); self.sync_open_command_history(); @@ -389,6 +429,8 @@ impl OpenCADStudio { // and plugin request draining). #[cfg(not(target_arch = "wasm32"))] self.notify_plugins_selection_changed(); + #[cfg(not(target_arch = "wasm32"))] + self.notify_plugins_document_changed(); // OTRACK acquires tracking points only while a command or grip drag is // running; drop them once neither is active so the temporary tracking // points / vectors disappear when the command ends (issue #64). diff --git a/src/entities/dimension.rs b/src/entities/dimension.rs index e041fc779..c177f5e79 100644 --- a/src/entities/dimension.rs +++ b/src/entities/dimension.rs @@ -25,6 +25,14 @@ pub(crate) fn set_dimension_text_override(base: &mut DimensionBase, text: Option base.user_text = text; } +/// Recompute the fields a script cannot set: the base definition point and +/// the stored measurement, exactly as grip edits do. +pub(crate) fn normalize_scripted_dimension(dim: &mut Dimension) { + let definition_point = dimension_definition_point(dim); + dim.base_mut().definition_point = definition_point; + dim.base_mut().actual_measurement = dim.measurement(); +} + fn dimension_definition_point(dim: &Dimension) -> acadrust::types::Vector3 { match dim { Dimension::Aligned(d) => d.definition_point, diff --git a/src/entities/helix.rs b/src/entities/helix.rs index e78643321..2d479f17f 100644 --- a/src/entities/helix.rs +++ b/src/entities/helix.rs @@ -602,3 +602,28 @@ mod tests { } } } + +/// Rebuild the derived spline after a scripted create or edit of the +/// generating parameters. `radius` is the top radius; the base radius is the +/// start point's distance from the axis. +pub(crate) fn normalize_scripted_helix(old: Option<&Helix>, new: &mut Helix) -> Result<(), String> { + let unchanged = old.is_some_and(|old| { + old.axis_base_point == new.axis_base_point + && old.start_point == new.start_point + && old.axis_vector == new.axis_vector + && old.radius.to_bits() == new.radius.to_bits() + && old.turns.to_bits() == new.turns.to_bits() + && old.turn_height.to_bits() == new.turn_height.to_bits() + && old.handedness == new.handedness + && old.constraint == new.constraint + }); + if unchanged { + return Ok(()); + } + let top = new.radius; + if rebuild(new, top) { + Ok(()) + } else { + Err("Helix parameters do not define a valid curve".into()) + } +} diff --git a/src/entities/mline.rs b/src/entities/mline.rs index f9d10c7ce..e2ead2af9 100644 --- a/src/entities/mline.rs +++ b/src/entities/mline.rs @@ -418,6 +418,54 @@ fn restore_shifted_mline_segment( } } +/// Recompute the derived MLine state after a scripted create or edit: +/// vertex direction/miter, element segment layout and the per-element miter +/// offsets. Dash/cut parameters survive an edit unless a vertex moved. +pub(crate) fn normalize_scripted_mline( + old: Option<&MLine>, + new: &mut MLine, + document: &acadrust::CadDocument, +) -> Result<(), String> { + let style = resolved_mline_style(new, document) + .ok_or_else(|| format!("MLine style {:?} does not exist", new.style_name))?; + let unchanged = old.is_some_and(|old| { + old.vertices == new.vertices + && old.normal == new.normal + && old.flags == new.flags + && old.justification == new.justification + && old.scale_factor.to_bits() == new.scale_factor.to_bits() + && old.style_name == new.style_name + && old.style_handle == new.style_handle + }); + if unchanged { + return Ok(()); + } + let moved = old.is_none_or(|old| { + old.vertices.len() != new.vertices.len() + || old + .vertices + .iter() + .zip(&new.vertices) + .any(|(a, b)| a.position != b.position) + }); + let elements = style.elements.len().max(1); + for vertex in &mut new.vertices { + if vertex.segments.is_empty() { + vertex.init_segments(elements); + } else if moved { + for segment in &mut vertex.segments { + segment.parameters.truncate(1); + segment.area_fill_parameters.clear(); + } + } + } + if !rebuild_mline_geometry(new) { + return Err("MLine vertices are degenerate".into()); + } + crate::modules::draw::draw::mline::sync_mline_element_parameters(new, style); + Ok(()) +} + /// Resolve a multiline into styled parallel lines in WCS. pub fn resolved_mline_style<'a>( m: &MLine, diff --git a/src/entities/table.rs b/src/entities/table.rs index 8039b4615..f2de572b8 100644 --- a/src/entities/table.rs +++ b/src/entities/table.rs @@ -3339,3 +3339,29 @@ impl Transformable for Table { }); } } + +/// Keep the per-cell merge dimensions (what DXF stores) in step with +/// `merged_ranges` (what DWG stores) after a scripted create or edit. Cells a +/// script did not touch keep their merge state when the ranges are unchanged. +pub(crate) fn normalize_scripted_table(old: Option<&Table>, new: &mut Table) { + if old.is_some_and(|old| old.merged_ranges == new.merged_ranges) { + return; + } + for row in &mut new.rows { + for cell in &mut row.cells { + cell.merge_width = 1; + cell.merge_height = 1; + } + } + let ranges = new.merged_ranges.clone(); + for range in &ranges { + if let Some(cell) = new + .rows + .get_mut(range.top_row) + .and_then(|row| row.cells.get_mut(range.left_col)) + { + cell.merge_width = range.col_count() as i32; + cell.merge_height = range.row_count() as i32; + } + } +} diff --git a/src/plugin/external.rs b/src/plugin/external.rs index 61de77be9..d644b907a 100644 --- a/src/plugin/external.rs +++ b/src/plugin/external.rs @@ -15,7 +15,8 @@ #![cfg_attr(target_arch = "wasm32", allow(dead_code))] -use std::path::PathBuf; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; /// One entry in the curated plugin registry (`plugins/registry.json`). #[derive(Debug, Clone)] @@ -68,6 +69,9 @@ pub struct ExternalPlugin { pub command_prefixes: Vec, /// The package directory under the plugins folder. pub dir: PathBuf, + /// True when the package ships inside the application rather than the + /// per-user plugin directory. + pub bundled: bool, /// Whether a native library for this platform sits beside `plugin.toml`. pub lib_present: bool, } @@ -155,6 +159,24 @@ pub fn plugins_dir() -> Option { Some(p) } +/// Read-only plugins shipped with the application. The environment override +/// makes development and packaging tests independent of a particular bundle +/// layout. +pub fn bundled_plugins_dir() -> Option { + if let Ok(p) = std::env::var("OCS_BUNDLED_PLUGINS_DIR") { + return Some(PathBuf::from(p)); + } + let exe = std::env::current_exe().ok()?; + #[cfg(target_os = "macos")] + { + return Some(exe.parent()?.parent()?.join("Resources").join("plugins")); + } + #[cfg(not(target_os = "macos"))] + { + Some(exe.parent()?.join("plugins")) + } +} + /// Delete an installed package's folder. It stays loaded for the current /// session (the library is resident); the removal takes effect on next start. #[cfg(not(target_arch = "wasm32"))] @@ -179,16 +201,33 @@ fn lib_extension() -> &'static str { } } -/// Discover every package under the plugins directory, sorted by `ribbon_order` -/// then id. Missing directory → empty list (not an error). +/// Discover bundled and per-user packages, sorted by `ribbon_order` then id. +/// A per-user package with the same id explicitly overrides the bundled copy. +/// Missing directories are ignored. pub fn discover() -> Vec { - let Some(root) = plugins_dir() else { - return Vec::new(); + discover_from_roots(bundled_plugins_dir().as_deref(), plugins_dir().as_deref()) +} + +fn discover_from_roots( + bundled_root: Option<&Path>, + user_root: Option<&Path>, +) -> Vec { + let mut found = BTreeMap::new(); + if let Some(root) = bundled_root { + discover_root(root, true, &mut found); + } + if let Some(root) = user_root { + discover_root(root, false, &mut found); + } + let mut found: Vec<_> = found.into_values().collect(); + found.sort_by(|a, b| a.ribbon_order.cmp(&b.ribbon_order).then(a.id.cmp(&b.id))); + found +} + +fn discover_root(root: &Path, bundled: bool, found: &mut BTreeMap) { + let Ok(entries) = std::fs::read_dir(root) else { + return; }; - let Ok(entries) = std::fs::read_dir(&root) else { - return Vec::new(); - }; - let mut found = Vec::new(); for entry in entries.flatten() { let dir = entry.path(); if !dir.is_dir() { @@ -206,11 +245,10 @@ pub fn discover() -> Vec { } p.lib_present = lib_present_in(&dir); p.dir = dir; - found.push(p); + p.bundled = bundled; + found.insert(p.id.clone(), p); } } - found.sort_by(|a, b| a.ribbon_order.cmp(&b.ribbon_order).then(a.id.cmp(&b.id))); - found } /// True when a file with this platform's dynamic-library extension exists in @@ -298,6 +336,7 @@ pub(crate) fn parse_plugin_toml(text: &str) -> Option { ribbon_order, command_prefixes, dir: PathBuf::new(), + bundled: false, lib_present: false, }) } @@ -531,6 +570,42 @@ xdata_apps = ["MYPLUGIN_RECORD"] assert!(parse_plugin_toml("name = \"x\"").is_none()); } + #[test] + fn discovers_bundled_plugins_and_allows_a_user_override() { + let base = std::env::temp_dir().join(format!( + "ocs-bundled-plugin-discovery-{}", + std::process::id() + )); + let bundled = base.join("bundled").join("opencad.python"); + let user = base.join("user").join("opencad.python"); + std::fs::create_dir_all(&bundled).unwrap(); + std::fs::create_dir_all(&user).unwrap(); + let manifest = |version: &str| { + format!( + "[plugin]\nid=\"opencad.python\"\nversion=\"{version}\"\n[opencad]\napi_version=7\n" + ) + }; + std::fs::write(bundled.join("plugin.toml"), manifest("bundled")).unwrap(); + std::fs::write(user.join("plugin.toml"), manifest("user")).unwrap(); + std::fs::write( + bundled.join(format!("plugin.{}", lib_extension())), + b"fixture", + ) + .unwrap(); + std::fs::write(user.join(format!("plugin.{}", lib_extension())), b"fixture").unwrap(); + + let bundled_only = discover_from_roots(Some(&base.join("bundled")), None); + assert_eq!(bundled_only.len(), 1); + assert!(bundled_only[0].bundled); + assert_eq!(bundled_only[0].version, "bundled"); + + let overridden = discover_from_roots(Some(&base.join("bundled")), Some(&base.join("user"))); + assert_eq!(overridden.len(), 1); + assert!(!overridden[0].bundled); + assert_eq!(overridden[0].version, "user"); + std::fs::remove_dir_all(base).unwrap(); + } + #[test] fn incompatible_api_flagged() { let p = parse_plugin_toml("id=\"a\"\napi_version = 9999").unwrap(); diff --git a/src/plugin/mod.rs b/src/plugin/mod.rs index a1965b8be..1ac31a2ab 100644 --- a/src/plugin/mod.rs +++ b/src/plugin/mod.rs @@ -1,6 +1,6 @@ -// Open CAD Studio plugin runtime. Plugins are external cdylibs loaded from the -// user plugins folder (see `external`) and installed via the marketplace; the -// host ships no built-in add-ons. See `docs/plugin-architecture.md`. +// Open CAD Studio plugin runtime. Plugins are out-of-process cdylibs loaded +// from the application bundle or user plugins folder (see `external`). +// See `docs/plugin-architecture.md`. pub mod external; pub mod host; @@ -81,4 +81,4 @@ mod tests { "expected an error queued for the command line, got: {queued:?}" ); } -} \ No newline at end of file +} diff --git a/src/plugin/v4_support.rs b/src/plugin/v4_support.rs index 59259f0fc..e8b7eab42 100644 --- a/src/plugin/v4_support.rs +++ b/src/plugin/v4_support.rs @@ -51,6 +51,16 @@ pub fn publish_selection_changed_v4(tab_id: u64, handles: Vec) broadcast(HostNotification::SelectionChangedV4 { tab_id, handles }); } +/// Broadcast a change in a tab's active interactive command. +pub fn publish_command_state_changed(tab_id: u64, command: Option) { + broadcast(HostNotification::CommandStateChanged { tab_id, command }); +} + +/// Broadcast drawing changes independently of shared-view subscriptions. +pub fn publish_drawing_changed(tab_id: u64, epoch: u64) { + broadcast(HostNotification::DrawingChanged { tab_id, epoch }); +} + /// V4 notification handler installed on the plugin manager. /// /// Forwards REPL status messages to the log; other notifications are handled diff --git a/src/scene/entity.rs b/src/scene/entity.rs index 01c7eed3f..0c9f315cc 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -543,6 +543,9 @@ impl Scene { let Some(existing) = self.document.get_entity(handle) else { return false; }; + // IPC snapshots omit storage-only payloads (notably Unknown/Extended + // entities). Preserve them when replacing a clone from a plugin. + entity.preserve_storage_data_from(existing); // The caller edited a snapshot copy; keep the live entity in its block. entity.common_mut().owner_handle = existing.common().owner_handle; diff --git a/src/scene/selection.rs b/src/scene/selection.rs index c07394e5d..7f1393b5e 100644 --- a/src/scene/selection.rs +++ b/src/scene/selection.rs @@ -158,6 +158,16 @@ impl Scene { self.bump_selection_set(); } + /// Script-facing replacement: preserve exactly the validated input order. + /// UI picks still use `select_entity` and its leader/annotation expansion. + pub(crate) fn replace_selection_exact(&mut self, handles: &[Handle]) { + if self.selected_handles_in_order() == handles { return; } + self.selected_constraint = None; + self.selected = handles.iter().copied().collect(); + self.selected_order = handles.to_vec(); + self.bump_selection_set(); + } + pub fn select_all_visible(&mut self) -> usize { let block = self.interaction_block_handle(); let frozen: Option> = self diff --git a/src/ui/window/plugin_manager.rs b/src/ui/window/plugin_manager.rs index a6c2637fa..f81d4a44e 100644 --- a/src/ui/window/plugin_manager.rs +++ b/src/ui/window/plugin_manager.rs @@ -309,11 +309,19 @@ fn external_card<'a>( actions = actions.push(toggle_button(&p.id, disabled)); actions = actions.push(Space::new().width(6)); } - actions = actions.push(pill_button( - t!("Uninstall"), - Message::PluginUninstall(p.id.clone()), - button::danger, - )); + if p.bundled { + actions = actions.push( + text(t!("Bundled with Open CAD Studio")) + .size(11) + .style(muted_style), + ); + } else { + actions = actions.push(pill_button( + t!("Uninstall"), + Message::PluginUninstall(p.id.clone()), + button::danger, + )); + } container(column![info, actions].spacing(8).padding([10, 12])) .width(Fill)