Motion Trace: a point's path as a joint is driven or a study plays, kept as a 3D spline (#1529)
Thanks @SychPL.
This commit is contained in:
1 parent
dcffab09b1
commit
571122c4ba
19 files changed
+769
-9
No files matched your search
@@ -1179,5 +1179,235 @@ fn a_new_joint_starts_inside_its_limits_and_says_so() {
|
||||
assert!(r.get("warnings").is_none(), "{r}");
|
||||
}
|
||||
|
||||
/// The trace's points as world points.
|
||||
fn traced(r: &Value) -> Vec<Vec3> {
|
||||
r["points"].as_array().into_iter().flatten().filter_map(crate::params::vec3).collect()
|
||||
}
|
||||
|
||||
/// A point rigidly carried from segment (a0, b0) to segment (a, b) in the XY plane.
|
||||
fn carried(p0: Vec3, a0: Vec3, b0: Vec3, a: Vec3, b: Vec3) -> Vec3 {
|
||||
let turn = (b.y - a.y).atan2(b.x - a.x) - (b0.y - a0.y).atan2(b0.x - a0.x);
|
||||
let d = p0 - a0;
|
||||
a + Vec3::new(d.x * turn.cos() - d.y * turn.sin(), d.x * turn.sin() + d.y * turn.cos(), d.z)
|
||||
}
|
||||
|
||||
/// A point on the four-bar's coupler, 10 mm off the line between its pins, traces the coupler
|
||||
/// curve: the crank pin on its circle, the rocker pin where the coupler and rocker circles meet
|
||||
/// (on the open branch), and the point carried with the pins. A motion study turning the crank
|
||||
/// traces the same curve, and the design is left as it was.
|
||||
#[test]
|
||||
fn four_bar_coupler_point_traces_its_closed_form_curve() {
|
||||
let (mut s, p0, q0) = four_bar();
|
||||
let d = (q0 - p0) * (1.0 / q0.dist(p0));
|
||||
let c0 = (p0 + q0) * 0.5 + Vec3::new(-d.y, d.x, 0.0) * 10.0;
|
||||
let before = s.doc.clone();
|
||||
let p = json!({"occurrence": "Coupler", "point": [c0.x, c0.y, c0.z], "joint": "Crank", "from": 0, "to": 360, "samples": 73});
|
||||
let r = run(&mut s, "motion.trace", p);
|
||||
assert_eq!(*s.doc, *before, "a trace leaves the design as it was");
|
||||
assert!(r["stopped_at"].is_null() && r["samples"] == 73, "{r}");
|
||||
let pts = traced(&r);
|
||||
assert_eq!(pts.len(), 73);
|
||||
let mut q = q0;
|
||||
for (k, got) in pts.iter().enumerate() {
|
||||
let v = 5.0 * k as f64;
|
||||
if k > 0 {
|
||||
q = rocker_pin(v - 5.0, v, q);
|
||||
}
|
||||
let th = (90.0 + v).to_radians();
|
||||
let want = carried(c0, p0, q0, Vec3::new(20.0 * th.cos(), 20.0 * th.sin(), 0.0), q);
|
||||
assert!(near(*got, want), "{v}°: {got:?} wants {want:?}");
|
||||
}
|
||||
assert!(pts.first().zip(pts.last()).is_some_and(|(a, b)| near(*a, *b)), "a full turn closes the curve");
|
||||
run(&mut s, "motion.study", json!({"name": "Turn", "steps": 72, "keys": [{"joint": "Crank", "points": [[0, 0], [72, 360]]}]}));
|
||||
let r = run(&mut s, "motion.trace", json!({"occurrence": "Coupler", "point": [c0.x, c0.y, c0.z], "study": "Turn"}));
|
||||
let from_study = traced(&r);
|
||||
assert_eq!(from_study.len(), 73, "{r}");
|
||||
assert!(from_study.iter().zip(&pts).all(|(a, b)| near(*a, *b)), "{r}");
|
||||
}
|
||||
|
||||
/// The middle of a slider-crank's rod: halfway between the crank pin on its circle and the wrist
|
||||
/// pin at a·cos θ + √(b² − (a·sin θ)²).
|
||||
#[test]
|
||||
fn slider_crank_rod_traces_its_closed_form_curve() {
|
||||
let (mut s, p0, s0) = slider_crank();
|
||||
let m0 = (p0 + s0) * 0.5;
|
||||
let r =
|
||||
run(&mut s, "motion.trace", json!({"occurrence": "Rod", "point": [m0.x, m0.y, m0.z], "joint": "Crank", "from": 0, "to": 360, "samples": 37}));
|
||||
let pts = traced(&r);
|
||||
assert_eq!(pts.len(), 37, "{r}");
|
||||
for (k, got) in pts.iter().enumerate() {
|
||||
let th = (90.0 + 10.0 * k as f64).to_radians();
|
||||
let x = 15.0 * th.cos() + (40.0f64.powi(2) - (15.0 * th.sin()).powi(2)).sqrt();
|
||||
let want = (Vec3::new(15.0 * th.cos(), 15.0 * th.sin(), 0.0) + Vec3::new(x, 0.0, 0.0)) * 0.5;
|
||||
assert!(near(*got, want), "{}°: {got:?} wants {want:?}", 10 * k);
|
||||
}
|
||||
}
|
||||
|
||||
/// Driving the rocker past its swing stops where the loop can't close: the trace ends there.
|
||||
#[test]
|
||||
fn a_trace_ends_where_the_loop_stops_the_motion() {
|
||||
let (mut s, _, q0) = four_bar();
|
||||
let start = q0.y.atan2(q0.x - 45.0).to_degrees();
|
||||
let r = run(
|
||||
&mut s,
|
||||
"motion.trace",
|
||||
json!({"occurrence": "Rocker", "point": [q0.x, q0.y, q0.z], "joint": "Rocker", "from": 0, "to": 100, "samples": 21}),
|
||||
);
|
||||
assert!(r["stopped_by_loop"].is_object(), "{r}");
|
||||
let k = r["stopped_at"].as_u64().unwrap_or(u64::MAX) as usize;
|
||||
let pts = traced(&r);
|
||||
assert!(k > 0 && k < 20 && pts.len() == k + 1 && r["samples"] == k + 1, "{r}");
|
||||
let angle = |p: &Vec3| p.y.atan2(p.x - 45.0).to_degrees();
|
||||
for (i, p) in pts.iter().take(k).enumerate() {
|
||||
assert!((angle(p) - (start + 5.0 * i as f64)).abs() < 1e-6, "sample {i}: {}", angle(p));
|
||||
}
|
||||
let limit = ((900.0f64 - 3625.0) / 3600.0).acos().to_degrees();
|
||||
let last = pts.last().map(angle).unwrap_or(f64::NAN);
|
||||
assert!(last > limit - 5.0 && last <= limit + 1e-9, "stopped at {last}°, the limit is {limit}°");
|
||||
}
|
||||
|
||||
/// Keep makes a 3D sketch spline through the trace, in one undo step.
|
||||
#[test]
|
||||
fn a_kept_trace_is_a_3d_sketch_spline() {
|
||||
let (mut s, p0, q0) = four_bar();
|
||||
let c0 = (p0 + q0) * 0.5;
|
||||
let p = json!({"occurrence": "Coupler", "point": [c0.x, c0.y, c0.z], "joint": "Crank", "from": 0, "to": 180, "samples": 19});
|
||||
let pts = traced(&run(&mut s, "motion.trace", p.clone()));
|
||||
let features = s.doc.features.len();
|
||||
let r = run(&mut s, "motion.trace.keep", p);
|
||||
assert_eq!((r["name"].as_str(), r["points"].as_u64()), (Some("Motion Trace1"), Some(19)), "{r}");
|
||||
assert!(s.active_sketch.is_none(), "the sketch is left");
|
||||
let id = r["sketch"].as_u64().unwrap_or(0);
|
||||
let sk = s.doc.sketch(id).cloned().unwrap_or_default();
|
||||
assert!(sk.view.three_d && sk.wires.len() == 1, "{r}");
|
||||
let fit = sk.wires.first().map(|w| w.fit.clone()).unwrap_or_default();
|
||||
assert!(fit.len() == pts.len() && fit.iter().zip(&pts).all(|(a, b)| a.dist(*b) < 1e-9), "{fit:?}");
|
||||
let again = json!({"occurrence": "Coupler", "point": [0, 0, 0], "joint": "Crank", "from": 0, "to": 90});
|
||||
assert_eq!(run(&mut s, "motion.trace.keep", again)["name"], "Motion Trace2");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.features.len(), features);
|
||||
// A point on the ground does not move: there is nothing to keep.
|
||||
let e = s.execute("motion.trace.keep", &json!({"occurrence": "Ground", "point": [0, 0, 0], "joint": "Crank", "from": 0, "to": 90})).unwrap_err();
|
||||
assert!(e.to_string().contains("does not move"), "{e}");
|
||||
}
|
||||
|
||||
/// A full turn kept closes exactly on its start, also when the sample before the end lies a hair
|
||||
/// from it (a study keyed to 359.99999° one step before 360°).
|
||||
#[test]
|
||||
fn a_kept_full_turn_closes_on_its_start() {
|
||||
let mut s = Session::default();
|
||||
part(&mut s, "Ground", [-1.0, -1.0, -5.0]);
|
||||
part(&mut s, "Arm", [0.0, -1.0, 0.0]);
|
||||
let g = occ(&s, "Ground");
|
||||
run(&mut s, "occurrence.ground", json!({"occurrence": g, "grounded": true}));
|
||||
pin(&mut s, "revolute", "Pivot", "Ground", "Arm", Vec3::ZERO, Vec3::Z);
|
||||
run(&mut s, "motion.study", json!({"name": "Turn", "steps": 5, "keys": [{"joint": "Pivot", "points": [[0, 0], [4, 359.99999], [5, 360]]}]}));
|
||||
let r = run(&mut s, "motion.trace.keep", json!({"occurrence": "Arm", "point": [1, 0, 0], "study": "Turn"}));
|
||||
let id = r["sketch"].as_u64().unwrap_or(0);
|
||||
let fit = s.doc.sketch(id).ok().and_then(|sk| sk.wires.first().map(|w| w.fit.clone())).unwrap_or_default();
|
||||
assert_eq!(fit.len(), 5, "{fit:?}");
|
||||
assert_eq!(fit.first(), fit.last(), "{fit:?}");
|
||||
}
|
||||
|
||||
/// Kept while a component is active and shown moved (a move not captured yet): the spline goes
|
||||
/// in the root, where no joint moves it, through the traced points; the component stays active.
|
||||
#[test]
|
||||
fn a_trace_kept_in_a_moved_component_stays_on_the_path() {
|
||||
let (mut s, p0, q0) = four_bar();
|
||||
let crank = occ(&s, "Crank");
|
||||
let c0 = (p0 + q0) * 0.5;
|
||||
let p = json!({"occurrence": "Coupler", "point": [c0.x, c0.y, c0.z], "joint": "Crank", "from": 0, "to": 90, "samples": 4});
|
||||
let pts = traced(&run(&mut s, "motion.trace", p.clone()));
|
||||
run(&mut s, "component.activate", json!({"component": "Crank", "occurrence": crank, "path": [crank]}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": crank, "translate": [0, 0, 100], "capture": false}));
|
||||
let active = (s.active_component, s.active_occurrence_path.clone());
|
||||
let r = run(&mut s, "motion.trace.keep", p);
|
||||
assert_eq!((s.active_component, s.active_occurrence_path.clone()), active);
|
||||
let id = r["sketch"].as_u64().unwrap_or(0);
|
||||
assert_eq!(s.doc.feature(id).map(|f| f.component), Some(0), "{r}");
|
||||
let fit = s.doc.sketch(id).ok().and_then(|sk| sk.wires.first().map(|w| w.fit.clone())).unwrap_or_default();
|
||||
assert!(fit.len() == pts.len() && fit.iter().zip(&pts).all(|(a, b)| near(*a, *b)), "{fit:?} wants {pts:?}");
|
||||
}
|
||||
|
||||
/// A part inside a sub-assembly placed twice: the occurrence path picks the instance traced. The
|
||||
/// second Arm turns about its pivot and its Tip goes round; the first Arm, and so the Tip at its
|
||||
/// first placement, stays put.
|
||||
#[test]
|
||||
fn a_trace_follows_the_instance_its_path_names() {
|
||||
let mut s = Session::default();
|
||||
part(&mut s, "Ground", [-1.0, -1.0, -5.0]);
|
||||
let g = occ(&s, "Ground");
|
||||
run(&mut s, "occurrence.ground", json!({"occurrence": g, "grounded": true}));
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
let arm = run(&mut s, "component.create", json!({"name": "Arm"}));
|
||||
run(&mut s, "component.create", json!({"name": "Tip"}));
|
||||
run(&mut s, "solid.box", json!({"length": 2, "width": 2, "height": 2, "corner": [10, 0, 0]}));
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
let second = run(&mut s, "occurrence.copy", json!({"component": arm["component"], "translate": [0, 50, 0]}))["occurrence"].as_u64().unwrap_or(0);
|
||||
let at = |o: Value| json!({"occurrence": o, "point": [0, 50, 0], "z": [0, 0, 1]});
|
||||
run(&mut s, "joint.create", json!({"type": "revolute", "a": at(json!(g)), "b": at(json!(second)), "flip": true, "name": "Pivot"}));
|
||||
let tip = occ(&s, "Tip");
|
||||
let trace = |s: &mut Session, o: Value| {
|
||||
traced(&run(s, "motion.trace", json!({"occurrence": o, "point": [11, 51, 1], "joint": "Pivot", "from": 0, "to": 90, "samples": 3})))
|
||||
};
|
||||
let turned = trace(&mut s, json!([second, tip]));
|
||||
let pivot = Vec3::new(0.0, 50.0, 1.0);
|
||||
assert!(turned.iter().all(|p| (p.dist(pivot) - 122f64.sqrt()).abs() < 1e-6), "{turned:?}");
|
||||
assert!(turned.first().zip(turned.last()).is_some_and(|(a, b)| (a.dist(*b) - 244f64.sqrt()).abs() < 1e-6), "a quarter turn: {turned:?}");
|
||||
let first = trace(&mut s, json!(tip));
|
||||
assert!(first.iter().all(|p| p.dist(Vec3::new(11.0, 51.0, 1.0)) < 1e-9), "{first:?}");
|
||||
assert!(s.execute("motion.trace", &json!({"occurrence": [tip, second], "point": [0, 0, 0], "joint": "Pivot", "from": 0, "to": 90})).is_err());
|
||||
assert!(s.execute("motion.trace", &json!({"occurrence": vec![second; 65], "point": [0, 0, 0], "joint": "Pivot", "from": 0, "to": 90})).is_err());
|
||||
}
|
||||
|
||||
/// Trace params come from scripts and the control channel: bad ones are refused, never a panic.
|
||||
#[test]
|
||||
fn hostile_trace_params_are_refused() {
|
||||
let (mut s, _, _) = four_bar();
|
||||
run(&mut s, "motion.study", json!({"name": "Turn", "steps": 4, "keys": [{"joint": "Crank", "points": [[0, 0], [4, 90]]}]}));
|
||||
let base = json!({"occurrence": "Coupler", "point": [0, 0, 0], "joint": "Crank", "from": 0, "to": 90});
|
||||
let with = |k: &str, v: Value| {
|
||||
let mut p = base.clone();
|
||||
p[k] = v;
|
||||
p
|
||||
};
|
||||
let without = |k: &str| {
|
||||
let mut p = base.clone();
|
||||
p.as_object_mut().map(|o| o.remove(k));
|
||||
p
|
||||
};
|
||||
for p in [
|
||||
with("samples", json!(0)),
|
||||
with("samples", json!(1)),
|
||||
with("samples", json!(501)),
|
||||
with("samples", json!(1e300)),
|
||||
with("samples", json!(-3)),
|
||||
with("samples", json!("many")),
|
||||
with("point", json!([f64::MAX, 0, 0])),
|
||||
with("point", json!([0, 0])),
|
||||
with("point", json!("here")),
|
||||
without("point"),
|
||||
with("occurrence", json!("Nowhere")),
|
||||
without("occurrence"),
|
||||
with("joint", json!("Nowhere")),
|
||||
with("index", json!(1)),
|
||||
with("index", json!(-1)),
|
||||
with("from", json!("nonsense +")),
|
||||
with("to", json!(1e300)),
|
||||
without("from"),
|
||||
without("to"),
|
||||
with("study", json!("Turn")),
|
||||
with("study", json!(null)),
|
||||
without("joint"),
|
||||
] {
|
||||
for cmd in ["motion.trace", "motion.trace.keep"] {
|
||||
assert!(s.execute(cmd, &p).is_err(), "{cmd} {p}");
|
||||
}
|
||||
}
|
||||
run(&mut s, "joint.as_built", json!({"type": "rigid", "a": "Ground", "b": "Crank", "name": "Lock"}));
|
||||
assert!(s.execute("motion.trace", &with("joint", json!("Lock"))).is_err(), "a rigid joint does not move");
|
||||
}
|
||||
|
||||
#[path = "joints_coupling_tests.rs"]
|
||||
mod coupling;
|
||||
Reference in new issue
Block a user