Files
OneCAD/crates/engine/src/cmd/motion_tests.rs
T
bherbruckandClaude Opus 5.5 296683229d Rename SolveCraft to SolidCraft everywhere, with compatibility shims (#389)
* WIP deep rename (not for push)

* WIP2

* WIP3

* WIP4 wxs fix

* rustfmt

* Rename the remaining identifiers in merged crash-report code

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:17:01 -04:00

167 lines
9.3 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use serde_json::{Value, json};
use solidcraft_geom::Vec3;
use crate::Session;
fn run(s: &mut Session, id: &str, p: Value) -> Value {
match s.execute(id, &p) {
Ok(v) => v,
Err(e) => panic!("{id} {p}: {e}"),
}
}
fn occ(s: &Session, component: &str) -> u64 {
let c = s.doc.find_component(component).unwrap_or(0);
s.doc.occurrence_of(c).map(|o| o.id).unwrap_or(0)
}
/// Where B's corner (20, 0, 0) is now.
fn b_x(s: &Session) -> f64 {
super::world_point(s, occ(s, "B"), Vec3::new(20.0, 0.0, 0.0)).x
}
/// Cubes A (grounded, x 0…10) and B (x 20…30) on a slider along x: B's −x face on A's +x face
/// at value 0, B 10 mm away to start.
fn rail() -> Session {
let mut s = Session::default();
for (name, x) in [("A", 0), ("B", 20)] {
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "component.create", json!({"name": name}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0]}));
}
run(&mut s, "component.activate", json!({"component": "root"}));
let a = occ(&s, "A");
run(&mut s, "occurrence.ground", json!({"occurrence": a, "grounded": true}));
run(&mut s, "joint.create", json!({"type": "slider", "a": {"face": [10, 5, 5]}, "b": {"face": [20, 5, 5]}, "name": "Rail"}));
run(&mut s, "joint.drive", json!({"joint": "Rail", "value": 10}));
assert!((b_x(&s) - 20.0).abs() < 1e-6, "{}", b_x(&s));
s
}
#[test]
fn contact_sets_stop_a_driven_joint_at_touching() {
let mut s = rail();
// No contact: B passes into A.
run(&mut s, "joint.drive", json!({"joint": "Rail", "value": -5}));
assert!((b_x(&s) - 5.0).abs() < 1e-6);
run(&mut s, "joint.drive", json!({"joint": "Rail", "value": 10}));
run(&mut s, "contact.create", json!({"occurrences": ["A", "B"]}));
let r = run(&mut s, "joint.drive", json!({"joint": "Rail", "value": -5}));
assert!(r["stopped_by_contact"].is_object(), "{r}");
// Stopped where the faces touch (to the search's resolution, 15 mm / 2^14).
assert!((b_x(&s) - 10.0).abs() < 2e-3, "{}", b_x(&s));
assert!(b_x(&s) >= 10.0 - 2e-3, "a touch, not a pass");
assert_eq!(run(&mut s, "contact.check", json!({}))["collisions"].as_array().map(Vec::len), Some(0));
// Away is free; contact off lets it through again.
let r = run(&mut s, "joint.drive", json!({"joint": "Rail", "value": 3}));
assert!(r["stopped_by_contact"].is_null() && (b_x(&s) - 13.0).abs() < 1e-6, "{r}");
run(&mut s, "contact.disable_all", json!({}));
run(&mut s, "joint.drive", json!({"joint": "Rail", "value": -5}));
assert!((b_x(&s) - 5.0).abs() < 1e-6);
// Suppressed sets don't count; Enable All Contact puts every occurrence in one.
run(&mut s, "joint.drive", json!({"joint": "Rail", "value": 10}));
run(&mut s, "contact.enable_sets", json!({}));
run(&mut s, "contact.edit", json!({"set": "Contact Set1", "suppressed": true}));
run(&mut s, "joint.drive", json!({"joint": "Rail", "value": -5}));
assert!((b_x(&s) - 5.0).abs() < 1e-6);
run(&mut s, "joint.drive", json!({"joint": "Rail", "value": 10}));
run(&mut s, "contact.enable_all", json!({}));
run(&mut s, "joint.drive", json!({"joint": "Rail", "value": -5}));
assert!((b_x(&s) - 10.0).abs() < 2e-3);
assert!(s.execute("contact.create", &json!({"occurrences": ["A"]})).is_err());
}
#[test]
fn contact_uses_preserved_body_placements_after_component_reassignment() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Transferred"}));
let component = run(&mut s, "component.create", json!({"name": "Carrier"}));
run(&mut s, "occurrence.move", json!({"occurrence": component["occurrence"], "translate": [100, 0, 0]}));
run(&mut s, "component.move_bodies", json!({"bodies": ["Transferred"], "component": component["component"]}));
run(&mut s, "component.activate", json!({"component": "root"}));
let probe = run(&mut s, "component.create", json!({"name": "Probe"}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [5, 0, 0]}));
run(&mut s, "contact.create", json!({"occurrences": [component["occurrence"], probe["occurrence"]]}));
let collisions = run(&mut s, "contact.check", json!({}));
assert_eq!(collisions["collisions"].as_array().map(Vec::len), Some(1), "body stayed at x=0: {collisions}");
run(&mut s, "occurrence.move", json!({"occurrence": probe["occurrence"], "translate": [100, 0, 0]}));
let collisions = run(&mut s, "contact.check", json!({}));
assert_eq!(collisions["collisions"].as_array().map(Vec::len), Some(0), "probe moved away: {collisions}");
}
#[test]
fn automatic_explode_centres_follow_reassigned_bodies() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Transferred"}));
let component = run(&mut s, "component.create", json!({"name": "Carrier"}));
run(&mut s, "occurrence.move", json!({"occurrence": component["occurrence"], "translate": [100, 0, 0]}));
run(&mut s, "component.move_bodies", json!({"bodies": ["Transferred"], "component": component["component"]}));
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "component.create", json!({"name": "Neighbour"}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0]}));
let exploded = run(&mut s, "explode.create", json!({"scale": 1}));
let moves = exploded["moves"].as_array().unwrap();
let carrier = moves.iter().find(|m| m["occurrence"] == component["occurrence"]).unwrap();
assert!((carrier["translate"][0].as_f64().unwrap() + 10.0).abs() < 1e-6, "{exploded}");
}
#[test]
fn motion_study_plays_and_exports() {
let mut s = rail();
let r = run(&mut s, "motion.study", json!({"name": "Push", "steps": 100, "keys": [{"joint": "Rail", "points": [[0, 10], [100, -5]]}]}));
assert_eq!(r["keys"], 1);
run(&mut s, "motion.play", json!({"study": "Push", "step": 50}));
assert!((b_x(&s) - 12.5).abs() < 1e-6, "{}", b_x(&s));
// With contact, the end of the study stops at touching.
run(&mut s, "contact.create", json!({"occurrences": ["A", "B"]}));
let r = run(&mut s, "motion.play", json!({"study": "Push", "step": 100}));
assert!(r["stopped_by_contact"].is_object() && (b_x(&s) - 10.0).abs() < 2e-3, "{r}");
// Export: positions per sample; the design keeps its joint value.
run(&mut s, "motion.play", json!({"study": "Push", "step": 0}));
let before = s.doc.clone();
let e = run(&mut s, "motion.export", json!({"study": "Push", "samples": 5}));
assert_eq!(*s.doc, *before);
let rows = e["samples"].as_array().cloned().unwrap_or_default();
assert_eq!(rows.len(), 5);
let bx = |row: &Value| {
let p = row["positions"].as_array().and_then(|a| a.iter().find(|p| p["occurrence"] == "B:1")).cloned().unwrap_or_default();
p["pose"]["origin"][0].as_f64().unwrap_or(f64::NAN)
};
// The B occurrence's origin moves by the slider value (it starts at 0 offset with value 0 at 10 mm… so 10 → −5).
assert!((bx(&rows[1]) - bx(&rows[0]) + 3.75).abs() < 1e-6, "{e}");
assert!(rows[4]["stopped_by_contact"].is_object());
let dir = std::env::temp_dir().join(format!("solidcraft-motion-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let csv = dir.join("push.csv");
run(&mut s, "motion.export", json!({"study": "Push", "samples": 3, "path": csv.to_string_lossy()}));
let text = std::fs::read_to_string(&csv).unwrap();
assert!(text.starts_with("step,occurrence,x,y,z") && text.lines().count() == 1 + 3 * 2, "{text}");
let _ = std::fs::remove_dir_all(&dir);
assert!(s.execute("motion.study", &json!({"keys": [{"joint": "Rail", "index": 3, "points": [[0, 1]]}]})).is_err());
run(&mut s, "motion.delete", json!({"study": "Push"}));
assert_eq!(run(&mut s, "motion.list", json!({}))["studies"].as_array().map(Vec::len), Some(0));
}
#[test]
fn exploded_views_show_without_changing_the_design() {
let mut s = rail();
let before = s.doc.occurrences.clone();
let r = run(&mut s, "explode.create", json!({"name": "Apart", "scale": 1}));
assert_eq!(r["moves"].as_array().map(Vec::len), Some(2), "{r}");
run(&mut s, "explode.show", json!({"view": "Apart"}));
// Centres at x 5 and 25 → middle 15: each moves 10 mm further out.
let bb = |s: &mut Session, b: &str| run(s, "inspect.measure", json!({"bodies": [b]}))["bodies"][0]["bbox"]["min"][0].as_f64().unwrap_or(f64::NAN);
let names: Vec<String> = s.world_state().bodies.iter().map(|b| b.name.clone()).collect();
assert!((bb(&mut s, &names[0]) + 10.0).abs() < 1e-6, "{names:?}");
assert!((bb(&mut s, &names[1]) - 30.0).abs() < 1e-6);
assert_eq!(s.doc.occurrences, before, "shown only");
run(&mut s, "explode.show", json!({"view": null}));
assert!(bb(&mut s, &names[0]).abs() < 1e-6);
run(&mut s, "explode.create", json!({"name": "Hand", "moves": [{"occurrence": "B", "translate": [0, 0, 50]}]}));
run(&mut s, "explode.show", json!({"view": "Hand"}));
assert!(
(run(&mut s, "inspect.measure", json!({"bodies": [names[1]]}))["bodies"][0]["bbox"]["min"][2].as_f64().unwrap_or(0.0) - 50.0).abs() < 1e-6
);
assert_eq!(run(&mut s, "explode.list", json!({}))["views"].as_array().map(Vec::len), Some(2));
}