Pieces a cut splits a body into are named after their faces since names follow history (lineage::order_lumps), so they keep their body names when an upstream edit moves the cut. The kernel's shell order named them before, and the two orders can differ: a slot along X through a 40x30x10 block swaps Body1 and Body1 (1). A design saved by an older build stores body names meaning the old order (fillet and chamfer bodies, targets, participants, materials, appearances, joints), so it would act on the other piece. Each feature now says which order names its pieces (Feature::shell_piece_order). The v13 to v14 step sets it on every saved feature, so an older design evaluates as it was saved; features made from now on, in new or old designs, name their pieces after their faces. The corpus gains split-body-pieces, saved by v0.3.2: a fillet, a chamfer and an appearance refer to the two pieces by body name. Without the setting the two pieces swap names (the corpus test fails); with it they stay as saved. Migration tests that compared an upgraded design with the original now expect the setting on its features. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J53sR6gEaVt56soFDpTck8
290 lines
13 KiB
Rust
290 lines
13 KiB
Rust
//! Designs saved by older SolveCraft builds (tests/corpus, see its README) open and evaluate as
|
|
//! they did when they were saved; damaged and hostile design files never panic.
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use serde_json::{Value, json};
|
|
use solvecraft_engine::Session;
|
|
|
|
fn corpus() -> Vec<PathBuf> {
|
|
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/corpus");
|
|
let mut v: Vec<PathBuf> =
|
|
std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.path()).filter(|p| p.extension().is_some_and(|x| x == "solvecraft")).collect();
|
|
v.sort();
|
|
assert!(v.len() >= 7, "corpus files in {}", dir.display());
|
|
v
|
|
}
|
|
|
|
fn open(path: &Path) -> Session {
|
|
let mut s = Session::default();
|
|
s.execute("doc.open", &json!({"path": path.to_string_lossy()})).unwrap_or_else(|e| panic!("{}: {e}", path.display()));
|
|
s
|
|
}
|
|
|
|
/// Differences that are deliberate behaviour changes made before the file format had versions
|
|
/// (no migration can tell those files apart): (file, check, why).
|
|
const KNOWN: [(&str, &str, &str); 2] = [
|
|
(
|
|
"m0-revolve-primitives",
|
|
"Body1 bbox",
|
|
"partial revolves turn toward the sketch normal since ac43365 (as Fusion's do); M0 turned them the other way",
|
|
),
|
|
("components-arm", "body_count", "Measure counted only the root component's bodies then; it measures every placed body now"),
|
|
];
|
|
|
|
fn known(file: &str, check: &str) -> bool {
|
|
KNOWN.iter().any(|(f, c, _)| *f == file && *c == check)
|
|
}
|
|
|
|
/// Volumes and areas then came from coarser tessellation (and curved booleans and fillets have
|
|
/// become exact since): the same part within this relative tolerance (the oracle's).
|
|
const REL: f64 = 1e-3;
|
|
|
|
fn close(a: f64, b: f64, rel: f64) -> bool {
|
|
(a - b).abs() <= rel * b.abs().max(1.0)
|
|
}
|
|
|
|
#[test]
|
|
fn old_designs_open_and_evaluate_as_saved() {
|
|
let mut problems = Vec::new();
|
|
for p in corpus() {
|
|
let name = p.file_stem().unwrap_or_default().to_string_lossy().to_string();
|
|
let want: Value = serde_json::from_slice(&std::fs::read(p.with_extension("expected.json")).unwrap()).unwrap();
|
|
let mut s = open(&p);
|
|
for r in &s.model.results {
|
|
if let Some(e) = &r.error {
|
|
problems.push(format!("{name}: feature {}: {e}", r.id));
|
|
}
|
|
}
|
|
assert_eq!(s.doc.format, solvecraft_engine::doc::format::format_string(solvecraft_engine::doc::format::FORMAT), "{name}: upgraded");
|
|
let got = s.execute("inspect.measure", &json!({})).unwrap();
|
|
if got["body_count"] != want["body_count"] && !known(&name, "body_count") {
|
|
problems.push(format!("{name}: {} bodies, saved with {}", got["body_count"], want["body_count"]));
|
|
}
|
|
for w in want["bodies"].as_array().into_iter().flatten() {
|
|
let bname = w["name"].as_str().unwrap_or_default();
|
|
let Some(g) = got["bodies"].as_array().into_iter().flatten().find(|g| g["name"] == w["name"]) else {
|
|
problems.push(format!("{name}: body {bname} missing"));
|
|
continue;
|
|
};
|
|
for k in ["volume_mm3", "area_mm2"] {
|
|
let (a, b) = (g[k].as_f64().unwrap_or(f64::NAN), w[k].as_f64().unwrap_or(f64::NAN));
|
|
if !close(a, b, REL) {
|
|
problems.push(format!("{name}: {bname} {k} {a} (saved {b})"));
|
|
}
|
|
}
|
|
if known(&name, &format!("{bname} bbox")) {
|
|
continue;
|
|
}
|
|
for (end, i) in [("min", 0), ("min", 1), ("min", 2), ("max", 0), ("max", 1), ("max", 2)] {
|
|
let (a, b) = (g["bbox"][end][i].as_f64().unwrap_or(f64::NAN), w["bbox"][end][i].as_f64().unwrap_or(f64::NAN));
|
|
if (a - b).abs() > 1e-3 * b.abs().max(1.0) {
|
|
problems.push(format!("{name}: {bname} bbox {end}[{i}] {a} (saved {b})"));
|
|
}
|
|
}
|
|
}
|
|
// Saved again, it reads back the same.
|
|
let again = solvecraft_engine::doc::Document::from_json(&s.doc.to_json()).unwrap();
|
|
assert_eq!(again, *s.doc, "{name}: round trip");
|
|
}
|
|
assert!(problems.is_empty(), "{}", problems.join("\n"));
|
|
}
|
|
|
|
/// Names saved before format 14 were worked out from geometry, pieces of a split face numbered by
|
|
/// where their centres lay (`F2:top#2`). They keep naming the faces they named: marked as such,
|
|
/// they are found as the build that saved them found them, without new notes.
|
|
#[test]
|
|
fn names_saved_before_exact_history_find_the_faces_they_named() {
|
|
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/corpus/naming-split-pieces.solvecraft");
|
|
let s = open(&path);
|
|
let feature = |name: &str| s.doc.features.iter().find(|f| f.name == name).unwrap_or_else(|| panic!("{name}"));
|
|
assert_eq!(feature("Round").edge_names, vec!["?F2:side:l2|F2:top#2".to_string()]);
|
|
assert_eq!(feature("Bevel").edge_names, vec!["?F2:side:l4|F2:top#1".to_string()]);
|
|
assert_eq!(feature("Raise").face_names, vec!["?F2:top#1".to_string()]);
|
|
for r in &s.model.results {
|
|
assert!(r.error.is_none() && r.warning.is_none(), "{}: {:?} {:?}", r.name, r.error, r.warning);
|
|
}
|
|
// The raised piece is the left one, as saved (the stored point is on it).
|
|
let state = s.model.state();
|
|
let solid = |p: [f64; 3]| state.bodies.iter().any(|b| b.body.contains(solvecraft_engine::geom::Vec3::new(p[0], p[1], p[2])));
|
|
assert!(solid([7.0, 15.0, 11.0]) && !solid([32.0, 15.0, 11.0]));
|
|
}
|
|
|
|
/// Body names saved before format 14 name the pieces of a split body in the kernel's shell order.
|
|
/// The features after the split that refer to a piece by its body name still find that piece:
|
|
/// the far one stays `Body1` (chamfered), the near one `Body1 (1)` (filleted, coloured).
|
|
#[test]
|
|
fn body_names_saved_before_exact_history_keep_their_pieces() {
|
|
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/corpus/split-body-pieces.solvecraft");
|
|
let s = open(&path);
|
|
assert!(s.doc.features.iter().all(|f| f.shell_piece_order), "every saved feature keeps the shell order");
|
|
for r in &s.model.results {
|
|
assert!(r.error.is_none() && r.warning.is_none(), "{}: {:?} {:?}", r.name, r.error, r.warning);
|
|
}
|
|
let state = s.model.state();
|
|
let piece = |name: &str| state.bodies.iter().find(|b| b.name == name).unwrap_or_else(|| panic!("{name}"));
|
|
let solid = |name: &str, p: [f64; 3]| piece(name).body.contains(solvecraft_engine::geom::Vec3::new(p[0], p[1], p[2]));
|
|
assert!(solid("Body1", [20.0, 24.0, 5.0]) && solid("Body1 (1)", [20.0, 6.0, 5.0]));
|
|
assert!(s.doc.appearances.bodies.contains_key("Body1 (1)"));
|
|
}
|
|
|
|
/// A small deterministic generator (xorshift).
|
|
struct Rng(u64);
|
|
impl Rng {
|
|
fn next(&mut self) -> u64 {
|
|
self.0 ^= self.0 << 13;
|
|
self.0 ^= self.0 >> 7;
|
|
self.0 ^= self.0 << 17;
|
|
self.0
|
|
}
|
|
fn below(&mut self, n: usize) -> usize {
|
|
(self.next() % n.max(1) as u64) as usize
|
|
}
|
|
}
|
|
|
|
const HOSTILE: [&str; 14] =
|
|
["0", "-1", "1e308", "-1e308", "1e9", "4294967297", "\"\"", "\"1/0\"", "\"((((1\"", "null", "true", "[]", "{}", "\"nope\""];
|
|
|
|
/// Every JSON value in a document, as a path of keys and indices.
|
|
fn paths(v: &Value, at: &mut Vec<Value>, out: &mut Vec<Vec<Value>>) {
|
|
out.push(at.clone());
|
|
match v {
|
|
Value::Object(m) => {
|
|
for (k, x) in m {
|
|
at.push(json!(k));
|
|
paths(x, at, out);
|
|
at.pop();
|
|
}
|
|
}
|
|
Value::Array(a) => {
|
|
for (i, x) in a.iter().enumerate() {
|
|
at.push(json!(i));
|
|
paths(x, at, out);
|
|
at.pop();
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn at_mut<'a>(v: &'a mut Value, path: &[Value]) -> Option<&'a mut Value> {
|
|
let mut cur = v;
|
|
for k in path {
|
|
cur = match k {
|
|
Value::String(s) => cur.get_mut(s.as_str())?,
|
|
Value::Number(n) => cur.get_mut(n.as_u64()? as usize)?,
|
|
_ => return None,
|
|
};
|
|
}
|
|
Some(cur)
|
|
}
|
|
|
|
/// Open a file's bytes as File → Open does and evaluate them; any panic is a failure.
|
|
fn try_bytes(bytes: &[u8]) {
|
|
if let Ok(doc) = solvecraft_engine::io::read_design(bytes) {
|
|
let mut s = Session::new(doc);
|
|
let _ = s.execute("inspect.measure", &json!({}));
|
|
let _ = s.execute("document.inspect", &json!({}));
|
|
}
|
|
}
|
|
|
|
/// Hostile shapes that no mutation of a saved file produces: plane references that close a
|
|
/// cycle, which resolve through the model and back into the document.
|
|
fn generated() -> Vec<(String, Vec<u8>)> {
|
|
let to = |name: &str| json!({"type": "plane", "plane": {"type": "construction", "name": name}});
|
|
let plane = |id: u64, name: &str, side: Value| {
|
|
json!({"id": id, "name": name, "suppressed": false, "type": "construction_plane",
|
|
"plane": {"type": "midplane", "a": side, "b": {"type": "plane", "plane": {"type": "origin", "name": "XZ"}}}})
|
|
};
|
|
let design = |name: &str, features: Value| {
|
|
json!({"format": "solvecraft/11", "name": name, "units": "mm", "params": [], "next_id": 9, "features": features}).to_string().into_bytes()
|
|
};
|
|
// A sketch past the caps interactive editing enforces: solving it would size dense matrices
|
|
// by the point count and abort on the allocation.
|
|
let point = json!({"id": "p1", "pos": [0.0, 0.0], "fixed": false});
|
|
let oversized = json!([{"id": 1, "name": "Sketch1", "suppressed": false, "type": "sketch",
|
|
"plane": {"type": "origin", "name": "XY"},
|
|
"sketch": {"points": vec![point; 200_000], "curves": [], "constraints": []}}]);
|
|
vec![
|
|
("construction plane is its own side".into(), design("selfplane", json!([plane(1, "P1", to("P1"))]))),
|
|
("two construction planes refer to each other".into(), design("twoplanes", json!([plane(1, "P1", to("P2")), plane(2, "P2", to("P1"))]))),
|
|
("sketch with 200,000 points".into(), design("bigsketch", oversized)),
|
|
]
|
|
}
|
|
|
|
#[test]
|
|
fn damaged_and_hostile_design_files_never_panic() {
|
|
let mut rng = Rng(0x5eed_cafe_f00d_d00d);
|
|
// The cases come from one seeded sequence; trying them is independent, so threads share it.
|
|
let mut all_cases: Vec<(String, String, Vec<u8>)> = Vec::new();
|
|
for p in corpus() {
|
|
let name = p.file_name().unwrap_or_default().to_string_lossy().to_string();
|
|
let bytes = std::fs::read(&p).unwrap();
|
|
let doc: Value = serde_json::from_slice(&bytes).unwrap();
|
|
let mut all = Vec::new();
|
|
paths(&doc, &mut Vec::new(), &mut all);
|
|
let mut cases: Vec<(String, Vec<u8>)> = Vec::new();
|
|
// Truncated and bit-flipped files.
|
|
for _ in 0..8 {
|
|
let cut = rng.below(bytes.len());
|
|
cases.push((format!("truncated at {cut}"), bytes[..cut].to_vec()));
|
|
let mut b = bytes.clone();
|
|
let i = rng.below(b.len());
|
|
b[i] ^= 1 << rng.below(8);
|
|
cases.push((format!("bit flip at {i}"), b));
|
|
}
|
|
// Values replaced by hostile ones, and keys removed.
|
|
for _ in 0..25 {
|
|
let path = &all[rng.below(all.len())];
|
|
let mut v = doc.clone();
|
|
let h = HOSTILE[rng.below(HOSTILE.len())];
|
|
if let Some(slot) = at_mut(&mut v, path) {
|
|
*slot = serde_json::from_str(h).unwrap();
|
|
}
|
|
cases.push((format!("{path:?} = {h}"), v.to_string().into_bytes()));
|
|
let mut v = doc.clone();
|
|
if let Some((last, parent)) = path.split_last()
|
|
&& let (Some(Value::Object(m)), Value::String(k)) = (at_mut(&mut v, parent), last)
|
|
{
|
|
m.remove(k);
|
|
}
|
|
cases.push((format!("{path:?} removed"), v.to_string().into_bytes()));
|
|
}
|
|
// Features repeated many times; deep nesting; not JSON at all.
|
|
let mut v = doc.clone();
|
|
if let Some(Value::Array(fs)) = v.get_mut("features") {
|
|
let copy = fs.clone();
|
|
for _ in 0..2 {
|
|
fs.extend(copy.iter().cloned());
|
|
}
|
|
}
|
|
cases.push(("features repeated".into(), v.to_string().into_bytes()));
|
|
cases.push(("deep nesting".into(), format!("{}{}", "[".repeat(100_000), "]".repeat(100_000)).into_bytes()));
|
|
cases.push(("binary".into(), (0..4096u32).map(|i| (i.wrapping_mul(2_654_435_761) >> 24) as u8).collect()));
|
|
all_cases.extend(cases.into_iter().map(|(what, b)| (name.clone(), what, b)));
|
|
}
|
|
all_cases.extend(generated().into_iter().map(|(what, b)| ("generated".to_string(), what, b)));
|
|
let next = std::sync::atomic::AtomicUsize::new(0);
|
|
let threads = std::thread::available_parallelism().map_or(4, |n| n.get());
|
|
let failures: Vec<String> = std::thread::scope(|scope| {
|
|
let workers: Vec<_> = (0..threads)
|
|
.map(|_| {
|
|
scope.spawn(|| {
|
|
let mut failures = Vec::new();
|
|
while let Some((name, what, b)) = all_cases.get(next.fetch_add(1, std::sync::atomic::Ordering::Relaxed)) {
|
|
if std::env::var_os("CORPUS_TRACE").is_some() {
|
|
eprintln!("{name}: {what}");
|
|
}
|
|
if std::panic::catch_unwind(|| try_bytes(b)).is_err() {
|
|
failures.push(format!("{name}: {what}"));
|
|
}
|
|
}
|
|
failures
|
|
})
|
|
})
|
|
.collect();
|
|
workers.into_iter().flat_map(|w| w.join().unwrap()).collect()
|
|
});
|
|
assert!(failures.is_empty(), "panics:\n{}", failures.join("\n"));
|
|
}
|