Merge pull request #1712 from SychPL/feat/motion-analysis
Motion analysis: chart a study's joints and points, and find the parts that collide along it
This commit is contained in:
23 files changed
+1588
-51
No files matched your search
@@ -371,11 +371,19 @@ fn dispatch(app: &mut SolveApp, ctx: &egui::Context, method: &str, p: &Value) ->
|
||||
let Some(id) = s("command") else { return err("missing `command`") };
|
||||
// `params`: what the dialog opens on. Refused: nothing starts, or the dialog closes.
|
||||
let params = p.get("params").filter(|v| !v.is_null());
|
||||
if params.is_some() && id != "joint.drive" {
|
||||
return err(format!("`{id}` takes no ui.start params yet (joint.drive takes joint, values)"));
|
||||
if params.is_some() && id != "joint.drive" && id != "motion.study" {
|
||||
return err(format!("`{id}` takes no ui.start params yet (joint.drive takes joint, values; motion.study takes study)"));
|
||||
}
|
||||
app.start(id);
|
||||
if let Some(params) = params {
|
||||
if let Some(params) = params.filter(|_| id == "motion.study") {
|
||||
let Some(crate::dialogs::Kind::Motion(k)) = app.dialog.as_mut().map(|d| &mut d.kind) else {
|
||||
return err("Motion Study did not open");
|
||||
};
|
||||
if let Err(e) = crate::dialogs_motion::preset(&app.session, k, params) {
|
||||
crate::dialogs::cancel(app);
|
||||
return err(e);
|
||||
}
|
||||
} else if let Some(params) = params {
|
||||
if app.tree.capture_prompt.is_some() {
|
||||
return err("Drive Joints waits for the Capture Position question (ui.capture)");
|
||||
}
|
||||
|
||||
@@ -23,13 +23,13 @@ use crate::viewport::Proj;
|
||||
|
||||
#[path = "dialogs_trace.rs"]
|
||||
mod trace;
|
||||
pub use trace::TraceForm;
|
||||
pub use trace::{TraceForm, picked_point};
|
||||
|
||||
/// Joint types in the order the Type box lists them (command names).
|
||||
pub const TYPES: [&str; 7] = ["rigid", "revolute", "slider", "cylindrical", "pin_slot", "planar", "ball"];
|
||||
const TYPE_LABELS: [&str; 7] = ["Rigid", "Revolute", "Slider", "Cylindrical", "Pin-slot", "Planar", "Ball"];
|
||||
/// What a joint origin input accepts (snaps on faces, edges and vertices).
|
||||
const SNAPS: u16 = FACES | EDGES | VERTICES;
|
||||
pub const SNAPS: u16 = FACES | EDGES | VERTICES;
|
||||
/// Commands whose effect is where occurrences sit (previewed on the placed model).
|
||||
const MOVES: [&str; 12] = [
|
||||
"joint.create",
|
||||
@@ -265,7 +265,7 @@ fn current_values(s: &Session, index: usize) -> Vec<String> {
|
||||
}
|
||||
|
||||
/// A value with its unit, without trailing zeros ("12 mm", "37.5 deg").
|
||||
fn num(v: f64, unit: &str) -> String {
|
||||
pub(crate) fn num(v: f64, unit: &str) -> String {
|
||||
let s = format!("{v:.4}");
|
||||
let s = s.trim_end_matches('0').trim_end_matches('.');
|
||||
format!("{} {unit}", if s == "-0" { "0" } else { s })
|
||||
@@ -780,7 +780,7 @@ fn sel_items(inputs: &[SelInput], i: usize) -> &[Sel] {
|
||||
}
|
||||
|
||||
/// The body a pick is on.
|
||||
fn body_of(sel: &Sel) -> Option<&str> {
|
||||
pub(crate) fn body_of(sel: &Sel) -> Option<&str> {
|
||||
match sel {
|
||||
Sel::Face { body, .. } | Sel::Edge { body, .. } | Sel::Vertex { body, .. } => Some(body),
|
||||
Sel::Body { name } => Some(name),
|
||||
|
||||
@@ -18,6 +18,8 @@ use crate::theme::Tokens;
|
||||
|
||||
#[path = "configuration_rules_ui.rs"]
|
||||
mod configuration_rules_ui;
|
||||
#[path = "dialogs_motion_plot.rs"]
|
||||
pub mod plot;
|
||||
|
||||
/// One keyed value of a motion study: a joint's value (index) at steps.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -38,7 +40,7 @@ pub enum Mo {
|
||||
},
|
||||
MotionStudy {
|
||||
name: String,
|
||||
/// Editing this study (its name).
|
||||
/// Editing this study (its id).
|
||||
study: Option<String>,
|
||||
steps: String,
|
||||
tracks: Vec<Track>,
|
||||
@@ -52,6 +54,8 @@ pub enum Mo {
|
||||
shown: Option<(u64, u64)>,
|
||||
saved: Option<BTreeMap<u64, Mat>>,
|
||||
error: Option<String>,
|
||||
/// The chart and the collision check by the timeline.
|
||||
analysis: Box<plot::Analysis>,
|
||||
},
|
||||
Explode {
|
||||
name: String,
|
||||
@@ -114,8 +118,14 @@ pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
|
||||
shown: None,
|
||||
saved: None,
|
||||
error: None,
|
||||
analysis: Box::default(),
|
||||
},
|
||||
vec![],
|
||||
// A point to plot, and the parts to check for collisions (each optional).
|
||||
vec![
|
||||
SelInput::new("Point", crate::dialogs_assembly::SNAPS, false),
|
||||
SelInput::new("Components", BODIES, true),
|
||||
SelInput::new("Against", BODIES, true),
|
||||
],
|
||||
),
|
||||
"explode.create" => (Mo::Explode { name: n("Exploded View", "explode.list"), scale: 1.0, shown: None, saved: None, error: None }, vec![]),
|
||||
"config.table" | "config.configure" => (Mo::Configurations { new_row: String::new(), error: None, rule_editor: Default::default() }, vec![]),
|
||||
@@ -193,6 +203,57 @@ fn transport(ui: &mut egui::Ui, what: Transport, tip: &str) -> egui::Response {
|
||||
resp.on_hover_text(tip)
|
||||
}
|
||||
|
||||
/// Study `key` as the Motion Study dialog edits it: (id, name, steps, keys as tracks with values
|
||||
/// in degrees and mm). `key` is an id first when `id_first`, else a name first (as
|
||||
/// `motion.study` reads a number and text). The dialog keys whole steps of its timeline, so a
|
||||
/// study keyed between them or off it (made by a command) is refused rather than changed.
|
||||
pub fn study_tracks(s: &Session, key: &str, id_first: bool) -> Result<(u64, String, String, Vec<Track>), String> {
|
||||
let studies = &s.doc.assembly.studies;
|
||||
let (by_id, by_name) = (studies.iter().find(|m| m.id.to_string() == key), studies.iter().find(|m| m.name == key));
|
||||
let m = if id_first { by_id.or(by_name) } else { by_name.or(by_id) }.ok_or_else(|| format!("no motion study `{key}`"))?;
|
||||
let last = f64::from(m.steps);
|
||||
if m.keys.iter().flat_map(|k| &k.points).any(|(st, _)| st.fract() != 0.0 || !(0.0..=last).contains(st)) {
|
||||
return Err(format!("{} has keys between whole steps or off its timeline, which this dialog can't keep: edit it with motion.study", m.name));
|
||||
}
|
||||
let tracks = m
|
||||
.keys
|
||||
.iter()
|
||||
.filter_map(|k| {
|
||||
let joint = s.doc.assembly.joints.iter().position(|j| j.id == k.joint)?;
|
||||
let angle = s.doc.assembly.joints.get(joint)?.kind.is_angle(k.index);
|
||||
let value = |v: f64| {
|
||||
let (x, unit) = if angle { (v.to_degrees(), "deg") } else { (v, "mm") };
|
||||
// Short where that keeps the value; else all its digits, so OK keeps the key.
|
||||
let short = crate::dialogs_assembly::num(x, unit);
|
||||
let kept = short.split(' ').next().and_then(|n| n.parse::<f64>().ok()).is_some_and(|y| (y - x).abs() <= 1e-9 * x.abs().max(1.0));
|
||||
if kept { short } else { format!("{x} {unit}") }
|
||||
};
|
||||
let points = k.points.iter().map(|(st, v)| (st.round().clamp(0.0, f64::from(m.steps)) as u32, value(*v))).collect();
|
||||
Some(Track { joint, index: k.index, points })
|
||||
})
|
||||
.collect();
|
||||
Ok((m.id, m.name.clone(), m.steps.to_string(), tracks))
|
||||
}
|
||||
|
||||
/// What the Motion Study dialog opens on when the control channel's `ui.start` passes `params`:
|
||||
/// `study` (an id or a name), opened to edit.
|
||||
pub fn preset(s: &Session, k: &mut Mo, p: &Value) -> Result<(), String> {
|
||||
let Mo::MotionStudy { name, study, steps, tracks, shown, .. } = k else { return Err("not a Motion Study dialog".into()) };
|
||||
let o = p.as_object().ok_or("`params` must be an object")?;
|
||||
if let Some(key) = o.keys().find(|k| k.as_str() != "study") {
|
||||
return Err(format!("unknown param `{key}` (Motion Study takes study)"));
|
||||
}
|
||||
let (key, id_first) = match o.get("study") {
|
||||
Some(Value::Number(n)) => (n.to_string(), true),
|
||||
Some(Value::String(x)) => (x.clone(), false),
|
||||
Some(_) => return Err("`study` must be an id or a name".into()),
|
||||
None => return Ok(()),
|
||||
};
|
||||
let (id, n, st, t) = study_tracks(s, &key, id_first)?;
|
||||
(*study, *name, *steps, *tracks, *shown) = (Some(id.to_string()), n, st, t, None);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The study command's parameters.
|
||||
pub fn study_params(s: &Session, name: &str, study: Option<&str>, steps: &str, tracks: &[Track]) -> Result<Value, String> {
|
||||
let steps: u32 = steps.trim().parse().map_err(|_| "steps must be a whole number".to_string())?;
|
||||
@@ -208,8 +269,9 @@ pub fn study_params(s: &Session, name: &str, study: Option<&str>, steps: &str, t
|
||||
return Err("add a key for a joint first".into());
|
||||
}
|
||||
let mut p = json!({"name": name, "steps": steps, "keys": keys});
|
||||
// The id as a number: a study may be named like another's id.
|
||||
if let Some(st) = study {
|
||||
p["study"] = json!(st);
|
||||
p["study"] = st.parse::<u64>().map_or_else(|_| json!(st), |id| json!(id));
|
||||
}
|
||||
Ok(p)
|
||||
}
|
||||
@@ -320,9 +382,8 @@ fn moved(before: &Session, after: &Session) -> BTreeMap<u64, Mat> {
|
||||
/// The study's pose at `step`, computed on a scratch copy.
|
||||
fn study_pose(app: &SolveApp, params: &Value, step: u32) -> Result<BTreeMap<u64, Mat>, String> {
|
||||
let mut sc = app.session.scratch();
|
||||
let name = params["study"].as_str().or(params["name"].as_str()).unwrap_or_default().to_string();
|
||||
sc.execute("motion.study", params).map_err(|e| e.to_string())?;
|
||||
sc.execute("motion.play", &json!({"study": name, "step": step})).map_err(|e| e.to_string())?;
|
||||
let id = scratch_study(&mut sc, params)?;
|
||||
sc.execute("motion.play", &json!({"study": id, "step": step})).map_err(|e| e.to_string())?;
|
||||
Ok(moved(&app.session, &sc))
|
||||
}
|
||||
|
||||
@@ -354,6 +415,12 @@ fn export_animation(app: &mut SolveApp, params: Value, name: &str) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Makes (or edits) the study on a scratch copy, as OK would: its id there (a name may be
|
||||
/// another study's too).
|
||||
pub fn scratch_study(sc: &mut Session, params: &Value) -> Result<Value, String> {
|
||||
Ok(sc.execute("motion.study", params).map_err(|e| e.to_string())?["study"].clone())
|
||||
}
|
||||
|
||||
/// The explosion at `scale`, computed on a scratch copy.
|
||||
fn explode_pose(app: &SolveApp, scale: f64) -> Result<BTreeMap<u64, Mat>, String> {
|
||||
let mut sc = app.session.scratch();
|
||||
@@ -432,7 +499,27 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel
|
||||
ui.end_row();
|
||||
}
|
||||
}
|
||||
Mo::MotionStudy { name, study, steps, tracks, step, playing, looping, speed, shown, saved, error } => {
|
||||
Mo::MotionStudy { name, study, steps, tracks, step, playing, looping, speed, shown, saved, error, analysis } => {
|
||||
// An existing study opens to edit (and to chart); New makes another from these keys.
|
||||
// By id: two studies may share a name.
|
||||
let studies: Vec<(String, String)> = app.session.doc.assembly.studies.iter().map(|m| (m.id.to_string(), m.name.clone())).collect();
|
||||
if !studies.is_empty() {
|
||||
let labels: Vec<&str> = std::iter::once("New study").chain(studies.iter().map(|(_, n)| n.as_str())).collect();
|
||||
let was = study.as_ref().and_then(|id| studies.iter().position(|(x, _)| x == id)).map_or(0, |i| i + 1);
|
||||
let mut pick = was;
|
||||
row_label(ui, "Study");
|
||||
combo(ui, "ms_study", &labels, &mut pick);
|
||||
ui.end_row();
|
||||
if pick != was {
|
||||
match pick.checked_sub(1).and_then(|i| studies.get(i)).map(|(id, _)| study_tracks(&app.session, id, true)) {
|
||||
Some(Ok((id, n, st, t))) => {
|
||||
(*study, *name, *steps, *tracks, *shown, *playing) = (Some(id.to_string()), n, st, t, None, false)
|
||||
}
|
||||
Some(Err(e)) => app.set_status(e, true),
|
||||
None => *study = None,
|
||||
}
|
||||
}
|
||||
}
|
||||
row_label(ui, "Name");
|
||||
ui.text_edit_singleline(name);
|
||||
ui.end_row();
|
||||
@@ -497,6 +584,9 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel
|
||||
(*step, *playing) = advance(*step, dt * fps, total, *looping);
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
// The chart and the collision check, by the timeline.
|
||||
let study_now = study_params(&app.session, name, study.as_deref(), steps, tracks);
|
||||
plot::rows(app, ui, analysis, &study_now, step, playing, total, inputs);
|
||||
// Tracks: a row per keyed joint value, its keys on a strip under the steps.
|
||||
let now = step.round() as u32;
|
||||
let mut remove: Option<usize> = None;
|
||||
@@ -1043,6 +1133,7 @@ mod tests {
|
||||
shown: None,
|
||||
saved,
|
||||
error: None,
|
||||
analysis: Box::default(),
|
||||
}
|
||||
} else {
|
||||
Mo::Explode { name: "E".into(), scale: 1.0, shown: None, saved, error: None }
|
||||
|
||||
@@ -0,0 +1,508 @@
|
||||
//! 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<u64>,
|
||||
/// The chart's `motion.plot` answer (or why none) and what it was for.
|
||||
plot: Option<(u64, Result<Value, String>)>,
|
||||
/// The last collision check's answer and what it was for.
|
||||
collisions: Option<(u64, Result<Value, String>)>,
|
||||
}
|
||||
|
||||
/// 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<Value, String> {
|
||||
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::<f64>().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<f64> {
|
||||
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<f64>, Vec<f64>, 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::<f64>().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<f64> {
|
||||
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<f64> {
|
||||
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<egui::Pos2> = 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<String> {
|
||||
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<u64> {
|
||||
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<Value, String>,
|
||||
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<Value, String>,
|
||||
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<String> = 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<Value, String>,
|
||||
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]]);
|
||||
}
|
||||
}
|
||||
@@ -71,11 +71,7 @@ pub(super) fn placement(s: &Session, inputs: &[SelInput]) -> Option<(Vec<u64>, V
|
||||
|
||||
/// The trace's parameters (OK's `motion.trace.keep` and the preview's `motion.trace`).
|
||||
pub fn params(s: &Session, f: &TraceForm, inputs: &[SelInput]) -> Result<Value, String> {
|
||||
let (path, at, shown, placed) = placement(s, inputs).ok_or("select a point on a component")?;
|
||||
// The trace starts where the part is placed, not where it may be shown.
|
||||
let at = mat_inverse(&shown).map(|m| apply_point(&placed, apply_point(&m, at))).ok_or("select a point on a component")?;
|
||||
let occurrence = if path.is_empty() { json!(0) } else { json!(path) };
|
||||
let mut p = json!({"occurrence": occurrence, "point": pt(at)});
|
||||
let mut p = picked_point(s, inputs)?;
|
||||
if f.by_study {
|
||||
p["study"] = json!(s.doc.assembly.studies.get(f.study).ok_or("no motion study yet")?.id);
|
||||
} else {
|
||||
@@ -91,6 +87,15 @@ pub fn params(s: &Session, f: &TraceForm, inputs: &[SelInput]) -> Result<Value,
|
||||
Ok(p)
|
||||
}
|
||||
|
||||
/// The picked point as `motion.trace` and `motion.plot` take it: `{occurrence, point}`.
|
||||
pub fn picked_point(s: &Session, inputs: &[SelInput]) -> Result<Value, String> {
|
||||
let (path, at, shown, placed) = placement(s, inputs).ok_or("select a point on a component")?;
|
||||
// The point starts where the part is placed, not where it may be shown.
|
||||
let at = mat_inverse(&shown).map(|m| apply_point(&placed, apply_point(&m, at))).ok_or("select a point on a component")?;
|
||||
let occurrence = if path.is_empty() { json!(0) } else { json!(path) };
|
||||
Ok(json!({"occurrence": occurrence, "point": pt(at)}))
|
||||
}
|
||||
|
||||
/// Where and why the motion stopped short, ending the trace (`None`: it went all the way).
|
||||
fn stop_text(v: &Value) -> Option<String> {
|
||||
let at = v["stopped_at"].as_u64()?.to_string();
|
||||
|
||||
Reference in new issue
Block a user