Files
solidcraft-upstream/crates/engine/src/cmd/motion_analysis_tests.rs
T
MateuszSychXpl 8fdf6a61a2 Motion analysis: plots of a study's joint values and a point's path, and the parts that collide along it
- motion.plot: joint values and a point's path sampled as the study plays (closed loops
  following), with velocities and accelerations by finite differences, per step or per
  second with a duration.
- motion.interference: the pairs of parts that pass through each other along a study, with
  the first step, the step ranges, the depth and the shared volume; occurrences, against
  or contact sets choose the pairs; bounded samples and work.
- Motion Study dialog: a Study box opens a saved study; a chart of a joint value or a
  picked point with a cursor at the step shown (click to move there); Check collisions
  with the steps shaded on the chart. ui.start motion.study takes study.
2026-10-16 17:52:38 +02:00

185 lines
11 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, 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));
}
/// 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}");
}
/// 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", "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}");
}