Add validated Insert transform scripting coverage

This commit is contained in:
Felix Riestra committed 2026-09-19 01:20:20 +02:00
1 parent 11d5614991
commit db61379bfe
5 files changed
+126 -12

No files matched your search

+10 -6
View File
@@ -24,6 +24,7 @@ struct EntityCoveragePolicy {
internal_kinds: Vec<String>, internal_kinds: Vec<String>,
opaque_kinds: Vec<String>, opaque_kinds: Vec<String>,
editable: BTreeMap<String, Vec<String>>, editable: BTreeMap<String, Vec<String>>,
readable: BTreeMap<String, Vec<String>>,
aliases: BTreeMap<String, BTreeMap<String, String>>, aliases: BTreeMap<String, BTreeMap<String, String>>,
} }
@@ -510,7 +511,7 @@ fn generate_entity_coverage(out_dir: &Path, registry: &TypeRegistry) {
let variants = &registry.types[&TypeId::new("EntityType")].variants; let variants = &registry.types[&TypeId::new("EntityType")].variants;
let variant_names: HashSet<&str> = variants.iter().map(|v| v.name.as_str()).collect(); 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) for name in policy.internal_kinds.iter().chain(&policy.opaque_kinds)
.chain(policy.editable.keys()) { .chain(policy.editable.keys()).chain(policy.readable.keys()) {
assert!(variant_names.contains(name.as_str()), "coverage policy names unknown kind: {name}"); assert!(variant_names.contains(name.as_str()), "coverage policy names unknown kind: {name}");
} }
for name in &policy.internal_kinds { for name in &policy.internal_kinds {
@@ -532,8 +533,9 @@ fn generate_entity_coverage(out_dir: &Path, registry: &TypeRegistry) {
else if policy.opaque_kinds.contains(kind) { EntityScope::Opaque } else if policy.opaque_kinds.contains(kind) { EntityScope::Opaque }
else { EntityScope::Canvas }; else { EntityScope::Canvas };
let editable = policy.editable.get(kind).cloned().unwrap_or_default(); let editable = policy.editable.get(kind).cloned().unwrap_or_default();
let mut unresolved: HashSet<String> = editable.iter().cloned().collect(); let readable = policy.readable.get(kind).cloned().unwrap_or_default();
assert_eq!(unresolved.len(), editable.len(), "duplicate editable property in {kind}"); let mut unresolved: HashSet<String> = editable.iter().chain(&readable).cloned().collect();
assert_eq!(unresolved.len(), editable.len() + readable.len(), "duplicate mapped property in {kind}");
let aliases = policy.aliases.get(kind); let aliases = policy.aliases.get(kind);
let mut properties = vec![PropertyCoverage { let mut properties = vec![PropertyCoverage {
name: "kind".into(), source_path: "<variant>".into(), type_id: "String".into(), name: "kind".into(), source_path: "<variant>".into(), type_id: "String".into(),
@@ -563,13 +565,13 @@ fn generate_entity_coverage(out_dir: &Path, registry: &TypeRegistry) {
let shape = &registry.types[&shape_type]; let shape = &registry.types[&shape_type];
for field in &shape.fields { for field in &shape.fields {
if field.name == "common" { continue; } if field.name == "common" { continue; }
let exposed = editable.iter().find(|name| { let exposed = editable.iter().chain(&readable).find(|name| {
aliases.and_then(|map| map.get(*name)).map_or(name.as_str(), String::as_str) == field.name aliases.and_then(|map| map.get(*name)).map_or(name.as_str(), String::as_str) == field.name
}); });
let (name, access) = match exposed { let (name, access) = match exposed {
Some(name) => { Some(name) => {
unresolved.remove(name); unresolved.remove(name);
((*name).clone(), ModelAccess::ReadWrite) ((*name).clone(), if editable.contains(name) { ModelAccess::ReadWrite } else { ModelAccess::ReadOnly })
} }
None => (field.name.clone(), ModelAccess::Unmapped), None => (field.name.clone(), ModelAccess::Unmapped),
}; };
@@ -579,7 +581,9 @@ fn generate_entity_coverage(out_dir: &Path, registry: &TypeRegistry) {
("Circle" | "Arc", "center" | "radius") | ("Circle" | "Arc", "center" | "radius") |
("Ray" | "XLine", "base_point" | "direction") | ("Ray" | "XLine", "base_point" | "direction") |
("Solid", "first_corner" | "second_corner" | "third_corner" | "fourth_corner" | "normal" | "thickness") | ("Solid", "first_corner" | "second_corner" | "third_corner" | "fourth_corner" | "normal" | "thickness") |
("Face3D", "first_corner" | "second_corner" | "third_corner" | "fourth_corner" | "invisible_edges")) { ("Face3D", "first_corner" | "second_corner" | "third_corner" | "fourth_corner" | "invisible_edges") |
("Insert", "insert_point" | "x_scale" | "y_scale" | "z_scale" | "rotation" | "normal" |
"column_count" | "row_count" | "column_spacing" | "row_spacing")) {
"transaction_geometry" "transaction_geometry"
} else { "type_conversion_only" }; } else { "type_conversion_only" };
properties.push(PropertyCoverage { properties.push(PropertyCoverage {
@@ -17,7 +17,11 @@
"Ray": ["layer", "base_point", "direction"], "Ray": ["layer", "base_point", "direction"],
"XLine": ["layer", "base_point", "direction"], "XLine": ["layer", "base_point", "direction"],
"Solid": ["layer", "first_corner", "second_corner", "third_corner", "fourth_corner", "normal", "thickness", "is_trace"], "Solid": ["layer", "first_corner", "second_corner", "third_corner", "fourth_corner", "normal", "thickness", "is_trace"],
"Face3D": ["layer", "first_corner", "second_corner", "third_corner", "fourth_corner", "invisible_edges"] "Face3D": ["layer", "first_corner", "second_corner", "third_corner", "fourth_corner", "invisible_edges"],
"Insert": ["layer", "insert_point", "x_scale", "y_scale", "z_scale", "rotation", "normal", "column_count", "row_count", "column_spacing", "row_spacing"]
},
"readable": {
"Insert": ["block_name"]
}, },
"aliases": { "aliases": {
"Polyline": {"closed": "flags"}, "Polyline": {"closed": "flags"},
+75 -1
View File
@@ -41,6 +41,12 @@ fn solid_normal(name: &str, value: &crate::host::acadrust::types::Vector3) -> Re
else { Err(format!("{name} must be nonzero")) } else { Err(format!("{name} must be nonzero")) }
} }
#[cfg(feature = "host")]
fn insert_scale(name: &str, value: f64) -> Result<(), String> {
if value.is_finite() && value.abs() >= 1e-12 { Ok(()) }
else { Err(format!("{name} must be finite and nonzero")) }
}
/// Validate newly created geometry for the kinds whose mapped fields have /// Validate newly created geometry for the kinds whose mapped fields have
/// host checks. Called by the Python add path before an entity enters the document. /// host checks. Called by the Python add path before an entity enters the document.
#[cfg(feature = "host")] #[cfg(feature = "host")]
@@ -167,6 +173,34 @@ pub fn validate_entity_mutation(
return Err("Face3D.invisible_edges has unknown bits".into()); return Err("Face3D.invisible_edges has unknown bits".into());
} }
} }
(EntityType::Insert(old), EntityType::Insert(new)) => {
if old.block_name != new.block_name || old.attributes != new.attributes ||
old.view_rep_handle != new.view_rep_handle || old.seqend_handle != new.seqend_handle {
return Err("Insert block identity and attached records cannot change in a geometry transaction".into());
}
changed3("Insert.insert_point", &old.insert_point, &new.insert_point)?;
if !same3(&old.normal, &new.normal) { solid_normal("Insert.normal", &new.normal)?; }
for (name, before, after) in [
("x_scale", old.x_scale(), new.x_scale()),
("y_scale", old.y_scale(), new.y_scale()),
("z_scale", old.z_scale(), new.z_scale()),
] {
if before.to_bits() != after.to_bits() { insert_scale(&format!("Insert.{name}"), after)?; }
}
for (name, before, after) in [
("rotation", old.rotation, new.rotation),
("column_spacing", old.column_spacing, new.column_spacing),
("row_spacing", old.row_spacing, new.row_spacing),
] {
if before.to_bits() != after.to_bits() && !after.is_finite() {
return Err(format!("Insert.{name} must be finite"));
}
}
if (old.column_count != new.column_count && new.column_count == 0) ||
(old.row_count != new.row_count && new.row_count == 0) {
return Err("Insert array counts must be greater than zero".into());
}
}
_ => {} _ => {}
} }
Ok(()) Ok(())
@@ -222,7 +256,7 @@ mod tests {
if entry.scope != EntityScope::Canvas || !matches!(entry.kind.as_str(), if entry.scope != EntityScope::Canvas || !matches!(entry.kind.as_str(),
"Point" | "Line" | "Circle" | "Arc" | "Ellipse" | "Polyline" | "Point" | "Line" | "Circle" | "Arc" | "Ellipse" | "Polyline" |
"Polyline2D" | "Polyline3D" | "LwPolyline" | "Spline" | "Text" | "MText" | "Polyline2D" | "Polyline3D" | "LwPolyline" | "Spline" | "Text" | "MText" |
"Ray" | "XLine" | "Solid" | "Face3D") { "Ray" | "XLine" | "Solid" | "Face3D" | "Insert") {
assert!(entry.properties.iter().all(|p| p.model_access != ModelAccess::ReadWrite assert!(entry.properties.iter().all(|p| p.model_access != ModelAccess::ReadWrite
|| (entry.scope == EntityScope::Canvas && p.name == "layer")), "{}", entry.kind); || (entry.scope == EntityScope::Canvas && p.name == "layer")), "{}", entry.kind);
} }
@@ -249,6 +283,11 @@ mod tests {
"Face3D.first_corner".to_owned(), "Face3D.second_corner".to_owned(), "Face3D.first_corner".to_owned(), "Face3D.second_corner".to_owned(),
"Face3D.third_corner".to_owned(), "Face3D.fourth_corner".to_owned(), "Face3D.third_corner".to_owned(), "Face3D.fourth_corner".to_owned(),
"Face3D.invisible_edges".to_owned(), "Face3D.invisible_edges".to_owned(),
"Insert.insert_point".to_owned(), "Insert.x_scale".to_owned(),
"Insert.y_scale".to_owned(), "Insert.z_scale".to_owned(),
"Insert.rotation".to_owned(), "Insert.normal".to_owned(),
"Insert.column_count".to_owned(), "Insert.row_count".to_owned(),
"Insert.column_spacing".to_owned(), "Insert.row_spacing".to_owned(),
])); ]));
} }
@@ -265,6 +304,17 @@ mod tests {
} }
} }
#[test]
fn insert_catalog_separates_transform_writes_from_block_identity() {
let catalog: EntityCoverageCatalog = serde_json::from_str(get_embedded_entity_coverage_json()).unwrap();
let insert = catalog.entity_kinds.iter().find(|entry| entry.kind == "Insert").unwrap();
let block_name = insert.properties.iter().find(|property| property.name == "block_name").unwrap();
assert_eq!(block_name.model_access, ModelAccess::ReadOnly);
assert_eq!(block_name.validation, "none");
assert!(insert.properties.iter().find(|property| property.name == "attributes").unwrap().model_access == ModelAccess::Unmapped);
assert!(insert.properties.iter().find(|property| property.name == "insert_point").unwrap().model_access == ModelAccess::ReadWrite);
}
#[cfg(feature = "host")] #[cfg(feature = "host")]
#[test] #[test]
fn unsupported_canvas_kind_has_typed_snapshot_fields() { fn unsupported_canvas_kind_has_typed_snapshot_fields() {
@@ -326,6 +376,30 @@ mod tests {
.unwrap_err().contains("Face3D.invisible_edges")); .unwrap_err().contains("Face3D.invisible_edges"));
} }
#[cfg(feature = "host")]
#[test]
fn insert_transaction_checks_transforms_and_protects_references() {
use crate::host::acadrust::{self, types::Vector3};
let before = acadrust::entities::Insert::new("DOOR", Vector3::new(1.0, 2.0, 0.0));
let mut moved = before.clone();
moved.insert_point = Vector3::new(5.0, 6.0, 0.0);
moved.set_x_scale(2.0);
assert!(validate_entity_mutation(&acadrust::EntityType::Insert(before.clone()),
&acadrust::EntityType::Insert(moved)).is_ok());
let mut bad = before.clone();
bad.set_y_scale(f64::NAN);
assert!(validate_entity_mutation(&acadrust::EntityType::Insert(before.clone()),
&acadrust::EntityType::Insert(bad)).unwrap_err().contains("Insert.y_scale"));
let mut bad = before.clone();
bad.column_count = 0;
assert!(validate_entity_mutation(&acadrust::EntityType::Insert(before.clone()),
&acadrust::EntityType::Insert(bad)).unwrap_err().contains("array counts"));
let mut bad = before.clone();
bad.block_name = "OTHER".into();
assert!(validate_entity_mutation(&acadrust::EntityType::Insert(before),
&acadrust::EntityType::Insert(bad)).unwrap_err().contains("block identity"));
}
#[cfg(feature = "host")] #[cfg(feature = "host")]
#[test] #[test]
fn canvas_layer_patch_preserves_unmapped_hatch_fields() { fn canvas_layer_patch_preserves_unmapped_hatch_fields() {
+11 -4
View File
@@ -28,11 +28,14 @@ The v7 transaction path checks changed Point locations, Line endpoints,
Circle/Arc centers, Ray/XLine base points, and Solid/Face3D corners for finite Circle/Arc centers, Ray/XLine base points, and Solid/Face3D corners for finite
coordinates. Circle/Arc radii must be finite and positive; Ray/XLine directions coordinates. Circle/Arc radii must be finite and positive; Ray/XLine directions
must be unit vectors; Solid normals must be nonzero, its thickness finite, must be unit vectors; Solid normals must be nonzero, its thickness finite,
and Face3D invisible-edge flags limited to four known bits. Those fields report `transaction_geometry` in the and Face3D invisible-edge flags limited to four known bits. Insert transactions
validate placement, finite rotation and spacing, nonzero finite scale and normal,
and positive array counts. They reject changes to the referenced block and
attached attribute and sequence records. Those fields report `transaction_geometry` in the
catalog. All 43 canvas kinds also support nonempty `layer` changes through catalog. All 43 canvas kinds also support nonempty `layer` changes through
the same undoable transaction; internal and opaque variants remain read-only the same undoable transaction; internal and opaque variants remain read-only
through the document model. The adapter accepts only `layer` patches for through the document model. The adapter accepts only `layer` patches for
canvas kinds outside its 16 geometry converters. Other writable properties canvas kinds outside its 17 geometry converters. Other writable properties
still report `type_conversion_only`; broader CAD range and cross-property still report `type_conversion_only`; broader CAD range and cross-property
checks remain outstanding. checks remain outstanding.
The host's `entity_snapshot` helper serializes any typed entity as a detached The host's `entity_snapshot` helper serializes any typed entity as a detached
@@ -45,7 +48,11 @@ null and cannot be used as a lossless replacement entity.
The separate PandoraBox adapter currently maps these entity kinds to Python The separate PandoraBox adapter currently maps these entity kinds to Python
dictionaries: Point, Line, Circle, Arc, Ellipse, Polyline, Polyline2D, dictionaries: Point, Line, Circle, Arc, Ellipse, Polyline, Polyline2D,
Polyline3D, LwPolyline, Spline, Text, MText, Ray, XLine, Solid, and Face3D. The authoritative per-field mapping Polyline3D, LwPolyline, Spline, Text, MText, Ray, XLine, Solid, Face3D, and
Insert. Insert is update-only: the block name is readable but cannot be
changed, and Python creation is withheld until block-table references can be
established safely. Attached attributes remain available in the raw snapshot.
The authoritative per-field mapping
is `plugins/opencad-python/entity_manifest.json` in PandoraBox. The generated is `plugins/opencad-python/entity_manifest.json` in PandoraBox. The generated
mapping excludes fields it cannot represent, including common color, line mapping excludes fields it cannot represent, including common color, line
weight, transparency, Polyline3D smooth type, and MText background color. weight, transparency, Polyline3D smooth type, and MText background color.
@@ -81,7 +88,7 @@ Input tokens are bound to the drawing tab that requested the pick. Polling a
token from another tab returns no result and does not consume it. token from another tab returns no result and does not consume it.
Python `doc.entities[handle]` returns a descriptor for every entity. For kinds Python `doc.entities[handle]` returns a descriptor for every entity. For kinds
outside the 16-kind generated schema it contains only handle, kind, and layer; outside the 17-kind generated schema it contains only handle, kind, and layer;
`layer` is editable on canvas kinds. `doc.coverage()` enumerates the `layer` is editable on canvas kinds. `doc.coverage()` enumerates the
catalog; `doc.coverage("Line")` and `line.coverage` return one entry. For catalog; `doc.coverage("Line")` and `line.coverage` return one entry. For
covered kinds, the coverage object lists actual readable and editable covered kinds, the coverage object lists actual readable and editable
+25
View File
@@ -726,6 +726,31 @@ mod tests {
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]
fn insert_transform_transaction_undoes_without_changing_block_identity() {
let mut app = OpenCADStudio::new_for_test();
app.tabs[0].is_start = false;
let handle;
{
let mut host = HostSession::new(&mut app, 0);
let insert = acadrust::entities::Insert::new("DOOR", acadrust::types::Vector3::new(1.0, 2.0, 0.0));
handle = host.add_entity(EntityType::Insert(insert));
let mut changed = host.document().get_entity(handle).unwrap().clone();
if let EntityType::Insert(insert) = &mut changed {
insert.insert_point.x = 4.0;
insert.set_x_scale(2.0);
}
host.update_entities_transaction("Move block", vec![changed]).unwrap();
assert!(matches!(host.document().get_entity(handle), Some(EntityType::Insert(insert))
if insert.insert_point.x == 4.0 && insert.x_scale() == 2.0 && insert.block_name == "DOOR"));
}
app.finish_pending_history(0);
assert_eq!(app.tabs[0].history.undo_stack.len(), 1);
app.undo_steps(1);
assert!(matches!(app.tabs[0].scene.document.get_entity(handle), Some(EntityType::Insert(insert))
if insert.insert_point.x == 1.0 && insert.x_scale() == 1.0 && insert.block_name == "DOOR"));
}
#[test] #[test]
fn system_variables_change_without_command_reentry() { fn system_variables_change_without_command_reentry() {
let mut app = OpenCADStudio::new_for_test(); let mut app = OpenCADStudio::new_for_test();