diff --git a/crates/engine/src/cmd/contact_sweep.rs b/crates/engine/src/cmd/contact_sweep.rs index 539fc6a0..19a0e4ca 100644 --- a/crates/engine/src/cmd/contact_sweep.rs +++ b/crates/engine/src/cmd/contact_sweep.rs @@ -238,6 +238,8 @@ impl ContactGeometry { /// adds up the cost, refused past `budget`. pub(super) fn hits(&self, s: &Session, work: &mut usize, budget: usize, all: bool) -> Result> { let mut out: Vec<(Member, Member, f64)> = Vec::new(); + // The member pairs found so far (lower id first). + let mut found: BTreeSet<(u64, u64)> = BTreeSet::new(); let meshes: Vec = self .bodies .iter() @@ -251,7 +253,7 @@ impl ContactGeometry { 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)) { + if found.contains(&(oa.0.min(ob.0), oa.0.max(ob.0))) { continue; } let (Some(ma), Some(mb), Some(ba), Some(bb)) = (meshes.get(*a), meshes.get(*b), self.bodies.get(*a), self.bodies.get(*b)) else { @@ -280,6 +282,7 @@ impl ContactGeometry { return Err(limit("motion exceeds contact sweep collision budget; simplify the contact set or use smaller increments")); } if let Some(depth) = super::mesh_overlap(ma, mb, 1e-4 * ba.size.max(bb.size).max(1.0)) { + found.insert((oa.0.min(ob.0), oa.0.max(ob.0))); out.push((oa.clone(), ob.clone(), depth)); if !all { break; diff --git a/crates/engine/src/cmd/motion_analysis.rs b/crates/engine/src/cmd/motion_analysis.rs index ffe250f3..34e030ec 100644 --- a/crates/engine/src/cmd/motion_analysis.rs +++ b/crates/engine/src/cmd/motion_analysis.rs @@ -212,9 +212,10 @@ 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()); - // Keyed values are as the study sets them; loops can hand back a follower's - // angle a turn round. - if !given.contains(&(*id, *i)) { + // Keyed values are as the study sets them, and values beyond half a turn + // (through motion links) already run on; a loop can hand back a follower's + // angle within half a turn, a turn round. + if !given.contains(&(*id, *i)) && v.iter().all(|x| x.abs() <= 180.0 + 1e-9) { unwrap_degrees(v); } } diff --git a/crates/engine/src/cmd/motion_analysis_tests.rs b/crates/engine/src/cmd/motion_analysis_tests.rs index 163fa333..88c2cd41 100644 --- a/crates/engine/src/cmd/motion_analysis_tests.rs +++ b/crates/engine/src/cmd/motion_analysis_tests.rs @@ -4,7 +4,7 @@ use serde_json::{Value, json}; use solidcraft_geom::Vec3; -use super::{four_bar, occ, rocker_pin, run, slider_crank}; +use super::{four_bar, occ, part, pin, rocker_pin, run, slider_crank}; use crate::Session; /// A joint's series in a plot, by joint name. @@ -100,6 +100,20 @@ fn slider_crank_piston_acceleration_matches_its_analytic_form() { assert!(nums(&crank["velocity"]).iter().all(|v| (v - 1.0).abs() < 1e-9)); } +/// A dial linked 2:1 to the crank turns on past a turn: its values are not taken a turn round, +/// even half a turn a sample. +#[test] +fn a_linked_value_past_half_a_turn_runs_on() { + let (mut s, _, _) = four_bar(); + part(&mut s, "Dial", [80.0, 0.0, -5.0]); + pin(&mut s, "revolute", "Dial", "Ground", "Dial", Vec3::new(80.0, 0.0, 0.0), Vec3::Z); + run(&mut s, "joint.motion_link", json!({"a": "Crank", "b": "Dial", "ratio": 2})); + run(&mut s, "motion.study", json!({"name": "Turn", "steps": 2, "keys": [{"joint": "Crank", "points": [[0, 0], [2, 360]]}]})); + let r = run(&mut s, "motion.plot", json!({"study": "Turn", "joints": ["Dial"], "samples": 3})); + let v: Vec = nums(&series(&r, "Dial")["value"]).iter().map(|x| x.abs()).collect(); + assert!((v[1] - 360.0).abs() < 1e-6 && (v[2] - 720.0).abs() < 1e-6, "{r}"); +} + /// Cubes A (grounded, x 0…10) and B (x 20…30) on a slider along x, B 10 mm away at value 10. fn rail() -> Session { let mut s = Session::default(); diff --git a/crates/ui-egui/src/dialogs_motion.rs b/crates/ui-egui/src/dialogs_motion.rs index 809b35ab..de13ec34 100644 --- a/crates/ui-egui/src/dialogs_motion.rs +++ b/crates/ui-egui/src/dialogs_motion.rs @@ -221,7 +221,13 @@ pub fn study_tracks(s: &Session, key: &str, id_first: bool) -> Result<(u64, Stri .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| if angle { crate::dialogs_assembly::num(v.to_degrees(), "deg") } else { crate::dialogs_assembly::num(v, "mm") }; + 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::().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 }) }) diff --git a/crates/ui-egui/src/dialogs_motion_plot.rs b/crates/ui-egui/src/dialogs_motion_plot.rs index 52515f47..f0a6f144 100644 --- a/crates/ui-egui/src/dialogs_motion_plot.rs +++ b/crates/ui-egui/src/dialogs_motion_plot.rs @@ -464,6 +464,10 @@ mod tests { // 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"})] {