- 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.
185 lines
11 KiB
Rust
185 lines
11 KiB
Rust
//! `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}");
|
||
}
|