diff --git a/assets/locales/de.json b/assets/locales/de.json index 3f540d24..464f585f 100644 --- a/assets/locales/de.json +++ b/assets/locales/de.json @@ -14,7 +14,9 @@ "Bends": "Biegungen", "Boundary": "Begrenzung", "Centre to centre": "Mitte zu Mitte", + "Check collisions": "Kollisionen prüfen", "Circumference": "Umfang", + "Collisions": "Kollisionen", "Construction": "Konstruktionslinie", "Contact sets": "Kontaktsätze", "Continuity": "Stetigkeit", diff --git a/assets/locales/ko.json b/assets/locales/ko.json index aaecb5df..7d7f7d5a 100644 --- a/assets/locales/ko.json +++ b/assets/locales/ko.json @@ -14,7 +14,9 @@ "Bends": "벤딩", "Boundary": "경계", "Centre to centre": "중심 간", + "Check collisions": "충돌 확인", "Circumference": "원주", + "Collisions": "충돌", "Construction": "구성선", "Contact sets": "접촉 집합", "Continuity": "연속성", diff --git a/crates/engine/src/cmd/contact_sweep.rs b/crates/engine/src/cmd/contact_sweep.rs index 3ab185d8..3f5a3483 100644 --- a/crates/engine/src/cmd/contact_sweep.rs +++ b/crates/engine/src/cmd/contact_sweep.rs @@ -155,6 +155,11 @@ impl ContactGeometry { self.pairs.retain(|(_, _, a, b)| keep(a.0, b.0)); } + /// How many mesh points are placed for each check. + pub(super) fn vertex_count(&self) -> usize { + self.bodies.iter().map(|b| b.mesh.positions.len()).fold(0, usize::saturating_add) + } + /// How many pairs of set members are checked against each other. pub(super) fn pair_count(&self) -> usize { self.pairs.iter().map(|(_, _, a, b)| (a.0, b.0)).collect::>().len() @@ -242,6 +247,8 @@ impl ContactGeometry { Ok(Mesh { positions, triangles: body.mesh.triangles.clone(), ..Mesh::default() }) }) .collect::>()?; + // Each mesh's box once, not once per pair it is in. + let boxes: Vec = meshes.iter().map(Mesh::bounds).collect(); for (a, b, oa, ob) in &self.pairs { // Members already found colliding need no more of their bodies tested. if out.iter().any(|(x, y, _)| (x.0, y.0) == (oa.0, ob.0) || (x.0, y.0) == (ob.0, oa.0)) { @@ -250,7 +257,9 @@ impl ContactGeometry { let (Some(ma), Some(mb), Some(ba), Some(bb)) = (meshes.get(*a), meshes.get(*b), self.bodies.get(*a), self.bodies.get(*b)) else { return Err(limit("invalid contact geometry pair")); }; - let (aabb, bbb) = (ma.bounds(), mb.bounds()); + let (Some(aabb), Some(bbb)) = (boxes.get(*a), boxes.get(*b)) else { + return Err(limit("invalid contact geometry pair")); + }; if aabb.max.x < bbb.min.x || bbb.max.x < aabb.min.x || aabb.max.y < bbb.min.y diff --git a/crates/engine/src/cmd/motion_analysis.rs b/crates/engine/src/cmd/motion_analysis.rs index 99e32aa3..7a56f3b8 100644 --- a/crates/engine/src/cmd/motion_analysis.rs +++ b/crates/engine/src/cmd/motion_analysis.rs @@ -128,12 +128,14 @@ fn second_derivative(v: &[f64], h: f64) -> Vec { } /// Angles in degrees made continuous: a jump of more than half a turn between samples is taken -/// as the angle going round. +/// as the angle going round (half a turn exactly is a real step). fn unwrap_degrees(v: &mut [f64]) { let (mut turns, mut prev) = (0.0, None::); for x in v.iter_mut() { let raw = *x; - if let Some(p) = prev { + if let Some(p) = prev + && (raw - p).abs() > 180.0 + { turns -= 360.0 * ((raw - p) / 360.0).round(); } prev = Some(raw); @@ -175,6 +177,7 @@ pub(super) fn plot(s: &mut Session, p: &Value) -> Result { } }; let series = plotted(s, p, cmd)?; + let given = keyed(&m); let point = if p.get("point").is_some() || p.get("occurrence").is_some() { Some(traced_point(s, p, cmd)?) } else { None }; let mut values: Vec> = vec![Vec::new(); series.len()]; let mut path: Vec = Vec::new(); @@ -208,7 +211,11 @@ pub(super) fn plot(s: &mut Session, p: &Value) -> Result { let unit = if angle { "deg" } else { "mm" }; if angle { v.iter_mut().for_each(|x| *x = x.to_degrees()); - unwrap_degrees(v); + // Keyed values are as the study sets them; loops can hand back a follower's + // angle a turn round. + if !given.contains(&(*id, *i)) { + unwrap_degrees(v); + } } json!({"joint": id, "name": j.map(|j| j.name.clone()), "index": i, "dof": j.and_then(|j| j.kind.dofs().get(*i).copied()), "unit": unit, "velocity_unit": format!("{unit}/{per}"), "acceleration_unit": format!("{unit}/{per}²"), @@ -347,6 +354,12 @@ pub(super) fn interference(s: &mut Session, p: &Value) -> Result { let mut found: BTreeMap<(u64, u64), Collision> = BTreeMap::new(); let (mut work, mut checked, mut truncated) = (0usize, 0usize, false); let stop = play(s, &m, n, false, |t, k| { + // Placing the meshes is work too, also where nothing comes near. + work = work.saturating_add(geometry.vertex_count()); + if work > MAX_WORK { + truncated = true; + return Ok(false); + } let hits: Vec<(Member, Member, f64)> = match geometry.hits(t, &mut work, MAX_WORK, true) { Ok(h) => h, Err(_) if work > MAX_WORK => { @@ -428,5 +441,9 @@ mod tests { let mut v = vec![170.0, 179.0, -172.0, -160.0, 175.0]; unwrap_degrees(&mut v); assert_eq!(v, vec![170.0, 179.0, 188.0, 200.0, 175.0]); + // Half a turn a sample is a step, not a wrap. + let mut v = vec![0.0, 180.0, 360.0]; + unwrap_degrees(&mut v); + assert_eq!(v, vec![0.0, 180.0, 360.0]); } } diff --git a/crates/ui-egui/src/dialogs_motion.rs b/crates/ui-egui/src/dialogs_motion.rs index af1a0e78..e314aaf3 100644 --- a/crates/ui-egui/src/dialogs_motion.rs +++ b/crates/ui-egui/src/dialogs_motion.rs @@ -40,7 +40,7 @@ pub enum Mo { }, MotionStudy { name: String, - /// Editing this study (its name). + /// Editing this study (its id). study: Option, steps: String, tracks: Vec, @@ -203,10 +203,19 @@ fn transport(ui: &mut egui::Ui, what: Transport, tip: &str) -> egui::Response { resp.on_hover_text(tip) } -/// Study `key` (an id or a name) as the Motion Study dialog edits it: its name, steps and keys -/// as tracks (values in degrees and mm). -pub fn study_tracks(s: &Session, key: &str) -> Result<(String, String, Vec), String> { - let m = s.doc.assembly.studies.iter().find(|m| m.id.to_string() == key || m.name == key).ok_or_else(|| format!("no motion study `{key}`"))?; +/// Study `key` (an id or a name) as the Motion Study dialog edits it: (id, name, steps, keys as +/// tracks with values in degrees and mm). The dialog keys whole steps, so a study keyed between +/// them (made by a command) is refused rather than rounded. +pub fn study_tracks(s: &Session, key: &str) -> Result<(u64, String, String, Vec), String> { + let studies = &s.doc.assembly.studies; + let m = studies + .iter() + .find(|m| m.id.to_string() == key) + .or_else(|| studies.iter().find(|m| m.name == key)) + .ok_or_else(|| format!("no motion study `{key}`"))?; + if m.keys.iter().flat_map(|k| &k.points).any(|(st, _)| st.fract() != 0.0) { + return Err(format!("{} has keys between whole steps, which this dialog can't keep: edit it with motion.study", m.name)); + } let tracks = m .keys .iter() @@ -218,7 +227,7 @@ pub fn study_tracks(s: &Session, key: &str) -> Result<(String, String, Vec Result<(), String> { Some(_) => return Err("`study` must be an id or a name".into()), None => return Ok(()), }; - let (n, st, t) = study_tracks(s, &key)?; - (*study, *name, *steps, *tracks, *shown) = (Some(n.clone()), n, st, t, None); + let (id, n, st, t) = study_tracks(s, &key)?; + (*study, *name, *steps, *tracks, *shown) = (Some(id.to_string()), n, st, t, None); Ok(()) } @@ -481,18 +490,21 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel } 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. - let names: Vec = app.session.doc.assembly.studies.iter().map(|m| m.name.clone()).collect(); - if !names.is_empty() { - let labels: Vec<&str> = std::iter::once("New study").chain(names.iter().map(String::as_str)).collect(); - let was = study.as_ref().and_then(|n| names.iter().position(|x| x == n)).map_or(0, |i| i + 1); + // 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| names.get(i)).map(|n| study_tracks(&app.session, n)) { - Some(Ok((n, st, t))) => (*study, *name, *steps, *tracks, *shown, *playing) = (Some(n.clone()), n, st, t, None, false), - Some(Err(e)) => *error = Some(e), + match pick.checked_sub(1).and_then(|i| studies.get(i)).map(|(id, _)| study_tracks(&app.session, id)) { + 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, } } diff --git a/crates/ui-egui/src/dialogs_motion_plot.rs b/crates/ui-egui/src/dialogs_motion_plot.rs index 2b9b76ec..985c2180 100644 --- a/crates/ui-egui/src/dialogs_motion_plot.rs +++ b/crates/ui-egui/src/dialogs_motion_plot.rs @@ -39,6 +39,8 @@ pub struct Analysis { 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. @@ -77,7 +79,8 @@ pub fn plot_params(s: &Session, what: Plotted, study: &Value, duration: &str, in } let d = duration.trim(); if !d.is_empty() { - p["duration"] = json!(d.parse::().map_err(|_| "the duration must be a number of seconds".to_string())?); + let seconds = d.parse::().ok().filter(|x| x.is_finite() && *x > 0.0).ok_or("the duration must be a number of seconds, more than 0")?; + p["duration"] = json!(seconds); } Ok(p) } @@ -230,6 +233,14 @@ pub fn rows( 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); @@ -377,6 +388,15 @@ mod tests { 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. @@ -402,8 +422,14 @@ mod tests { 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(); - h.app.run("motion.study", json!({"name": "Push", "steps": 15, "keys": [{"joint": "Rail", "points": [[0, 10], [15, -5]]}]})).unwrap(); - for bad in [json!({"study": "Nope"}), json!({"study": "Push", "step": 3}), json!({"study": [1]})] { + 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 is not opened (the dialog would round its keys). + h.app.run("motion.study", json!({"name": "Fine", "steps": 15, "keys": [{"joint": "Rail", "points": [[0, 10], [7.5, -5]]}]})).unwrap(); + for bad in [json!({"study": "Nope"}), json!({"study": "Push", "step": 3}), json!({"study": [1]}), json!({"study": "Fine"})] { let r = h.call("ui.start", json!({"command": "motion.study", "params": bad})); assert!(r["ok"] == false && h.app.dialog.is_none(), "{bad}: {r}"); } @@ -413,7 +439,7 @@ mod tests { 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"), "15")); + 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); } diff --git a/docs/book/src/guide/assemblies.md b/docs/book/src/guide/assemblies.md index f686e2f8..fa354800 100644 --- a/docs/book/src/guide/assemblies.md +++ b/docs/book/src/guide/assemblies.md @@ -168,8 +168,9 @@ what changed from one frame to the next. Frames are capped: at most 1000, and a **Motion analysis.** The **Motion Study** dialog's **Study** box opens a saved study to edit, and by its timeline the dialog charts what the study does. -**Plot** picks a moving joint value (or **Picked point**, once a point is picked in the dialog's -**Point** box) and whether to show its value, velocity or acceleration; the chart runs over the +**Plot** picks a moving joint value (a joint picked in the Browser is picked here too), or +**Picked point** once a point is picked in the dialog's **Point** box, and **Quantity** whether to +show its value, velocity or acceleration; the chart runs over the study's steps with a cursor at the step shown, and a click or a drag on it moves to that step. Values are in degrees and millimetres; rates are per step, or per second once **Duration** says how many seconds the whole study takes. A picked point charts its distance from where it