diff --git a/crates/ocs_plugin_api/build.rs b/crates/ocs_plugin_api/build.rs index 83eff3320..0f7e09cd0 100644 --- a/crates/ocs_plugin_api/build.rs +++ b/crates/ocs_plugin_api/build.rs @@ -17,7 +17,9 @@ 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}; +use entity_coverage_schema::{ + EntityCoverageCatalog, EntityKindCoverage, EntityScope, ModelAccess, PropertyCoverage, +}; #[derive(serde::Deserialize)] struct EntityCoveragePolicy { @@ -506,41 +508,71 @@ fn generate_type_registry(out_dir: &Path) { 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")) + 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 + .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}"); + 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}"); + 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}"); + 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 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}"); + 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(), + 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() { @@ -549,69 +581,203 @@ fn generate_entity_coverage(out_dir: &Path, registry: &TypeRegistry) { "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, + 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(), + } 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_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; } + 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 + 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 }) + ( + (*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")) { + 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" + ) + ) { "transaction_geometry" - } else { "type_conversion_only" }; + } 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, + 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 }); + 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()) + 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"); } diff --git a/crates/ocs_plugin_api/entity_coverage_policy.json b/crates/ocs_plugin_api/entity_coverage_policy.json index e4f372ee4..341449ebc 100644 --- a/crates/ocs_plugin_api/entity_coverage_policy.json +++ b/crates/ocs_plugin_api/entity_coverage_policy.json @@ -21,12 +21,14 @@ "Insert": ["layer", "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"] + "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"] }, "readable": { "Insert": ["block_name"], "Tolerance": ["dimension_style_handle"], - "Shape": ["style_handle"] + "Shape": ["style_handle"], + "AttributeEntity": ["attdef_handle"] }, "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 17a52851b..cbfa829d0 100644 --- a/crates/ocs_plugin_api/src/entity_coverage.rs +++ b/crates/ocs_plugin_api/src/entity_coverage.rs @@ -16,35 +16,49 @@ pub fn get_embedded_entity_coverage_json() -> &'static str { /// 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 { +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")) } + 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")) } + 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")) } + 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")) } + 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 @@ -52,7 +66,9 @@ fn insert_scale(name: &str, value: f64) -> Result<(), String> { #[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()); } + 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)?; @@ -64,17 +80,27 @@ pub fn validate_new_canvas_entity(entity: &crate::host::EntityType) -> Result<() } 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)?; } + ("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()); } + 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)?; } + ("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()); } @@ -83,47 +109,128 @@ pub fn validate_new_canvas_entity(entity: &crate::host::EntityType) -> Result<() 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.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()); } + 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.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 { + 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()); } + 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::AttributeDefinition(value) => { - finite_vector("AttributeDefinition.insertion_point", &value.insertion_point)?; - finite_vector("AttributeDefinition.alignment_point", &value.alignment_point)?; + 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()); + 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.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.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()); } - 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()); } } _ => {} } @@ -140,32 +247,58 @@ pub fn validate_entity_mutation( ) -> 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(_)) { + 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")) } + 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() + 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")) } + 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, + 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(()) } + 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)?; + finite3( + "Point.location", + new.location.x, + new.location.y, + new.location.z, + )?; } } (EntityType::Line(old), EntityType::Line(new)) => { @@ -180,21 +313,29 @@ pub fn validate_entity_mutation( 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)?; } + 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)?; } + 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)?; } + 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)?; } + 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 [ @@ -202,8 +343,12 @@ pub fn validate_entity_mutation( ("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)?; } + ] { + 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()); } @@ -214,24 +359,34 @@ pub fn validate_entity_mutation( ("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 { + ] { + 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 { + 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)?; } + 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)?; } + if before.to_bits() != after.to_bits() { + insert_scale(&format!("Insert.{name}"), after)?; + } } for (name, before, after) in [ ("rotation", old.rotation, new.rotation), @@ -242,68 +397,123 @@ pub fn validate_entity_mutation( 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) { + 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() { + 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) { + && (!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() { + 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 { + && 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) { + 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) { + 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.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) { + && (!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) { + && (!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_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()) { + && !(old.style_name != new.style_name && new.style_handle.is_some()) + { return Err("Shape.style_handle is read-only".into()); } } (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()); + 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.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()); + 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), @@ -314,12 +524,16 @@ pub fn validate_entity_mutation( } } if old.width_factor.to_bits() != new.width_factor.to_bits() - && (!new.width_factor.is_finite() || new.width_factor.abs() < 1e-12) { + && (!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()); + && 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()); @@ -334,6 +548,68 @@ pub fn validate_entity_mutation( 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()); + } + } _ => {} } Ok(()) @@ -349,7 +625,10 @@ pub fn validate_canvas_entity_references( 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 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 { @@ -357,31 +636,132 @@ pub fn validate_canvas_entity_references( } } 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), + 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 !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()))) + 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)); } + 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)); + } } 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())?; + 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))?; + 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)); + 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(()) @@ -397,16 +777,69 @@ pub fn bind_canvas_entity_references( ) -> 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())) + 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::Shape(value) if value.style_handle.filter(|h| !h.is_null()).is_none() => { - value.style_handle = document.text_styles.iter() + 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) @@ -415,11 +848,22 @@ pub fn bind_canvas_entity_references( /// 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 { +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(_)) { + 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(); @@ -434,23 +878,46 @@ mod tests { #[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 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)); + 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); + 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")); + assert!(matches!( + property.validation.as_str(), + "type_conversion_only" | "transaction_geometry" | "transaction_nonempty" + )); } } } @@ -458,69 +925,156 @@ mod tests { #[test] fn unsupported_kinds_have_no_editable_properties() { - let catalog: EntityCoverageCatalog = serde_json::from_str(get_embedded_entity_coverage_json()).unwrap(); + 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") { - assert!(entry.properties.iter().all(|p| p.model_access != ModelAccess::ReadWrite - || (entry.scope == EntityScope::Canvas && p.name == "layer")), "{}", entry.kind); + 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" + ) + { + 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() + 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))) + .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(), + 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(), + "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(), + "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.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.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(), + ]) + ); } #[cfg(feature = "host")] @@ -529,18 +1083,26 @@ mod tests { 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"); + 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")); + 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")); + &document, + &acadrust::EntityType::Tolerance(tolerance) + ) + .unwrap_err() + .contains("does not exist")); } #[cfg(feature = "host")] @@ -555,26 +1117,51 @@ mod tests { 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); + 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!() }; + 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")); + 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); + 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); + 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"); } @@ -583,13 +1170,38 @@ 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(); + 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); + 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")] @@ -598,7 +1210,10 @@ mod tests { 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(); + let fields = snapshot + .get("Hatch") + .and_then(|value| value.as_object()) + .unwrap(); assert!(fields.contains_key("common")); assert!(fields.len() > 1); } @@ -614,13 +1229,16 @@ mod tests { assert!(validate_entity_mutation( &acadrust::EntityType::Circle(before.clone()), &acadrust::EntityType::Circle(layer_only), - ).is_ok()); + ) + .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")); + ) + .unwrap_err() + .contains("Circle.radius")); } #[cfg(feature = "host")] @@ -629,7 +1247,11 @@ mod tests { 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")); + 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()); @@ -638,19 +1260,41 @@ mod tests { 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")); + 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 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")); + 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 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")); + assert!(validate_entity_mutation( + &acadrust::EntityType::Face3D(face), + &acadrust::EntityType::Face3D(changed) + ) + .unwrap_err() + .contains("Face3D.invisible_edges")); } #[cfg(feature = "host")] @@ -661,20 +1305,35 @@ mod tests { 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()); + 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")); + 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")); + 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")); + assert!(validate_entity_mutation( + &acadrust::EntityType::Insert(before), + &acadrust::EntityType::Insert(bad) + ) + .unwrap_err() + .contains("block identity")); } #[cfg(feature = "host")] @@ -694,7 +1353,11 @@ mod tests { #[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}}; + 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); @@ -708,14 +1371,21 @@ mod tests { 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(); + 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(); + document + .add_entity(acadrust::EntityType::BlockEnd(end)) + .unwrap(); let mut definition = acadrust::entities::AttributeDefinition::new( - "PART_NO".into(), "Part number".into(), "PN-001".into()); + "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()); @@ -724,16 +1394,25 @@ mod tests { definition.common.owner_handle = Handle::NULL; assert!(validate_canvas_entity_references( - &document, &acadrust::EntityType::AttributeDefinition(definition.clone())) - .unwrap_err().contains("block-record owner")); + &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")); + &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")); + assert!( + validate_new_canvas_entity(&acadrust::EntityType::AttributeDefinition(definition)) + .unwrap_err() + .contains("no whitespace") + ); } } diff --git a/crates/ocs_plugin_api/src/ipc/server.rs b/crates/ocs_plugin_api/src/ipc/server.rs index ffa64e6e6..ee96ceac9 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, @@ -77,9 +98,7 @@ pub fn handle_plugin_request( DocumentPath { tab_id } => PluginResponse::DocumentPath( host.document_path(tab_id).map(|path| path.into_os_string()), ), - GetSystemVariable { name } => { - PluginResponse::SystemVariable(host.system_variable(&name)) - } + GetSystemVariable { name } => PluginResponse::SystemVariable(host.system_variable(&name)), SetSystemVariable { name, value } => { PluginResponse::SystemVariableResult(host.set_system_variable(&name, value)) } 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/src/app/plugin_host.rs b/src/app/plugin_host.rs index 17984346f..d60531410 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -7,9 +7,9 @@ use acadrust::xdata::ExtendedDataRecord; 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); } @@ -104,6 +150,33 @@ impl<'a> HostSession<'a> { } pub fn add_entity(&mut self, entity: EntityType) -> Handle { + 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 @@ -122,7 +195,10 @@ 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); + 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(); } @@ -135,29 +211,37 @@ impl<'a> HostSession<'a> { 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(()); } + 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.document().get_entity(handle) else { + 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) { + 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) + ocs_plugin_api::entity_coverage::bind_canvas_entity_references(self.document(), entity) .map_err(|error| format!("entity {handle:?}: {error}"))?; - if self.app.tabs[self.tab].scene.is_layer_locked(handle) { + ocs_plugin_api::entity_coverage::validate_entity_mutation(&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")); } } @@ -165,7 +249,11 @@ impl<'a> HostSession<'a> { 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)); + 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(); @@ -186,15 +274,23 @@ impl<'a> HostSession<'a> { return Err(format!("entity {handle:?} does not exist")); } } - if self.selection() == handles { return Ok(()); } + 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())); + 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()); + crate::plugin::v4_support::publish_selection_changed_v4( + self.tab_id(), + self.selection(), + ); } Ok(()) } @@ -204,7 +300,24 @@ impl<'a> HostSession<'a> { /// (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; } self.app.tabs[self.tab].scene.erase_entities(&[handle]); let removed = self.document().get_entity(handle).is_none(); @@ -274,7 +387,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; }; @@ -355,10 +472,9 @@ impl<'a> HostSession<'a> { ("SNAPANG", HostSettingValue::Number(_)) => { Err("SNAPANG: finite number required".to_owned()) } - ("CLAYER", _) | ("SNAPANG", _) => Err(format!( - "{}: wrong value type", - name.to_ascii_uppercase() - )), + ("CLAYER", _) | ("SNAPANG", _) => { + Err(format!("{}: wrong value type", name.to_ascii_uppercase())) + } _ => Err(format!("unsupported system variable {name:?}")), } } @@ -400,11 +516,19 @@ 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> { + 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 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) } @@ -589,10 +713,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); self.refresh(); @@ -666,6 +787,114 @@ mod tests { 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(); @@ -686,32 +915,59 @@ mod tests { 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)))); + 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; } + 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()); + 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")); + 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)); + 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)); + 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)); + 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 @@ -720,9 +976,15 @@ mod tests { /// 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 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()); + 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()); @@ -731,63 +993,120 @@ mod tests { 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"); + &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")); + assert!(process + .dispatch(host, command, &mut |_| {}) + .expect("Python dispatch")); }; - dispatch(&mut host, concat!( + 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(); + ), + ); + 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)); + 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!() }; + 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"); + 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")); + 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"); + 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"); + .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"); + .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"); + 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)); + 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)); + 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); @@ -796,7 +1115,9 @@ mod tests { #[test] fn staged_python_tolerance_lifecycle_over_real_ipc() { - let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { return; }; + 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()); @@ -804,32 +1125,64 @@ mod tests { 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, + std::path::Path::new(&plugin_path), + &mut host, crate::plugin::v4_support::notification_handler(), - ).expect("spawn staged Python plugin"); + ) + .expect("spawn staged Python plugin"); let dispatch = |host: &mut HostSession<'_>, command: &str| { - assert!(process.dispatch(host, command, &mut |_| {}).expect("Python dispatch")); + assert!(process + .dispatch(host, command, &mut |_| {}) + .expect("Python dispatch")); }; - dispatch(&mut host, concat!( + 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 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.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")); + 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!( + "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", @@ -837,55 +1190,106 @@ mod tests { " tolerance.direction = (0.0, 1.0, 0.0)\n", " tolerance.text = 'POSITION%%v0.2'\n", "doc.selection = [tolerance]\n" - ), handle.value())).unwrap(); + ), + 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)); + 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.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"); + 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")); + 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")); + 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 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")); + .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())); + 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)); + 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")); + 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); @@ -894,14 +1298,19 @@ mod tests { #[test] fn staged_python_shape_lifecycle_over_real_ipc() { - let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { return; }; + 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 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()); } + 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(); @@ -916,31 +1325,60 @@ mod tests { host.document_mut().text_styles.add(style).unwrap(); let process = ocs_plugin_api::process::PluginProcess::spawn( - std::path::Path::new(&plugin_path), &mut host, + std::path::Path::new(&plugin_path), + &mut host, crate::plugin::v4_support::notification_handler(), - ).expect("spawn staged Python plugin"); + ) + .expect("spawn staged Python plugin"); let dispatch = |host: &mut HostSession<'_>, command: &str| { - assert!(process.dispatch(host, command, &mut |_| {}).expect("Python dispatch")); + assert!(process + .dispatch(host, command, &mut |_| {}) + .expect("Python dispatch")); }; - dispatch(&mut host, concat!( + 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 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")); + 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!( + "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", @@ -949,12 +1387,21 @@ mod tests { " shape.rotation = 0.5\n", " shape.relative_x_scale = 1.5\n", "doc.selection = [shape]\n" - ), handle.value())).unwrap(); + ), + 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)); + 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); @@ -968,25 +1415,47 @@ mod tests { assert_eq!(host.selection(), vec![handle]); let assert_real_glyph = |entity: &EntityType, document: &CadDocument| { - let EntityType::Shape(shape) = entity else { panic!("expected Shape"); }; + 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 { + 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_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!( + dispatch( + &mut host, + &format!( "PY_EVAL ocs.update_many('Reject size', [{{'handle':{},'size':0.0}}])", - handle.value())); + handle.value() + ), + ); assert_eq!(host.document().get_entity(handle), Some(&expected)); - dispatch(&mut host, &format!( + dispatch( + &mut host, + &format!( "PY_EVAL ocs.update_many('Reject style', [{{'handle':{},'style_name':'Missing'}}])", - handle.value())); + handle.value() + ), + ); assert_eq!(host.document().get_entity(handle), Some(&expected)); - assert!(host.app.command_line.history.last().unwrap().text.contains("does not exist")); + 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(); @@ -994,14 +1463,22 @@ mod tests { 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_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())); + 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); @@ -1009,10 +1486,15 @@ mod tests { 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)); + 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)); + 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); @@ -1021,7 +1503,9 @@ mod tests { #[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 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()); @@ -1038,52 +1522,88 @@ mod tests { 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); + 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(); + 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(); + 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(); + "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, + std::path::Path::new(&plugin_path), + &mut host, crate::plugin::v4_support::notification_handler(), - ).expect("spawn staged Python plugin"); + ) + .expect("spawn staged Python plugin"); let dispatch = |host: &mut HostSession<'_>, command: &str| { - assert!(process.dispatch(host, command, &mut |_| {}).expect("Python dispatch")); + assert!(process + .dispatch(host, command, &mut |_| {}) + .expect("Python dispatch")); }; - dispatch(&mut host, &format!(concat!( + 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() + ), + 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 { + .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)); + 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())); + 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!( + "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", @@ -1092,12 +1612,21 @@ mod tests { " definition.rotation = 0.25\n", " definition.width_factor = 1.25\n", "doc.selection = [definition]\n" - ), handle.value())).unwrap(); + ), + 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)); + 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); @@ -1108,15 +1637,22 @@ mod tests { 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")); + 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 { + 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() + assert!(strokes.iter().any(|stroke| stroke + .run + .as_ref() .is_some_and(|run| run.text.contains("PN-002")))); for (label, patch, message) in [ @@ -1125,32 +1661,59 @@ mod tests { ("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())); + 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)); + 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)) + 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:?}"); + && (value.rotation - 0.25).abs() < 1e-12), + "{label}: {actual:?}" + ); if full_text_state { - assert!(matches!(actual, Some(EntityType::AttributeDefinition(value)) + assert!( + matches!(actual, Some(EntityType::AttributeDefinition(value)) if value.width_factor == 1.25 && value.text_style == "Standard" - && value.prompt == "Part number"), "{label}: {actual:?}"); + && 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")); + 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_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 @@ -1159,24 +1722,42 @@ mod tests { // 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())); + 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()); + 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)); + 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)) @@ -1187,9 +1768,252 @@ mod tests { 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_point_pick_and_cancel_over_real_ipc() { - let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { return; }; + 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()); @@ -1197,38 +2021,70 @@ mod tests { 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, + 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")) + ) + .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 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()); + 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()) + 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, - ))); + 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 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!(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")); + assert!(app + .command_line + .history + .last() + .unwrap() + .text + .contains("point")); let target = { let mut host = HostSession::new(&mut app, 0); @@ -1239,79 +2095,192 @@ mod tests { }; 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() + 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!(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())); + 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!(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")); + 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 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 + .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, + 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")) + ) + .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()); + 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 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!(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)); + 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!(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")); + 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!(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")); + 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(); + 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)); @@ -1320,40 +2289,88 @@ mod tests { 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()); + 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; } + 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}"); + 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}"); + 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(); + 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()); + 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}"); + 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(); + 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!(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"); + assert!( + app.command_line.history.last().unwrap().text.contains("[]"), + "closing a tab must discard its queued events" + ); } #[test] @@ -1365,14 +2382,28 @@ mod tests { 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"); + 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"); + assert_eq!( + app.tabs[0] + .scene + .document + .get_entity(handle) + .unwrap() + .common() + .layer, + "0" + ); } #[test] @@ -1382,22 +2413,30 @@ mod tests { 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)); + 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")); + 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")); + 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] @@ -1405,28 +2444,56 @@ mod tests { 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()))); + 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.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))); + 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] @@ -1524,7 +2591,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, @@ -1628,12 +2698,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. @@ -1705,8 +2781,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); @@ -1756,8 +2833,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(