diff --git a/crates/engine/src/cmd/contact_sweep.rs b/crates/engine/src/cmd/contact_sweep.rs index 3f5a3483..539fc6a0 100644 --- a/crates/engine/src/cmd/contact_sweep.rs +++ b/crates/engine/src/cmd/contact_sweep.rs @@ -162,7 +162,7 @@ impl ContactGeometry { /// 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() + self.pairs.iter().map(|(_, _, a, b)| (a.0.min(b.0), a.0.max(b.0))).collect::>().len() } fn frame(body: &CachedBody, s: &Session) -> Result { diff --git a/crates/ui-egui/src/dialogs_motion.rs b/crates/ui-egui/src/dialogs_motion.rs index 7dc65ac0..809b35ab 100644 --- a/crates/ui-egui/src/dialogs_motion.rs +++ b/crates/ui-egui/src/dialogs_motion.rs @@ -203,18 +203,17 @@ 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: (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> { +/// 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), 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 (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 @@ -238,13 +237,13 @@ pub fn preset(s: &Session, k: &mut Mo, p: &Value) -> Result<(), String> { if let Some(key) = o.keys().find(|k| k.as_str() != "study") { return Err(format!("unknown param `{key}` (Motion Study takes study)")); } - let key = match o.get("study") { - Some(Value::Number(n)) => n.to_string(), - Some(Value::String(x)) => x.clone(), + 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)?; + 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(()) } @@ -506,7 +505,7 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel 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)) { + 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) } diff --git a/crates/ui-egui/src/dialogs_motion_plot.rs b/crates/ui-egui/src/dialogs_motion_plot.rs index 49aa0d0b..52515f47 100644 --- a/crates/ui-egui/src/dialogs_motion_plot.rs +++ b/crates/ui-egui/src/dialogs_motion_plot.rs @@ -461,9 +461,12 @@ mod tests { .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). + // 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(); - for bad in [json!({"study": "Nope"}), json!({"study": "Push", "step": 3}), json!({"study": [1]}), json!({"study": "Fine"})] { + h.app.run("motion.study", json!({"name": "Off", "steps": 15, "keys": [{"joint": "Rail", "points": [[-1, 10], [15, -5]]}]})).unwrap(); + 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}"); } diff --git a/docs/book/src/guide/assemblies.md b/docs/book/src/guide/assemblies.md index fa354800..311545ff 100644 --- a/docs/book/src/guide/assemblies.md +++ b/docs/book/src/guide/assemblies.md @@ -193,10 +193,11 @@ colliding pair with its `first_step`, `last_step`, the `steps` ranges where they far into each other (`depth_mm`, the most at `deepest_step`) and their shared `volume_mm3` there (null when it is not cheap to work out). It plays the study as `motion.play` does, but contact sets don't stop it, and parts are tested at the samples only: a part that passes right -through another between two samples is missed. A check -takes at most 256 bodies; on a large design, list the `occurrences` to check (an arm, with -`against` the parts it must miss). When the work runs out it says `truncated` and the step it -`checked_to`. +through another between two samples is missed. Parts are told apart by occurrence, as contact +sets tell them apart: an occurrence inside a sub-assembly placed twice is one part, so its two +copies are not checked against each other. A check takes at most 256 bodies; on a large design, +list the `occurrences` to check (an arm, with `against` the parts it must miss). When the work +runs out it says `truncated` and the step it `checked_to`. **Insert Design** (`component.insert_design {path, translate?}`, on the **ASSEMBLE** panel and in the **File** menu) adds a component linked to another saved design, so each part can live in