Files
OneCAD/crates/engine/src/cmd/motion_analysis_tests.rs
T

212 lines
13 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.
//! `motion.plot` against the analytic motion of a four-bar and a slider-crank, and
//! `motion.interference` on two blocks pushed through each other at a known step.
use serde_json::{Value, json};
use solidcraft_geom::Vec3;
use super::{four_bar, occ, part, pin, rocker_pin, run, slider_crank};
use crate::Session;
/// A joint's series in a plot, by joint name.
fn series<'a>(r: &'a Value, name: &str) -> &'a Value {
r["joints"].as_array().and_then(|list| list.iter().find(|j| j["name"] == name)).unwrap_or(&Value::Null)
}
fn nums(v: &Value) -> Vec<f64> {
v.as_array().into_iter().flatten().map(|x| x.as_f64().unwrap_or(f64::NAN)).collect()
}
/// The crank turns at a constant 50°/s (a full turn in 7.2 s). The rocker turns at
/// ω4 = ω2·a·sin(θ2 − θ3) / (c·sin(θ4 − θ3)) (crank a = 20, rocker c = 40; θ2, θ3 and θ4 the
/// crank, coupler and rocker angles), from differentiating the loop a·e^iθ2 + b·e^iθ3 = d + c·e^iθ4.
#[test]
fn four_bar_rocker_velocity_matches_its_analytic_derivative() {
let (mut s, _, q0) = four_bar();
run(&mut s, "motion.study", json!({"name": "Turn", "steps": 360, "keys": [{"joint": "Crank", "points": [[0, 0], [360, 360]]}]}));
let before = s.doc.clone();
let r = run(&mut s, "motion.plot", json!({"study": "Turn", "joints": ["Crank", "Rocker"], "samples": 181, "duration": 7.2}));
assert_eq!(*s.doc, *before, "a plot leaves the design as it was");
assert!(r["stopped_at"].is_null() && r["samples"] == 181, "{}", r["stopped_by_loop"]);
assert_eq!(r["joints"].as_array().map(Vec::len), Some(2));
let (crank, rocker) = (series(&r, "Crank"), series(&r, "Rocker"));
assert_eq!((rocker["unit"].as_str(), rocker["velocity_unit"].as_str()), (Some("deg"), Some("deg/s")));
let time = nums(&r["time"]);
assert!((time.last().copied().unwrap_or(0.0) - 7.2).abs() < 1e-9 && (time[1] - 0.04).abs() < 1e-12, "{time:?}");
// The crank: exactly as keyed, at a constant speed.
for ((v, w), a) in nums(&crank["value"]).iter().zip(nums(&crank["velocity"])).zip(nums(&crank["acceleration"])) {
assert!((w - 50.0).abs() < 1e-6 && a.abs() < 1e-6, "{v}°: {w} deg/s, {a} deg/s²");
}
let (value, velocity) = (nums(&rocker["value"]), nums(&rocker["velocity"]));
let start = q0.y.atan2(q0.x - 45.0);
let (mut q, mut peak, mut worst) = (q0, 0.0f64, 0.0f64);
for k in 0..181 {
let v = 2.0 * k as f64;
if k > 0 {
q = rocker_pin(v - 2.0, v, q);
}
let th2 = (90.0 + v).to_radians();
let p = Vec3::new(20.0 * th2.cos(), 20.0 * th2.sin(), 0.0);
let (th3, th4) = ((q - p).y.atan2((q - p).x), q.y.atan2(q.x - 45.0));
// The rocker's value is its turn from where it was assembled, made continuous.
let turned = (th4 - start).to_degrees();
assert!(((value[k] - turned + 540.0).rem_euclid(360.0) - 180.0).abs() < 1e-6, "{v}°: {} wants {turned}", value[k]);
let w4 = 50.0 * 20.0 * (th2 - th3).sin() / (40.0 * (th4 - th3).sin());
peak = peak.max(w4.abs());
worst = worst.max((velocity[k] - w4).abs());
}
// Central differences 0.04 s apart: within 0.2 % of the fastest swing.
assert!(peak > 40.0 && worst < 2e-3 * peak, "worst {worst} deg/s of {peak} deg/s");
}
/// The piston of a slider-crank (crank a = 15, rod b = 40) turning at 100°/s sits at
/// x = a·cos θ + S with S = √(b² − a²·sin²θ), so it accelerates at
/// ω²·(−a·cos θ − a²·cos 2θ / S − a⁴·sin²θ·cos²θ / S³).
#[test]
fn slider_crank_piston_acceleration_matches_its_analytic_form() {
let (mut s, _, s0) = slider_crank();
run(&mut s, "motion.study", json!({"name": "Turn", "steps": 360, "keys": [{"joint": "Crank", "points": [[0, 0], [360, 360]]}]}));
let p = json!({"study": "Turn", "joints": ["Bore"], "occurrence": "Piston", "point": [s0.x, s0.y, s0.z], "samples": 181, "duration": 3.6});
let r = run(&mut s, "motion.plot", p);
assert!(r["stopped_at"].is_null() && r["samples"] == 181, "{}", r["stopped_by_loop"]);
let point = &r["point"];
assert_eq!((point["occurrence"].as_str(), point["acceleration_unit"].as_str()), (Some("Piston:1"), Some("mm/s²")));
let at = |key: &str, k: usize, c: usize| point[key][k][c].as_f64().unwrap_or(f64::NAN);
let (a, b, w) = (15.0f64, 40.0f64, 100.0f64.to_radians());
let (mut peak, mut worst) = (0.0f64, 0.0f64);
for k in 0..181 {
let th = (90.0 + 2.0 * k as f64).to_radians();
let root = (b * b - (a * th.sin()).powi(2)).sqrt();
assert!((at("position", k, 0) - (a * th.cos() + root)).abs() < 1e-6, "{k}");
let want = w * w * (-a * th.cos() - a * a * (2.0 * th).cos() / root - a.powi(4) * (th.sin() * th.cos()).powi(2) / root.powi(3));
peak = peak.max(want.abs());
// The ends take one-sided differences; inside, central ones.
let tolerance = if k == 0 || k == 180 { 2e-2 } else { 2e-3 };
worst = worst.max((at("acceleration", k, 0) - want).abs() / tolerance);
assert!(at("acceleration", k, 1).abs() < 1e-6 && at("acceleration", k, 2).abs() < 1e-6, "it stays on its axis");
}
assert!(peak > 40.0 && worst < peak, "worst {worst} of {peak} mm/s² (scaled by tolerance)");
// The bore's value is the piston's travel, so its acceleration is the piston's along x.
let bore = series(&r, "Bore");
assert_eq!(bore["acceleration_unit"], "mm/s²");
let along = |k: usize| nums(&bore["value"])[k] - nums(&bore["value"])[0];
let sign = if along(90) * (at("position", 90, 0) - at("position", 0, 0)) < 0.0 { -1.0 } else { 1.0 };
for (k, acc) in nums(&bore["acceleration"]).iter().enumerate() {
assert!((acc - sign * at("acceleration", k, 0)).abs() < 1e-6, "{k}: {acc}");
}
// Per step without a duration: 2 steps between samples, 1° a step.
let r = run(&mut s, "motion.plot", json!({"study": "Turn", "joints": ["Crank"], "samples": 181}));
let crank = series(&r, "Crank");
assert!(r["time"].is_null() && crank["velocity_unit"] == "deg/step", "{crank}");
assert!(nums(&crank["velocity"]).iter().all(|v| (v - 1.0).abs() < 1e-9));
}
/// A dial linked 2:1 to the crank turns on past a turn: its values are not taken a turn round,
/// even half a turn a sample.
#[test]
fn a_linked_value_past_half_a_turn_runs_on() {
let (mut s, _, _) = four_bar();
part(&mut s, "Dial", [80.0, 0.0, -5.0]);
pin(&mut s, "revolute", "Dial", "Ground", "Dial", Vec3::new(80.0, 0.0, 0.0), Vec3::Z);
run(&mut s, "joint.motion_link", json!({"a": "Crank", "b": "Dial", "ratio": 2}));
run(&mut s, "motion.study", json!({"name": "Turn", "steps": 2, "keys": [{"joint": "Crank", "points": [[0, 0], [2, 360]]}]}));
let r = run(&mut s, "motion.plot", json!({"study": "Turn", "joints": ["Dial"], "samples": 3}));
let v: Vec<f64> = nums(&series(&r, "Dial")["value"]).iter().map(|x| x.abs()).collect();
assert!((v[1] - 360.0).abs() < 1e-6 && (v[2] - 720.0).abs() < 1e-6, "{r}");
}
/// Cubes A (grounded, x 0…10) and B (x 20…30) on a slider along x, B 10 mm away at value 10.
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}));
s
}
/// B is pushed 1 mm a step from 10 mm away to 5 mm into A: the faces touch at step 10 and the
/// blocks overlap from step 11 to the end, 100 mm³ more each step.
#[test]
fn interference_names_the_step_two_parts_start_to_collide() {
let mut s = rail();
run(&mut s, "motion.study", json!({"name": "Push", "steps": 15, "keys": [{"joint": "Rail", "points": [[0, 10], [15, -5]]}]}));
// A contact set would stop the motion where the faces touch; the check plays through.
run(&mut s, "contact.create", json!({"occurrences": ["A", "B"]}));
let before = s.doc.clone();
for p in [json!({"study": "Push"}), json!({"study": "Push", "occurrences": ["A", "B"]}), json!({"study": "Push", "contact_sets": true})] {
let r = run(&mut s, "motion.interference", p.clone());
assert_eq!(*s.doc, *before, "the design is left as it was");
assert!(r["clear"] == false && r["samples"] == 16 && r["pairs"] == 1 && r["truncated"] == false, "{p}: {r}");
let c = &r["collisions"][0];
assert_eq!((c["a"].as_str(), c["b"].as_str()), (Some("A:1"), Some("B:1")), "{r}");
assert_eq!((c["first_step"].as_f64(), c["last_step"].as_f64()), (Some(11.0), Some(15.0)), "{r}");
assert_eq!(c["steps"], json!([[11.0, 15.0]]), "{r}");
let deepest = c["deepest_step"].as_f64().unwrap_or(f64::NAN);
assert!(c["depth_mm"].as_f64().unwrap_or(0.0) > 1.0 && (11.0..=15.0).contains(&deepest), "{r}");
let volume = c["volume_mm3"].as_f64().unwrap_or(f64::NAN);
assert!((volume - 100.0 * (deepest - 10.0)).abs() < 1e-6, "{r}");
}
// B against a far block C only: B's pass through A is not asked about.
run(&mut s, "component.create", json!({"name": "C"}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [0, 50, 0]}));
run(&mut s, "component.activate", json!({"component": "root"}));
let r = run(&mut s, "motion.interference", json!({"study": "Push", "occurrences": ["B"], "against": ["C"]}));
assert!(r["clear"] == true && r["pairs"] == 1, "{r}");
let r = run(&mut s, "motion.interference", json!({"study": "Push", "occurrences": ["B"], "against": ["A", "C"]}));
assert!(r["pairs"] == 2 && r["collisions"].as_array().map(Vec::len) == Some(1) && r["collisions"][0]["first_step"] == 11.0, "{r}");
assert!(s.execute("motion.interference", &json!({"study": "Push", "against": ["A"]})).is_err());
// Pushed only to touching, they never pass through each other.
run(&mut s, "motion.study", json!({"study": "Push", "keys": [{"joint": "Rail", "points": [[0, 10], [15, 0]]}]}));
let r = run(&mut s, "motion.interference", json!({"study": "Push"}));
assert!(r["clear"] == true && r["collisions"] == json!([]), "{r}");
}
/// A number names a study by its id first, text by its name first: a study may be named like
/// another's id.
#[test]
fn studies_by_id_or_name() {
let mut s = rail();
let keys = json!([{"joint": "Rail", "points": [[0, 10], [15, -5]]}]);
let first = run(&mut s, "motion.study", json!({"name": "Late", "steps": 15, "keys": keys}))["study"].as_u64().unwrap_or(0);
run(&mut s, "motion.study", json!({"name": first.to_string(), "steps": 15, "keys": keys}));
assert_eq!(run(&mut s, "motion.plot", json!({"study": first, "samples": 3}))["study"], "Late");
assert_eq!(run(&mut s, "motion.plot", json!({"study": first.to_string(), "samples": 3}))["study"], first.to_string());
}
/// Hostile parameters are refused, never a panic.
#[test]
fn motion_analysis_refuses_what_it_cannot_do() {
let mut s = rail();
run(&mut s, "motion.study", json!({"name": "Push", "steps": 15, "keys": [{"joint": "Rail", "points": [[0, 10], [15, -5]]}]}));
let refused = [
("motion.plot", json!({"study": "Nope"})),
("motion.plot", json!({"study": "Push", "samples": 2})),
("motion.plot", json!({"study": "Push", "samples": 1e9})),
("motion.plot", json!({"study": "Push", "samples": "many"})),
("motion.plot", json!({"study": "Push", "duration": 0})),
("motion.plot", json!({"study": "Push", "duration": -3})),
("motion.plot", json!({"study": "Push", "duration": 1e-200})),
("motion.plot", json!({"study": "Push", "joints": ["Nope"]})),
("motion.plot", json!({"study": "Push", "joints": vec![json!("Rail"); 300]})),
("motion.plot", json!({"study": "Push", "point": [1, 2]})),
("motion.plot", json!({"study": "Push", "occurrence": "B", "point": [1e300, 0, 0]})),
("motion.interference", json!({"study": "Push", "samples": 1})),
("motion.interference", json!({"study": "Push", "samples": f64::MAX})),
("motion.interference", json!({"study": "Push", "occurrences": ["A"]})),
("motion.interference", json!({"study": "Push", "occurrences": ["A", "B"], "contact_sets": true})),
("motion.interference", json!({"study": "Push", "contact_sets": true})),
];
for (cmd, p) in refused {
assert!(s.execute(cmd, &p).is_err(), "{cmd} {p}");
}
// A point of a part that does not move has no velocity.
let r = run(&mut s, "motion.plot", json!({"study": "Push", "occurrence": "A", "point": [5, 5, 5]}));
assert!(r["point"]["speed"].as_array().is_some_and(|v| v.len() == 16 && v.iter().all(|x| x.as_f64() == Some(0.0))), "{r}");
}