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