From 11d561499198b406b44a69a159c615090b2ac82a Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Sat, 19 Sep 2026 01:13:24 +0200 Subject: [PATCH] Add versioned scripting host model and entity coverage --- crates/ocs_plugin_api/Cargo.toml | 3 +- crates/ocs_plugin_api/build.rs | 113 +++++- .../entity_coverage_policy.json | 28 ++ crates/ocs_plugin_api/src/entity_coverage.rs | 342 ++++++++++++++++++ .../src/entity_coverage_types.rs | 52 +++ crates/ocs_plugin_api/src/host.rs | 45 +++ crates/ocs_plugin_api/src/ipc/client.rs | 34 ++ crates/ocs_plugin_api/src/ipc/protocol.rs | 10 + crates/ocs_plugin_api/src/ipc/server.rs | 5 + crates/ocs_plugin_api/src/ipc/v4/client.rs | 34 ++ crates/ocs_plugin_api/src/ipc/v4/protocol.rs | 31 ++ crates/ocs_plugin_api/src/lib.rs | 4 + crates/ocs_plugin_api/src/manifest.rs | 5 +- crates/ocs_plugin_api/src/process.rs | 10 + crates/ocs_plugin_api/src/process/v4.rs | 1 + crates/ocs_plugin_api/src/runner.rs | 8 + docs/plugin-host-model.md | 88 +++++ src/app/plugin_host.rs | 167 +++++++++ src/app/update/mod.rs | 16 + src/plugin/v4_support.rs | 10 + src/scene/entity.rs | 3 + src/scene/selection.rs | 10 + 22 files changed, 1015 insertions(+), 4 deletions(-) create mode 100644 crates/ocs_plugin_api/entity_coverage_policy.json create mode 100644 crates/ocs_plugin_api/src/entity_coverage.rs create mode 100644 crates/ocs_plugin_api/src/entity_coverage_types.rs create mode 100644 docs/plugin-host-model.md diff --git a/crates/ocs_plugin_api/Cargo.toml b/crates/ocs_plugin_api/Cargo.toml index fb55f5254..d0d28766c 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/build.rs b/crates/ocs_plugin_api/build.rs index d8ed9a7b0..765360e34 100644 --- a/crates/ocs_plugin_api/build.rs +++ b/crates/ocs_plugin_api/build.rs @@ -14,6 +14,18 @@ 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 EntityCoveragePolicy { + internal_kinds: Vec, + opaque_kinds: Vec, + editable: BTreeMap>, + aliases: BTreeMap>, +} fn main() { let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap()); @@ -23,6 +35,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 +500,103 @@ 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()) { + 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 mut unresolved: HashSet = editable.iter().cloned().collect(); + assert_eq!(unresolved.len(), editable.len(), "duplicate editable 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), + "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().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(), ModelAccess::ReadWrite) + } + 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")) { + "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, + }); + } + 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 +1019,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": 6, + "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..513af55a4 --- /dev/null +++ b/crates/ocs_plugin_api/entity_coverage_policy.json @@ -0,0 +1,28 @@ +{ + "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"] + }, + "aliases": { + "Polyline": {"closed": "flags"}, + "Polyline2D": {"closed": "flags"}, + "Text": {"text": "value", "insertion": "insertion_point"}, + "MText": {"text": "value", "insertion": "insertion_point"} + } +} 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..5634c542f --- /dev/null +++ b/crates/ocs_plugin_api/src/entity_coverage.rs @@ -0,0 +1,342 @@ +//! 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")) } +} + +/// 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()); } + 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()); + } + } + _ => {} + } + 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(()) } + }; + 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()); + } + } + _ => {} + } + Ok(()) +} + +/// 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)); + 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 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") { + 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(), + ])); + } + + #[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"); + } + } + } + + #[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 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()); + } +} 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 4dad141b4..b9a716dbd 100644 --- a/crates/ocs_plugin_api/src/host.rs +++ b/crates/ocs_plugin_api/src/host.rs @@ -83,6 +83,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. @@ -133,6 +137,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) @@ -170,6 +188,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)), } } @@ -575,6 +599,27 @@ pub trait HostApi { ) -> 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()) + } } /// Simplified, read-only entity kind exposed by [`DocumentReader`]. diff --git a/crates/ocs_plugin_api/src/ipc/client.rs b/crates/ocs_plugin_api/src/ipc/client.rs index b78d90e61..744232162 100644 --- a/crates/ocs_plugin_api/src/ipc/client.rs +++ b/crates/ocs_plugin_api/src/ipc/client.rs @@ -444,6 +444,40 @@ impl HostApi for PluginHostApi { 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 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()), + } + } } /// Sentinel reader used when the shared-memory view could not be initialized. diff --git a/crates/ocs_plugin_api/src/ipc/protocol.rs b/crates/ocs_plugin_api/src/ipc/protocol.rs index 8dcc7c588..d6bd60a59 100644 --- a/crates/ocs_plugin_api/src/ipc/protocol.rs +++ b/crates/ocs_plugin_api/src/ipc/protocol.rs @@ -76,6 +76,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`. @@ -142,6 +144,11 @@ pub enum PluginRequest { 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 }, } /// Responses the host sends back for `PluginRequest`. @@ -170,6 +177,9 @@ pub enum PluginResponse { DocumentPath(Option), SystemVariable(Option), SystemVariableResult(Result), + EntityTransactionResult(Result<(), String>), + Selection(Vec), + SelectionResult(Result<(), String>), } /// 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 ff1c9e510..ffa64e6e6 100644 --- a/crates/ocs_plugin_api/src/ipc/server.rs +++ b/crates/ocs_plugin_api/src/ipc/server.rs @@ -83,5 +83,10 @@ pub fn handle_plugin_request( 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)), } } diff --git a/crates/ocs_plugin_api/src/ipc/v4/client.rs b/crates/ocs_plugin_api/src/ipc/v4/client.rs index c77202060..1de1631ae 100644 --- a/crates/ocs_plugin_api/src/ipc/v4/client.rs +++ b/crates/ocs_plugin_api/src/ipc/v4/client.rs @@ -758,6 +758,40 @@ impl HostApi for V4PluginHostApi { 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 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()), + } + } } #[cfg(test)] diff --git a/crates/ocs_plugin_api/src/ipc/v4/protocol.rs b/crates/ocs_plugin_api/src/ipc/v4/protocol.rs index 34e97c2e9..4b6ddd583 100644 --- a/crates/ocs_plugin_api/src/ipc/v4/protocol.rs +++ b/crates/ocs_plugin_api/src/ipc/v4/protocol.rs @@ -86,6 +86,37 @@ 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 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 5edc5671e..8bf2ee892 100644 --- a/crates/ocs_plugin_api/src/manifest.rs +++ b/crates/ocs_plugin_api/src/manifest.rs @@ -20,8 +20,9 @@ /// a multiplexed socket while leaving the V2/V3 ABI and protocol untouched. v5 /// adds the `BuiltinPlugin::on_load` lifecycle callback and /// `HostApi::document_path`. v6 appends generic host system-variable access -/// (`HostApi::system_variable` / `set_system_variable`). -pub const API_VERSION: u32 = 6; +/// (`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 diff --git a/crates/ocs_plugin_api/src/process.rs b/crates/ocs_plugin_api/src/process.rs index 7535af350..b77ca97ff 100644 --- a/crates/ocs_plugin_api/src/process.rs +++ b/crates/ocs_plugin_api/src/process.rs @@ -237,6 +237,7 @@ fn request_kind(req: &HostRequest) -> &'static str { HostRequest::GetPrompt { .. } => "GetPrompt", HostRequest::NeedsEntityPick { .. } => "NeedsEntityPick", HostRequest::ExecuteCode { .. } => "ExecuteCode", + HostRequest::DropInteractive { .. } => "DropInteractive", HostRequest::Shutdown => "Shutdown", } } @@ -599,6 +600,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 05e7dd490..fbf5edf3b 100644 --- a/crates/ocs_plugin_api/src/process/v4.rs +++ b/crates/ocs_plugin_api/src/process/v4.rs @@ -71,6 +71,7 @@ fn request_kind(req: &HostRequest) -> &'static str { HostRequest::GetPrompt { .. } => "GetPrompt", HostRequest::NeedsEntityPick { .. } => "NeedsEntityPick", HostRequest::ExecuteCode { .. } => "ExecuteCode", + HostRequest::DropInteractive { .. } => "DropInteractive", HostRequest::Shutdown => "Shutdown", } } diff --git a/crates/ocs_plugin_api/src/runner.rs b/crates/ocs_plugin_api/src/runner.rs index b1d89ab0c..0a9ff9d95 100644 --- a/crates/ocs_plugin_api/src/runner.rs +++ b/crates/ocs_plugin_api/src/runner.rs @@ -191,6 +191,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(); @@ -290,6 +294,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/docs/plugin-host-model.md b/docs/plugin-host-model.md new file mode 100644 index 000000000..1eea48b33 --- /dev/null +++ b/docs/plugin-host-model.md @@ -0,0 +1,88 @@ +# Scripting host model (API v7) + +The host owns the drawing. Plugins can read the full `CadDocument` snapshot and +commit cloned `EntityType` values through IPC. `update_entities_transaction` +validates every replacement before changing the drawing, records one undo +entry, refreshes scene caches and the shared document view, and marks the +document dirty. The request preserves handles, owners, and entity kinds. A +missing or duplicate handle, a kind/owner change, or a locked source layer +rejects the whole batch. An empty batch does nothing. + +This is a full-entity replacement API, not a promise that every CAD property +is editable through Python. A client should clone the current value and patch +only fields it understands; unknown fields remain intact. The host can read +all `acadrust::EntityType` variants through `document()`. Its generic write +path accepts existing variants subject to the validation above. Entity +creation and deletion still use `add_entity` and `remove_entity` and are not +part of this transaction request. + +The host embeds a versioned 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`). Each property records its source field, type, optional/sequence +shape, snapshot readability, Python access (`read_write`, `read_only`, or +`unmapped`), and validation status. `unmapped` means the typed snapshot carries +the field but the Python document model has no getter or setter for it. +The v7 transaction path checks changed Point locations, Line endpoints, +Circle/Arc centers, Ray/XLine base points, and Solid/Face3D corners for finite +coordinates. Circle/Arc radii must be finite and positive; Ray/XLine directions +must be unit vectors; Solid normals must be nonzero, its thickness finite, +and Face3D invisible-edge flags limited to four known bits. Those fields report `transaction_geometry` in the +catalog. All 43 canvas kinds also support nonempty `layer` changes through +the same undoable transaction; internal and opaque variants remain read-only +through the document model. The adapter accepts only `layer` patches for +canvas kinds outside its 16 geometry converters. Other writable properties +still report `type_conversion_only`; broader CAD range and cross-property +checks remain outstanding. +The host's `entity_snapshot` helper serializes any typed entity as a detached +JSON value keyed by its catalog variant name. This read path carries every +serializable field, including nested and unmapped values, without granting a +write path for them. The Python adapter exposes its variant payload through +`entity.snapshot`; changing that dictionary does not change the drawing. +It is an inspection view: JSON converts non-finite floating-point values to +null and cannot be used as a lossless replacement entity. + +The separate PandoraBox adapter currently maps these entity kinds to Python +dictionaries: Point, Line, Circle, Arc, Ellipse, Polyline, Polyline2D, +Polyline3D, LwPolyline, Spline, Text, MText, Ray, XLine, Solid, and Face3D. The authoritative per-field mapping +is `plugins/opencad-python/entity_manifest.json` in PandoraBox. The generated +mapping excludes fields it cannot represent, including common color, line +weight, transparency, Polyline3D smooth type, and MText background color. +Absent geometry fields are not editable through the Python document model. +A feature test compares the generated Python keys with the +host catalog. The adapter's `experimental-host-model` feature requires API +v7; its portable default remains pinned to H7's v5 contract. + +API v7 also exposes a synchronous, ordered selection query and an atomic +selection replacement scoped to the session tab. All handles must exist before +replacement begins, duplicate handles are rejected, and the scripted +replacement keeps exactly the requested handles and order. UI picks continue +to group linked leaders and annotations. The V4 notification channel still broadcasts best-effort +document and selection changes. A new `DrawingChanged` notification carries +the scene epoch even when no shared document view is open. A new `CommandStateChanged` notification +reports transitions of the active interactive command at app message +boundaries, with the tab id and command name (or `None` when it ends). A +noninteractive command that starts and finishes within one message does not +produce an active-state transition. + +The host's existing `InteractiveCommand` machinery collects either a point or +an entity pick. PandoraBox's v7 adapter now exposes a token-based request and +poll API because `PY_RUN` uses a fresh Python interpreter per invocation; a +script cannot suspend in place while the user clicks. Enter and command +cancellation report a cancelled result. The host releases the runner's pick +command when it ends, including Escape cancellation. Drawing, selection, and command +notifications are retained in a bounded 256-event queue per tab between runs +and can be drained with `doc.poll_events()`. If a tab exceeds that bound, the +next poll begins with `{"type": "overflow", "dropped": n}`; scripts should +refresh drawing and selection state when they receive it. Closing a tab clears +its pending events, and a script can poll only its active tab. +Input tokens are bound to the drawing tab that requested the pick. Polling a +token from another tab returns no result and does not consume it. + +Python `doc.entities[handle]` returns a descriptor for every entity. For kinds +outside the 16-kind generated schema it contains only handle, kind, and layer; +`layer` is editable on canvas kinds. `doc.coverage()` enumerates the +catalog; `doc.coverage("Line")` and `line.coverage` return one entry. For +covered kinds, the coverage object lists actual readable and editable +dictionary keys, plus the unmapped snapshot fields and their types. diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index b572786ce..f217de115 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -93,6 +93,10 @@ impl<'a> HostSession<'a> { } #[cfg(not(target_arch = "wasm32"))] { + 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)); @@ -125,6 +129,73 @@ impl<'a> HostSession<'a> { ok } + pub fn update_entities_transaction( + &mut self, + label: &str, + 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 &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.document().get_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::validate_entity_mutation(existing, entity) + .map_err(|error| format!("entity {handle:?}: {error}"))?; + if self.app.tabs[self.tab].scene.is_layer_locked(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. + assert!(self.app.tabs[self.tab].scene.update_entity(entity)); + } + 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). @@ -188,6 +259,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 } @@ -225,6 +298,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 } @@ -322,6 +397,11 @@ 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 remove_entity(&mut self, handle: Handle) -> bool { self.remove_entity(handle) } @@ -489,6 +569,12 @@ impl PluginProcessInteractiveAdapter { } } +impl Drop for PluginProcessInteractiveAdapter { + fn drop(&mut self) { + let _ = self.process.drop_interactive(self.command_id); + } +} + impl crate::command::CadCommand for PluginProcessInteractiveAdapter { fn name(&self) -> &'static str { "PLUGIN" @@ -559,6 +645,87 @@ mod tests { 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 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)); + } + + #[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::Hatch(acadrust::entities::Hatch::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 system_variables_change_without_command_reentry() { let mut app = OpenCADStudio::new_for_test(); diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index e0f9a23a4..d498abbac 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -276,6 +276,7 @@ impl OpenCADStudio { 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); } @@ -305,7 +306,22 @@ impl OpenCADStudio { return Task::none(); } } + #[cfg(not(target_arch = "wasm32"))] + let command_before = self.tabs.get(self.active_tab).map(|tab| { + (tab.id, tab.active_cmd.as_ref().map(|command| command.name().to_owned())) + }); let task = self.update_inner(msg); + #[cfg(not(target_arch = "wasm32"))] + { + let command_after = self.tabs.get(self.active_tab).map(|tab| { + (tab.id, tab.active_cmd.as_ref().map(|command| command.name().to_owned())) + }); + if command_before != command_after { + if let Some((tab_id, command)) = command_after { + crate::plugin::v4_support::publish_command_state_changed(tab_id, command); + } + } + } self.refresh_gpu_status(); self.show_next_startup_modal(); self.sync_open_command_history(); 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 8a5ee23f4..b5a66886c 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -525,6 +525,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 4ebab412c..f5684e31f 100644 --- a/src/scene/selection.rs +++ b/src/scene/selection.rs @@ -144,6 +144,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