Files
solidcraft-upstream/crates/engine/src/cmd/config_certificate_tests.rs
T
bherbruck c1d819f235 feat: preserve body history and placed modelling workflows
Record ownership transfers in the timeline so earlier Join/Cut membership,
world placement, machining data and styles survive component moves, edits,
independent copying, undo and reopening. Preserve pending positions in history
snapshots and keep constructor transfers ordered during feature redefinition.

Retain shallow sheet pockets and exact planar intersection islands through
unfold/refold, with depth-aware flat previews and DXF export. Store explicit
3D wire coordinate spaces and migrate known legacy provenance without moving
saved points or analytic fits.

Infer unique eligible cylindrical Emboss targets, align Revolve manipulators
with actual extent geometry, and discard obsolete preview errors after input
correction. Certify constructed and immutable imported face styles using exact
source metadata while preserving legacy face names. Upgrade designs to format10
with bounded validation and geometry-preserving legacy migration.
2026-10-11 03:59:12 -04:00

252 lines
15 KiB
Rust

use crate::Session;
use serde_json::{Value, json};
use solvecraft_doc::{
FeatureKind,
config::{Column, FaceCertificate},
};
use solvecraft_geom::Vec3;
fn run(session: &mut Session, command: &str, params: Value) -> Value {
session.execute(command, &params).unwrap_or_else(|error| panic!("{command} {params}: {error}"))
}
fn paint(session: &mut Session, body: &str, point: Value) {
let table = run(session, "config.configure", json!({"face_appearances":[{"body":body,"point":point}]}));
let key = table["columns"][0].as_str().unwrap();
run(session, "config.row", json!({"name":"Painted","values":{key:"ABS - Red"}}));
run(session, "config.activate", json!({"row":"Painted"}));
}
fn painted_radius(session: &Session, name: &str, radius: f64) -> usize {
let state = session.model.state();
let body =
state.body(name).unwrap_or_else(|| panic!("missing {name}; bodies {:?}", state.bodies.iter().map(|body| &body.name).collect::<Vec<_>>()));
let painted = session.doc.face_colors(body);
let mesh = body.body.tessellate(0.001).unwrap();
let inverse = solvecraft_doc::mat_inverse(&body.source_transform).unwrap();
for (triangle, face) in mesh.triangles.iter().zip(&mesh.tri_face) {
if painted.iter().any(|(index, _)| *index == *face as usize) {
for vertex in mesh.tri(triangle).unwrap() {
let vertex = solvecraft_doc::apply_point(&inverse, vertex);
assert!((vertex.x.hypot(vertex.y) - radius).abs() < 1e-7, "{name}: {vertex:?}");
}
}
}
assert!(!painted.is_empty());
painted.len()
}
#[test]
fn certified_annulus_side_rows_follow_radius_edits_and_clone() {
let mut s = Session::default();
let part = run(&mut s, "component.create", json!({"name":"RingPart"}))["component"].as_u64().unwrap();
let sketch = run(&mut s, "sketch.create", json!({"plane":"XY"}))["sketch"].as_u64().unwrap();
run(&mut s, "sketch.circle.center", json!({"center":[0,0],"radius":10,"id":"outer"}));
run(&mut s, "sketch.dimension", json!({"entities":["outer"],"type":"radius","value":10}));
run(&mut s, "sketch.circle.center", json!({"center":[0,0],"radius":9,"id":"inner"}));
run(&mut s, "sketch.dimension", json!({"entities":["inner"],"type":"radius","value":9}));
let FeatureKind::Sketch { sketch: source_sketch, .. } = &s.doc.feature(sketch).unwrap().kind else { panic!() };
let dimensions: Vec<_> = source_sketch.constraints.iter().filter_map(|dimension| dimension.param.clone()).collect();
assert_eq!(dimensions.len(), 2);
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance":10,"profiles":[{"point":[9.5,0]}],"body_name":"Ring"}));
paint(&mut s, "Ring", json!([10, 0, 5]));
assert_eq!(painted_radius(&s, "Ring", 10.), 2, "both constructed arc pieces share outer curve");
let Column::FaceAppearance(target) = &s.doc.configs.columns[0] else { panic!() };
assert_eq!(target.certificate, Some(FaceCertificate::ExtrudeSide { sketch, curve: "outer".into() }));
run(&mut s, "parameters.change", json!({"name":dimensions[0],"expression":"12 mm"}));
run(&mut s, "parameters.change", json!({"name":dimensions[1],"expression":"9.25 mm"}));
run(&mut s, "config.activate", json!({"row":"Painted"}));
assert_eq!(painted_radius(&s, "Ring", 12.), 2);
run(&mut s, "component.paste_new", json!({"component":part,"translate":[30,0,0]}));
assert_eq!(s.doc.configs.columns.len(), 2);
run(&mut s, "config.activate", json!({"row":"Painted"}));
assert_eq!(painted_radius(&s, "Ring (copy)", 12.), 2);
let reopened = solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
let mut reopened = Session::new(reopened);
run(&mut reopened, "config.activate", json!({"row":"Painted"}));
assert_eq!(painted_radius(&reopened, "Ring", 12.), 2);
assert_eq!(painted_radius(&reopened, "Ring (copy)", 12.), 2);
run(&mut reopened, "config.activate", json!({"row":"Default"}));
assert!(reopened.doc.appearances.faces.is_empty());
}
fn imported(session: &mut Session, step: String, name: &str) -> u64 {
let owner = session.active_component;
let id = session.doc_mut().add_feature(FeatureKind::Import { file: "fixture.step".into(), step, components: vec![] }, Some(name)).unwrap();
let feature = session.doc_mut().feature_mut(id).unwrap();
feature.body_names = vec![name.into()];
feature.component = owner;
session.refresh();
assert!(session.model.result(id).unwrap().error.is_none());
id
}
#[test]
fn legacy_v9_imported_emboss_anchor_and_ordinary_face_paint_replay_without_renaming() {
let mut s = Session::default();
let cylinder = solvecraft_kernel::cylinder(Vec3::ZERO, Vec3::Z, 10., 20.).unwrap();
let step = solvecraft_kernel::step_export(&[&cylinder], "legacy anchored cylinder").unwrap();
let source = imported(&mut s, step, "Sleeve");
run(&mut s, "material.assign", json!({"bodies":["Sleeve"],"material":"Steel"}));
let before_volume = s.model.state().body("Sleeve").unwrap().body.tessellate_with(1e-4, true).unwrap().measure().volume;
let legacy_names = solvecraft_doc::naming::face_names(s.model.state().body("Sleeve").unwrap());
assert!(legacy_names.iter().all(|name| name.starts_with(&format!("F{source}:face:"))));
run(&mut s, "appearance.assign", json!({"faces":[{"body":"Sleeve","point":[6,0,20]}],"appearance":"ABS - Blue"}));
assert!(s.doc.appearances.faces[0].certificate.is_none());
let badge = run(&mut s, "sketch.create", json!({"plane":{"face":[0,0,20]},"name":"Badge","project_edges":false}))["sketch"].as_u64().unwrap();
let FeatureKind::Sketch { plane: saved_plane, .. } = &s.doc.feature(badge).unwrap().kind else { panic!() };
let saved_plane = saved_plane.clone();
assert!(matches!(&saved_plane, solvecraft_doc::PlaneRef::Face { name: Some(name), .. } if legacy_names.contains(name)));
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-2,-2],"p1":[2,2]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.emboss", json!({"sketch":"Badge","profiles":"all","depth":1,"targets":["Sleeve"],"name":"Raised Badge"}));
let anchored = s.doc.find_feature("Raised Badge").unwrap().clone();
assert!(matches!(anchored.kind, FeatureKind::Emboss { face: None, .. }));
let volume = s.model.state().body("Sleeve").unwrap().body.tessellate_with(1e-4, true).unwrap().measure().volume;
assert!((volume - std::f64::consts::PI * 2000. - 16.).abs() < 0.1, "volume {volume}");
assert!((volume - before_volume - 16.).abs() < 0.01, "addedvolume {} baseline {before_volume}", volume - before_volume);
let paint = s.doc.appearances.faces.clone();
let mut legacy: Value = serde_json::from_str(&s.doc.to_json()).unwrap();
legacy["format"] = json!("solvecraft/9");
let reopened = Session::new(solvecraft_doc::Document::from_json(&legacy.to_string()).unwrap());
assert_eq!(reopened.doc.feature(anchored.id).unwrap(), &anchored);
let FeatureKind::Sketch { plane, .. } = &reopened.doc.feature(badge).unwrap().kind else { panic!() };
assert_eq!(plane, &saved_plane);
assert_eq!(reopened.doc.appearances.faces, paint);
assert_eq!(reopened.doc.materials.get("Sleeve"), Some(&"Steel".to_string()));
assert!(reopened.model.result(anchored.id).unwrap().error.is_none());
let state = reopened.model.state();
let body = state.body("Sleeve").unwrap();
assert!((body.body.tessellate_with(1e-4, true).unwrap().measure().volume - volume).abs() < 1e-8);
assert!(!reopened.doc.face_colors(body).is_empty());
}
#[test]
fn certified_side_refuses_reused_body_name_from_a_new_producer() {
let mut s = Session::default();
let sketch = run(&mut s, "sketch.create", json!({"plane":"XY"}))["sketch"].as_u64().unwrap();
run(&mut s, "sketch.circle.center", json!({"center":[0,0],"radius":10,"id":"circle"}));
run(&mut s, "sketch.finish", json!({}));
let source = run(&mut s, "solid.extrude", json!({"distance":10,"body_name":"Round"}))["feature"].as_u64().unwrap();
paint(&mut s, "Round", json!([10, 0, 5]));
let Column::FaceAppearance(target) = s.doc.configs.columns[0].clone() else { panic!() };
let mut replacement = s.doc.feature(source).unwrap().clone();
replacement.id = s.doc.next_id;
assert!(matches!(replacement.kind, FeatureKind::Extrude { sketch: original, .. } if original == sketch));
s.doc_mut().feature_mut(source).unwrap().suppressed = true;
s.doc_mut().next_id += 1;
s.doc_mut().features.push(replacement);
s.refresh();
let state = s.model.state();
let body = state.body("Round").unwrap();
assert!(s.doc.configuration_face_indices(body, &target).is_err());
assert!(s.doc.face_colors(body).is_empty());
let before = s.doc.clone();
let history = s.undo.len();
assert!(s.execute("config.activate", &json!({"row":"Painted"})).is_err());
assert_eq!(s.doc, before);
assert_eq!(s.undo.len(), history);
}
#[test]
fn legacy_v9_annulus_outer_side_emboss_keeps_its_original_heuristic_anchor() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane":"XY"}));
run(&mut s, "sketch.circle.center", json!({"center":[0,0],"radius":10,"id":"outer"}));
run(&mut s, "sketch.circle.center", json!({"center":[0,0],"radius":9,"id":"inner"}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance":20,"profiles":[{"point":[9.5,0]}],"body_name":"Sleeve"}));
let names = solvecraft_doc::naming::face_names(s.model.state().body("Sleeve").unwrap());
// Preserve the legacy centroid-family name even though strict paint uses
// the separately certified outer source curve.
let name = solvecraft_doc::naming::face_names_at(&s.model.state(), &[Vec3::new(0., 10., 7.5)]).remove(0);
assert!(name.contains("side:inner"));
run(&mut s, "sketch.create", json!({"plane":{"origin":[0,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]},"name":"Badge"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-2,5],"p1":[2,10]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.emboss", json!({"sketch":"Badge","profiles":"all","face":[0,10,7.5],"depth":1,"targets":["Sleeve"],"name":"Raised Badge"}));
let feature = s.doc.find_feature("Raised Badge").unwrap();
assert_eq!(feature.face_names, vec![name]);
assert!(feature.face_names.iter().all(|name| names.contains(name)));
let feature_id = feature.id;
let volume = s.model.state().body("Sleeve").unwrap().body.tessellate_with(1e-4, true).unwrap().measure().volume;
assert!((volume - std::f64::consts::PI * 380. - 21.).abs() < 0.2, "volume {volume}");
let mut legacy: Value = serde_json::from_str(&s.doc.to_json()).unwrap();
legacy["format"] = json!("solvecraft/9");
let reopened = Session::new(solvecraft_doc::Document::from_json(&legacy.to_string()).unwrap());
assert!(reopened.model.result(feature_id).unwrap().error.is_none());
assert!((reopened.model.state().body("Sleeve").unwrap().body.tessellate_with(1e-4, true).unwrap().measure().volume - volume).abs() < 1e-8);
}
#[test]
fn certified_side_transfer_preserves_geometry_and_unsupported_taper_refuses_atomically() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane":"XY"}));
run(&mut s, "sketch.circle.center", json!({"center":[0,0],"radius":10}));
run(&mut s, "sketch.finish", json!({}));
let source = run(&mut s, "solid.extrude", json!({"distance":10,"body_name":"Round"}))["feature"].as_u64().unwrap();
paint(&mut s, "Round", json!([10, 0, 5]));
let created = run(&mut s, "component.create", json!({"name":"Placed"}));
let destination = created["component"].as_u64().unwrap();
let occurrence = created["occurrence"].as_u64().unwrap();
run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[30,20,0],"angle":90,"axis":[0,0,1],"capture":false}));
run(&mut s, "component.move_bodies", json!({"component":destination,"bodies":["Round"]}));
run(&mut s, "config.activate", json!({"row":"Painted"}));
assert_eq!(painted_radius(&s, "Round", 10.), 2);
let before = s.world_state().body("Round").unwrap().mesh().bounds();
let mut s = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
run(&mut s, "config.activate", json!({"row":"Painted"}));
assert_eq!(painted_radius(&s, "Round", 10.), 2);
assert!(
s.world_state().body("Round").unwrap().mesh().bounds().min.dist(before.min) < 1e-8,
"before {before:?}, reopened {:?}",
s.world_state().body("Round").unwrap().mesh().bounds()
);
// A successful ordinary modifier has no exact untapered source certificate.
let FeatureKind::Extrude { extent, .. } = &mut s.doc_mut().feature_mut(source).unwrap().kind else { panic!() };
extent.taper = Some("5 deg".into());
s.refresh();
assert!(s.model.result(source).unwrap().error.is_none());
assert!(s.doc.face_colors(s.model.state().body("Round").unwrap()).is_empty());
let document = s.doc.clone();
let history = s.undo.len();
assert!(s.execute("config.preview", &json!({"row":"Painted"})).is_err());
assert!(s.execute("config.activate", &json!({"row":"Painted"})).is_err());
assert_eq!(s.doc, document);
assert_eq!(s.undo.len(), history);
}
#[test]
fn certified_import_payload_binding_roundtrip_clone_and_replacement_refusal() {
let geometry = solvecraft_kernel::cylinder(Vec3::ZERO, Vec3::Z, 10., 10.).unwrap();
let step = solvecraft_kernel::step_export(&[&geometry], "certificate fixture").unwrap();
let mut s = Session::default();
let part = run(&mut s, "component.create", json!({"name":"ImportedPart"}))["component"].as_u64().unwrap();
let source = imported(&mut s, step.clone(), "Imported");
paint(&mut s, "Imported", json!([10, 0, 5]));
let pieces = painted_radius(&s, "Imported", 10.);
let Column::FaceAppearance(target) = &s.doc.configs.columns[0] else { panic!() };
assert!(
matches!(&target.certificate, Some(FaceCertificate::Imported { item, face, payload_sha256 }) if *item != 0 && *face != 0 && payload_sha256.len() == 64)
);
run(&mut s, "component.paste_new", json!({"component":part,"translate":[30,0,0]}));
run(&mut s, "config.activate", json!({"row":"Painted"}));
assert_eq!(painted_radius(&s, "Imported (copy)", 10.), pieces);
let reopened = solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
let mut s = Session::new(reopened);
run(&mut s, "config.activate", json!({"row":"Painted"}));
assert_eq!(painted_radius(&s, "Imported", 10.), pieces);
// Identical geometry, entity IDs and item IDs, but a replaced embedded payload.
let FeatureKind::Import { step: payload, .. } = &mut s.doc_mut().feature_mut(source).unwrap().kind else { panic!() };
*payload = step.replace("certificate fixture", "replacement fixture");
assert_ne!(*payload, step);
s.refresh();
assert!(s.doc.face_colors(s.model.state().body("Imported").unwrap()).is_empty());
assert_eq!(painted_radius(&s, "Imported (copy)", 10.), pieces);
let before = s.doc.clone();
let history = s.undo.len();
assert!(s.execute("config.preview", &json!({"row":"Painted"})).is_err());
assert!(s.execute("config.activate", &json!({"row":"Painted"})).is_err());
assert_eq!(s.doc, before);
assert_eq!(s.undo.len(), history);
}