//! Motion Study analysis rows: a chart over the study's timeline of a joint's value, or of a //! picked point's travel, as value, velocity or acceleration (`motion.plot`), with a cursor at //! the step shown; and the parts that collide as the study plays (`motion.interference`). Both //! are worked out on a scratch copy, again only when what they are for changes. use std::hash::{Hash, Hasher}; use egui::{Align2, FontId, Rect, RichText, Sense, Stroke, pos2, vec2}; use serde_json::{Value, json}; use solidcraft_engine::Session; use solidcraft_engine::doc::joints::JointKind; use crate::SolveApp; use crate::dialogs::{combo, row_label}; use crate::selection::SelInput; use crate::theme::Tokens; /// Most samples the chart takes (each is a solve). const SAMPLES: u64 = 201; pub const QUANTITIES: [&str; 3] = ["Value", "Velocity", "Acceleration"]; /// What the chart shows. #[derive(Clone, Copy, Debug, PartialEq)] pub enum Plotted { Nothing, /// A joint's value: (joint id, value index). Joint(u64, usize), /// The picked point: its distance from where it starts. Point, } #[derive(Clone, Debug, Default)] pub struct Analysis { /// Index into `choices`. pub what: usize, /// Index into `QUANTITIES`. pub quantity: usize, /// Seconds the whole study takes (empty: rates per step). pub duration: String, /// Collisions among all components (0) or in the design's contact sets (1). pub among: usize, /// The joint last picked in the browser, which the chart then shows. followed: Option, /// The chart's `motion.plot` answer (or why none) and what it was for. plot: Option<(u64, Result)>, /// The last collision check's answer and what it was for. collisions: Option<(u64, Result)>, } /// What the chart can show: (label, what). pub fn choices(s: &Session, point: bool) -> Vec<(String, Plotted)> { let mut out = vec![("None".to_string(), Plotted::Nothing)]; for j in s.doc.assembly.joints.iter().filter(|j| j.kind != JointKind::Rigid && !j.suppressed) { let dofs = j.kind.dofs(); for (i, d) in dofs.iter().enumerate() { out.push((if dofs.len() > 1 { format!("{} · {d}", j.name) } else { j.name.clone() }, Plotted::Joint(j.id, i))); } } if point { out.push(("Picked point".into(), Plotted::Point)); } out } /// `motion.plot`'s parameters for `study` (the study command's parameters). pub fn plot_params(s: &Session, what: Plotted, study: &Value, duration: &str, inputs: &[SelInput]) -> Result { let name = study.get("study").or_else(|| study.get("name")).cloned().unwrap_or(Value::Null); let steps = study["steps"].as_u64().unwrap_or(100); let mut p = json!({"study": name, "samples": steps.saturating_add(1).clamp(3, SAMPLES)}); match what { Plotted::Nothing => return Err(String::new()), Plotted::Joint(id, _) => p["joints"] = json!([id]), Plotted::Point => { let picked = crate::dialogs_assembly::picked_point(s, inputs)?; p["joints"] = json!([]); p["occurrence"] = picked["occurrence"].clone(); p["point"] = picked["point"].clone(); } } let d = duration.trim(); if !d.is_empty() { let seconds = d.parse::().ok().filter(|x| (1e-3..=1e9).contains(x)).ok_or("the duration must be a number of seconds, 0.001 or more")?; p["duration"] = json!(seconds); } Ok(p) } fn nums(v: &Value) -> Vec { v.as_array().into_iter().flatten().map(|x| x.as_f64().unwrap_or(f64::NAN)).collect() } /// The chart's line from `motion.plot`'s answer: (study steps, values, unit). pub fn line(v: &Value, what: Plotted, quantity: usize) -> Option<(Vec, Vec, String)> { let series = match what { Plotted::Nothing => return None, Plotted::Joint(_, i) => v["joints"].as_array()?.iter().find(|j| j["index"] == i)?, Plotted::Point => &v["point"], }; let unit = series[["unit", "velocity_unit", "acceleration_unit"].get(quantity)?].as_str()?.to_string(); let ys = match (what, quantity) { (Plotted::Point, 0) => { let at = |p: &Value| [0, 1, 2].map(|c| p[c].as_f64().unwrap_or(f64::NAN)); let pts: Vec<[f64; 3]> = series["position"].as_array()?.iter().map(at).collect(); let start = *pts.first()?; pts.iter().map(|p| p.iter().zip(&start).map(|(a, b)| (a - b).powi(2)).sum::().sqrt()).collect() } (Plotted::Point, 1) => nums(&series["speed"]), (Plotted::Point, _) => nums(&series["acceleration_norm"]), (_, q) => nums(&series[*["value", "velocity", "acceleration"].get(q)?]), }; Some((nums(&v["step"]), ys, unit)) } /// A short number for an axis or a readout. fn short(v: f64) -> String { let a = v.abs(); if a >= 1000.0 { format!("{v:.0}") } else if a >= 10.0 { format!("{v:.1}") } else { format!("{v:.3}") } } /// The value of a line at step `at` (linear between samples; `None` outside it). pub fn value_at(xs: &[f64], ys: &[f64], at: f64) -> Option { let k = xs.iter().position(|x| *x >= at)?; let (x1, y1) = (*xs.get(k)?, *ys.get(k)?); if k == 0 || x1 <= at { return Some(y1); } let (x0, y0) = (*xs.get(k - 1)?, *ys.get(k - 1)?); Some(y0 + (y1 - y0) * (at - x0) / (x1 - x0).max(1e-12)) } /// A line chart of `ys` over study steps `xs` (0…`total`), with the step shown as a cursor and /// `bands` (step ranges where parts collide) shaded. Returns the step clicked or dragged to. fn chart(ui: &mut egui::Ui, xs: &[f64], ys: &[f64], unit: &str, total: u32, now: f64, bands: &[[f64; 2]], t: &Tokens) -> Option { let (r, resp) = ui.allocate_exact_size(vec2(300.0, 120.0), Sense::click_and_drag()); crate::scenario::publish_rect("motion:chart", r); let p = ui.painter_at(r); p.rect(r, 3.0, t.field, Stroke::new(1.0, t.border), egui::StrokeKind::Inside); let plot = Rect::from_min_max(r.min + vec2(6.0, 16.0), r.max - vec2(6.0, 16.0)); let total = f64::from(total.max(1)); let x = |s: f64| plot.left() + plot.width() * (s / total).clamp(0.0, 1.0) as f32; for [a, b] in bands { let (a, b) = (x(*a), x(*b)); p.rect_filled(Rect::from_x_y_ranges((a - 1.0)..=(b + 1.0), r.y_range()), 0.0, t.interference.gamma_multiply(0.35)); } let (lo, hi) = ys.iter().filter(|v| v.is_finite()).fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), v| (a.min(*v), b.max(*v))); if lo.is_finite() { // A flat line sits in the middle. let pad = if hi - lo < 1e-9 * hi.abs().max(1.0) { 1.0f64.max(hi.abs() * 0.1) } else { 0.0 }; let (lo, hi) = (lo - pad, hi + pad); let y = |v: f64| plot.bottom() - plot.height() * ((v - lo) / (hi - lo)) as f32; if lo < 0.0 && hi > 0.0 { p.line_segment([pos2(plot.left(), y(0.0)), pos2(plot.right(), y(0.0))], Stroke::new(1.0, t.border)); } let pts: Vec = xs.iter().zip(ys).filter(|(_, v)| v.is_finite()).map(|(s, v)| pos2(x(*s), y(*v))).collect(); p.add(egui::Shape::line(pts, Stroke::new(1.5, t.accent))); let font = FontId::proportional(10.0); p.text(r.left_top() + vec2(4.0, 2.0), Align2::LEFT_TOP, format!("{} {unit}", short(hi)), font.clone(), t.text_dim); p.text(r.left_bottom() + vec2(4.0, -2.0), Align2::LEFT_BOTTOM, format!("{} {unit}", short(lo)), font.clone(), t.text_dim); if let Some(v) = value_at(xs, ys, now) { p.text(r.right_top() + vec2(-4.0, 2.0), Align2::RIGHT_TOP, format!("{} {unit}", short(v)), font, t.text); } } // Where steps 0 and `total` fall, for a scenario or an agent to click between. crate::scenario::publish_handle("motion:chart_start", pos2(x(0.0), plot.center().y)); crate::scenario::publish_handle("motion:chart_end", pos2(x(total), plot.center().y)); let cx = x(now); p.line_segment([pos2(cx, r.top()), pos2(cx, r.bottom())], Stroke::new(1.5, t.warning)); let pos = resp.interact_pointer_pos().filter(|_| resp.clicked() || resp.dragged())?; Some((f64::from((pos.x - plot.left()) / plot.width().max(1.0)).clamp(0.0, 1.0) * total).round()) } /// What the motion stopping short ends the chart with (`None`: it went all the way). fn stop_text(v: &Value) -> Option { let at = v["stopped_at"].as_u64()?.to_string(); let name = |x: &Value| x.as_str().unwrap_or_default().to_string(); if let Some(l) = v["stopped_by_loop"].as_object() { return Some(crate::i18n::format("trace.stopped_by_loop", &[("sample", &at), ("joint", &name(&l["joint"]))])); } let c = v["stopped_by_contact"].as_object()?; Some(crate::i18n::format("trace.stopped_by_contact", &[("sample", &at), ("a", &name(&c["a"])), ("b", &name(&c["b"]))])) } fn key_of(x: impl std::fmt::Debug) -> u64 { let mut h = std::collections::hash_map::DefaultHasher::new(); format!("{x:?}").hash(&mut h); h.finish() } /// The occurrences of the bodies picked in input `i` (the instance each pick is on). fn picked_occurrences(s: &Session, inputs: &[SelInput], i: usize) -> Vec { let names: Vec<&str> = inputs.get(i).map(|inp| inp.items.iter().filter_map(crate::dialogs_assembly::body_of).collect()).unwrap_or_default(); if names.is_empty() { return Vec::new(); } let mut out = Vec::new(); for (name, _, path, _) in s.doc.body_frames(&s.model.state()) { if let Some(o) = path.last().filter(|_| names.contains(&name.as_str())) && !out.contains(o) { out.push(*o); } } out } /// The step ranges where any pair collides, from `motion.interference`'s answer. pub fn collision_bands(v: &Value) -> Vec<[f64; 2]> { let ranges = v["collisions"].as_array().into_iter().flatten().flat_map(|c| c["steps"].as_array().cloned().unwrap_or_default()); ranges.filter_map(|r| Some([r.get(0)?.as_f64()?, r.get(1)?.as_f64()?])).collect() } /// The analysis rows by a study's timeline: `study` is the study command's parameters (or why /// there are none yet), `step` the step shown (a click on the chart or a collision moves it). #[allow(clippy::too_many_arguments)] pub fn rows( app: &SolveApp, ui: &mut egui::Ui, a: &mut Analysis, study: &Result, step: &mut f64, playing: &mut bool, total: u32, inputs: &[SelInput], ) { let t = Tokens::get(); // The collision check first: the chart below it shades what it finds. collision_rows(app, ui, a, study, step, playing, inputs, &t); plot_rows(app, ui, a, study, step, playing, total, inputs, &t); } /// The Collisions rows: Check collisions and what it found (a click on a pair shows its first step). #[allow(clippy::too_many_arguments)] fn collision_rows( app: &SolveApp, ui: &mut egui::Ui, a: &mut Analysis, study: &Result, step: &mut f64, playing: &mut bool, inputs: &[SelInput], t: &Tokens, ) { // Collisions over the whole study: of the picked components (against the other picks, if // any), else among all components or in the contact sets. // Offered when there is a set to check (suppressed ones don't count). let assembly = &app.session.doc.assembly; let sets = assembly.contact_all || assembly.contact_sets.iter().any(|c| !c.suppressed); a.among = if sets { a.among.min(1) } else { 0 }; let (parts, against) = (picked_occurrences(&app.session, inputs, 1), picked_occurrences(&app.session, inputs, 2)); let among = if !parts.is_empty() { let mut p = json!({"occurrences": parts}); if !against.is_empty() { p["against"] = json!(against); } p } else { json!({"contact_sets": a.among == 1}) }; let check = study.as_ref().ok().map(|st| (st.clone(), among)); let key = key_of(&check); if a.collisions.as_ref().is_some_and(|(k, _)| *k != key) { a.collisions = None; } row_label(ui, "Collisions"); ui.horizontal(|ui| { if sets && parts.is_empty() { combo(ui, "ms_among", &["All components", "Contact sets"], &mut a.among); } let button = ui.add_enabled(check.is_some(), egui::Button::new(crate::i18n::tr_label("Check collisions"))); crate::scenario::publish_handle("motion:check", button.rect.center()); if button.clicked() && let Some((st, mut p)) = check { let mut sc = app.session.scratch(); let r = super::scratch_study(&mut sc, &st).and_then(|id| { p["study"] = id; sc.execute("motion.interference", &p).map_err(|e| e.to_string()) }); a.collisions = Some((key, r)); } }); ui.end_row(); match a.collisions.as_ref().map(|(_, r)| r) { Some(Ok(v)) => { let hits = v["collisions"].as_array().cloned().unwrap_or_default(); row_label(ui, ""); ui.vertical(|ui| { if hits.is_empty() { ui.label(RichText::new(crate::i18n::tr("motion.no_collisions")).color(t.text_dim)); } for (i, c) in hits.iter().enumerate() { let steps: Vec = c["steps"] .as_array() .into_iter() .flatten() .map(|r| { let (from, to) = (r[0].as_f64().unwrap_or(0.0).round(), r[1].as_f64().unwrap_or(0.0).round()); if from == to { format!("{from}") } else { format!("{from}-{to}") } }) .collect(); let name = |k: &str| c[k].as_str().unwrap_or_default().to_string(); let text = crate::i18n::format("motion.collision", &[("a", &name("a")), ("b", &name("b")), ("steps", &steps.join(", "))]); // A click shows where they first collide. let link = ui.link(RichText::new(text).color(t.error)); crate::scenario::publish_handle(&format!("motion:collision:{i}"), link.rect.center()); if link.clicked() && let Some(first) = c["first_step"].as_f64() { (*step, *playing) = (first, false); } } if v["truncated"] == true { let to = v["checked_to"].as_f64().map(|x| x.round().to_string()).unwrap_or_default(); ui.label(RichText::new(crate::i18n::format("motion.checked_to", &[("step", &to)])).color(t.warning)); } }); ui.end_row(); } Some(Err(e)) => { row_label(ui, ""); ui.add(egui::Label::new(RichText::new(e.as_str()).color(t.error)).wrap()); ui.end_row(); } None => {} } } /// The Plot rows: what to chart, and the chart (a click on it moves the step). #[allow(clippy::too_many_arguments)] fn plot_rows( app: &SolveApp, ui: &mut egui::Ui, a: &mut Analysis, study: &Result, step: &mut f64, playing: &mut bool, total: u32, inputs: &[SelInput], t: &Tokens, ) { let s = &app.session; let picked = inputs.first().is_some_and(|i| !i.items.is_empty()); let list = choices(s, picked); a.what = a.what.min(list.len().saturating_sub(1)); // A joint picked in the browser is charted (until the box or another pick says otherwise). let browsed = app.tree.picked_joints.first().copied(); if browsed != a.followed { a.followed = browsed; if let Some(i) = browsed.and_then(|id| list.iter().position(|(_, w)| *w == Plotted::Joint(id, 0))) { a.what = i; } } row_label(ui, "Plot"); let labels: Vec<&str> = list.iter().map(|(l, _)| l.as_str()).collect(); combo(ui, "ms_plot", &labels, &mut a.what); ui.end_row(); let what = list.get(a.what).map_or(Plotted::Nothing, |(_, w)| *w); let bands = a.collisions.as_ref().and_then(|(_, r)| r.as_ref().ok()).map(collision_bands).unwrap_or_default(); if what != Plotted::Nothing { row_label(ui, "Quantity"); combo(ui, "ms_quantity", &QUANTITIES, &mut a.quantity); ui.end_row(); row_label(ui, "Duration"); let r = ui.add(egui::TextEdit::singleline(&mut a.duration).hint_text(crate::i18n::tr("motion.duration_hint"))); crate::scenario::publish_handle("dialog:Duration", r.rect.center()); ui.end_row(); let params = study.clone().and_then(|st| plot_params(s, what, &st, &a.duration, inputs).map(|p| (st, p))); let key = key_of(¶ms); if a.plot.as_ref().is_none_or(|(k, _)| *k != key) { let r = params.and_then(|(st, mut p)| { let mut sc = s.scratch(); p["study"] = super::scratch_study(&mut sc, &st)?; sc.execute("motion.plot", &p).map_err(|e| e.to_string()) }); a.plot = Some((key, r)); } row_label(ui, ""); match a.plot.as_ref().map(|(_, r)| r) { Some(Ok(v)) => { ui.vertical(|ui| { if let Some((xs, ys, unit)) = line(v, what, a.quantity) && let Some(to) = chart(ui, &xs, &ys, &unit, total, *step, &bands, t) { (*step, *playing) = (to, false); } if let Some(why) = stop_text(v) { ui.add(egui::Label::new(RichText::new(why).color(t.warning)).wrap()); } }); } Some(Err(e)) => { ui.add(egui::Label::new(RichText::new(e.as_str()).color(t.text_dim)).wrap()); } None => {} } ui.end_row(); } } #[cfg(test)] mod tests { use super::*; /// The chart's line: a joint's quantity with its unit, or a point's distance from its start. #[test] fn lines_come_from_the_plot_answer() { let v = json!({"step": [0.0, 1.0, 2.0], "joints": [{"index": 0, "unit": "deg", "velocity_unit": "deg/s", "acceleration_unit": "deg/s²", "value": [0, 10, 30], "velocity": [5, 15, 25], "acceleration": [10, 10, 10]}], "point": {"unit": "mm", "velocity_unit": "mm/step", "acceleration_unit": "mm/step²", "position": [[0, 0, 0], [3, 4, 0], [6, 8, 0]], "speed": [5, 5, 5], "acceleration_norm": [0, 0, 0]}}); assert_eq!(line(&v, Plotted::Joint(7, 0), 1), Some((vec![0.0, 1.0, 2.0], vec![5.0, 15.0, 25.0], "deg/s".into()))); assert_eq!(line(&v, Plotted::Point, 0).map(|l| l.1), Some(vec![0.0, 5.0, 10.0])); assert_eq!(line(&v, Plotted::Point, 2).map(|l| l.2), Some("mm/step²".into())); assert_eq!(line(&v, Plotted::Joint(7, 1), 0), None); assert_eq!(line(&v, Plotted::Nothing, 0), None); assert_eq!(value_at(&[0.0, 2.0, 4.0], &[0.0, 10.0, 30.0], 3.0), Some(20.0)); assert_eq!(value_at(&[0.0, 2.0], &[1.0, 3.0], 9.0), None); let hits = json!({"collisions": [{"steps": [[11.0, 15.0], [20.0, 20.0]]}]}); assert_eq!(collision_bands(&hits), vec![[11.0, 15.0], [20.0, 20.0]]); // A duration is a number of seconds, more than 0; empty is per step. let s = Session::default(); let study = json!({"name": "S", "steps": 10}); let p = |d: &str| plot_params(&s, Plotted::Joint(7, 0), &study, d, &[]); assert_eq!(p("2").unwrap()["duration"], json!(2.0)); assert!(p("").unwrap().get("duration").is_none()); for bad in ["1e400", "nan", "-1", "0", "soon"] { assert!(p(bad).is_err(), "{bad}"); } } /// The analysis of the open Motion Study dialog, and its step. fn shown(h: &crate::scenario::Harness) -> (Analysis, f64) { match h.app.dialog.as_ref().map(|d| &d.kind) { Some(crate::dialogs::Kind::Motion(crate::dialogs_motion::Mo::MotionStudy { analysis, step, .. })) => ((**analysis).clone(), *step), _ => panic!("no Motion Study dialog"), } } /// In the dialog: a slider pushing B 1 mm a step from 10 mm off A to 5 mm into it. Plotting /// the slider's velocity draws a chart of -1 mm/step; a click on the chart moves the step /// there, and Check collisions names A and B from step 11. #[test] fn the_study_dialog_charts_a_joint_and_finds_collisions() { let mut h = crate::scenario::Harness::new(); for (name, x) in [("A", 0), ("B", 20)] { h.app.run("component.activate", json!({"component": "root"})).unwrap(); h.app.run("component.create", json!({ "name": name })).unwrap(); h.app.run("solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0]})).unwrap(); } h.app.run("component.activate", json!({"component": "root"})).unwrap(); h.app.run("occurrence.ground", json!({"occurrence": "A:1", "grounded": true})).unwrap(); h.app.run("joint.create", json!({"type": "slider", "a": {"face": [10, 5, 5]}, "b": {"face": [20, 5, 5]}, "name": "Rail"})).unwrap(); h.app.run("joint.drive", json!({"joint": "Rail", "value": 10})).unwrap(); let push = h .app .run("motion.study", json!({"name": "Push", "steps": 15, "keys": [{"joint": "Rail", "points": [[0, 10], [15, -5]]}]})) .unwrap()["study"] .to_string(); // A study keyed between whole steps or off its timeline is not opened (the dialog would move its keys). h.app.run("motion.study", json!({"name": "Fine", "steps": 15, "keys": [{"joint": "Rail", "points": [[0, 10], [7.5, -5]]}]})).unwrap(); h.app.run("motion.study", json!({"name": "Off", "steps": 15, "keys": [{"joint": "Rail", "points": [[-1, 10], [15, -5]]}]})).unwrap(); // A tiny key keeps its value in the form (OK writes the study back from it). h.app.run("motion.study", json!({"name": "Tiny", "steps": 15, "keys": [{"joint": "Rail", "points": [[0, 0.00004], [15, 3]]}]})).unwrap(); let (_, _, _, tiny) = crate::dialogs_motion::study_tracks(&h.app.session, "Tiny", false).unwrap(); assert_eq!(tiny[0].points, vec![(0, "0.00004 mm".to_string()), (15, "3 mm".to_string())]); for bad in [json!({"study": "Nope"}), json!({"study": "Push", "step": 3}), json!({"study": [1]}), json!({"study": "Fine"}), json!({"study": "Off"})] { let r = h.call("ui.start", json!({"command": "motion.study", "params": bad})); assert!(r["ok"] == false && h.app.dialog.is_none(), "{bad}: {r}"); } // The control channel opens the saved study to edit (and chart). let r = h.call("ui.start", json!({"command": "motion.study", "params": {"study": "Push"}})); assert_eq!(r["ok"], true, "{r}"); if let Some(crate::dialogs::Kind::Motion(crate::dialogs_motion::Mo::MotionStudy { study, steps, tracks, analysis, .. })) = h.app.dialog.as_mut().map(|d| &mut d.kind) { assert_eq!((study.as_deref(), steps.as_str()), (Some(push.as_str()), "15")); assert_eq!(*tracks, vec![crate::dialogs_motion::Track { joint: 0, index: 0, points: vec![(0, "10 mm".into()), (15, "-5 mm".into())] }]); (analysis.what, analysis.quantity) = (1, 1); } h.frames(4); let (a, _) = shown(&h); let plot = a.plot.as_ref().and_then(|(_, r)| r.as_ref().ok()).cloned().unwrap_or_default(); let (_, ys, unit) = line(&plot, Plotted::Joint(0, 0), 1).unwrap(); assert!(unit == "mm/step" && ys.len() == 16 && ys.iter().all(|v| (v + 1.0).abs() < 1e-9), "{plot}"); let chart = crate::scenario::rect_of("motion:chart").unwrap(); // The plot area starts 6 points in: two thirds of the way along is step 10. let x = chart.left() + 6.0 + (chart.width() - 12.0) * 2.0 / 3.0; h.step(&json!({"click": [x, chart.center().y]})).unwrap(); h.frames(2); assert_eq!(shown(&h).1, 10.0); let [bx, by] = h.widget("Check collisions").unwrap(); h.step(&json!({"click": [bx, by]})).unwrap(); h.frames(2); let (a, _) = shown(&h); let found = a.collisions.as_ref().and_then(|(_, r)| r.as_ref().ok()).cloned().unwrap_or_default(); assert_eq!((&found["collisions"][0]["a"], &found["collisions"][0]["first_step"]), (&json!("A:1"), &json!(11.0)), "{:?}", a.collisions); assert_eq!(collision_bands(&found), vec![[11.0, 15.0]]); } }