forked from 1b-agent/opencadstudio-upstream
Add validated Insert transform scripting coverage
This commit is contained in:
1 parent
11d5614991
commit
db61379bfe
5 files changed
+126
-12
No files matched your search
@@ -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 = ®istry.types[&TypeId::new("EntityType")].variants;
|
||||
let variant_names: HashSet<&str> = variants.iter().map(|v| v.name.as_str()).collect();
|
||||
for name in policy.internal_kinds.iter().chain(&policy.opaque_kinds)
|
||||
.chain(policy.editable.keys()) {
|
||||
.chain(policy.editable.keys()).chain(policy.readable.keys()) {
|
||||
assert!(variant_names.contains(name.as_str()), "coverage policy names unknown kind: {name}");
|
||||
}
|
||||
for name in &policy.internal_kinds {
|
||||
@@ -532,8 +533,9 @@ fn generate_entity_coverage(out_dir: &Path, registry: &TypeRegistry) {
|
||||
else if policy.opaque_kinds.contains(kind) { EntityScope::Opaque }
|
||||
else { EntityScope::Canvas };
|
||||
let editable = policy.editable.get(kind).cloned().unwrap_or_default();
|
||||
let mut unresolved: HashSet<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 = ®istry.types[&shape_type];
|
||||
for field in &shape.fields {
|
||||
if field.name == "common" { continue; }
|
||||
let exposed = editable.iter().find(|name| {
|
||||
let exposed = editable.iter().chain(&readable).find(|name| {
|
||||
aliases.and_then(|map| map.get(*name)).map_or(name.as_str(), String::as_str) == field.name
|
||||
});
|
||||
let (name, access) = match exposed {
|
||||
Some(name) => {
|
||||
unresolved.remove(name);
|
||||
((*name).clone(), ModelAccess::ReadWrite)
|
||||
((*name).clone(), if editable.contains(name) { ModelAccess::ReadWrite } else { ModelAccess::ReadOnly })
|
||||
}
|
||||
None => (field.name.clone(), ModelAccess::Unmapped),
|
||||
};
|
||||
@@ -579,7 +581,9 @@ fn generate_entity_coverage(out_dir: &Path, registry: &TypeRegistry) {
|
||||
("Circle" | "Arc", "center" | "radius") |
|
||||
("Ray" | "XLine", "base_point" | "direction") |
|
||||
("Solid", "first_corner" | "second_corner" | "third_corner" | "fourth_corner" | "normal" | "thickness") |
|
||||
("Face3D", "first_corner" | "second_corner" | "third_corner" | "fourth_corner" | "invisible_edges")) {
|
||||
("Face3D", "first_corner" | "second_corner" | "third_corner" | "fourth_corner" | "invisible_edges") |
|
||||
("Insert", "insert_point" | "x_scale" | "y_scale" | "z_scale" | "rotation" | "normal" |
|
||||
"column_count" | "row_count" | "column_spacing" | "row_spacing")) {
|
||||
"transaction_geometry"
|
||||
} else { "type_conversion_only" };
|
||||
properties.push(PropertyCoverage {
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user