diff --git a/crates/ocs_plugin_api/build.rs b/crates/ocs_plugin_api/build.rs index 765360e34..782b81cce 100644 --- a/crates/ocs_plugin_api/build.rs +++ b/crates/ocs_plugin_api/build.rs @@ -24,6 +24,7 @@ struct EntityCoveragePolicy { internal_kinds: Vec, opaque_kinds: Vec, editable: BTreeMap>, + readable: BTreeMap>, aliases: BTreeMap>, } @@ -510,7 +511,7 @@ fn generate_entity_coverage(out_dir: &Path, registry: &TypeRegistry) { 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.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 { @@ -532,8 +533,9 @@ fn generate_entity_coverage(out_dir: &Path, registry: &TypeRegistry) { 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 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(), @@ -563,13 +565,13 @@ fn generate_entity_coverage(out_dir: &Path, registry: &TypeRegistry) { let shape = ®istry.types[&shape_type]; for field in &shape.fields { if field.name == "common" { continue; } - let exposed = editable.iter().find(|name| { + 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(), ModelAccess::ReadWrite) + ((*name).clone(), if editable.contains(name) { ModelAccess::ReadWrite } else { ModelAccess::ReadOnly }) } None => (field.name.clone(), ModelAccess::Unmapped), }; @@ -579,7 +581,9 @@ fn generate_entity_coverage(out_dir: &Path, registry: &TypeRegistry) { ("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")) { + ("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")) { "transaction_geometry" } else { "type_conversion_only" }; properties.push(PropertyCoverage { diff --git a/crates/ocs_plugin_api/entity_coverage_policy.json b/crates/ocs_plugin_api/entity_coverage_policy.json index 513af55a4..cff46a247 100644 --- a/crates/ocs_plugin_api/entity_coverage_policy.json +++ b/crates/ocs_plugin_api/entity_coverage_policy.json @@ -17,7 +17,11 @@ "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"] + "Face3D": ["layer", "first_corner", "second_corner", "third_corner", "fourth_corner", "invisible_edges"], + "Insert": ["layer", "insert_point", "x_scale", "y_scale", "z_scale", "rotation", "normal", "column_count", "row_count", "column_spacing", "row_spacing"] + }, + "readable": { + "Insert": ["block_name"] }, "aliases": { "Polyline": {"closed": "flags"}, diff --git a/crates/ocs_plugin_api/src/entity_coverage.rs b/crates/ocs_plugin_api/src/entity_coverage.rs index 5634c542f..3f8131c1d 100644 --- a/crates/ocs_plugin_api/src/entity_coverage.rs +++ b/crates/ocs_plugin_api/src/entity_coverage.rs @@ -41,6 +41,12 @@ fn solid_normal(name: &str, value: &crate::host::acadrust::types::Vector3) -> Re 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")) } +} + /// 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")] @@ -167,6 +173,34 @@ pub fn validate_entity_mutation( 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()); + } + } _ => {} } Ok(()) @@ -222,7 +256,7 @@ mod tests { 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") { + "Ray" | "XLine" | "Solid" | "Face3D" | "Insert") { assert!(entry.properties.iter().all(|p| p.model_access != ModelAccess::ReadWrite || (entry.scope == EntityScope::Canvas && p.name == "layer")), "{}", entry.kind); } @@ -249,6 +283,11 @@ mod tests { "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(), ])); } @@ -265,6 +304,17 @@ mod tests { } } + #[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(); + assert_eq!(block_name.model_access, ModelAccess::ReadOnly); + assert_eq!(block_name.validation, "none"); + 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() { @@ -326,6 +376,30 @@ mod tests { .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() { diff --git a/docs/plugin-host-model.md b/docs/plugin-host-model.md index 1eea48b33..37bcb69f2 100644 --- a/docs/plugin-host-model.md +++ b/docs/plugin-host-model.md @@ -28,11 +28,14 @@ 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 +and Face3D invisible-edge flags limited to four known bits. Insert transactions +validate placement, finite rotation and spacing, nonzero finite scale and normal, +and positive array counts. They reject changes to the referenced block and +attached attribute and sequence records. 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 +canvas kinds outside its 17 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 @@ -45,7 +48,11 @@ 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 +Polyline3D, LwPolyline, Spline, Text, MText, Ray, XLine, Solid, Face3D, and +Insert. Insert is update-only: the block name is readable but cannot be +changed, and Python creation is withheld until block-table references can be +established safely. Attached attributes remain available in the raw snapshot. +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. @@ -81,7 +88,7 @@ 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; +outside the 17-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 diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index f217de115..907a24dd5 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -726,6 +726,31 @@ mod tests { 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();