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:
selune authored and GitHub committed 2026-10-17 00:09:37 +02:00
commit 37496198bf
23 files changed
+1588 -51

No files matched your search

+55 -8
View File
@@ -27,9 +27,13 @@ struct CachedBody {
size: f64,
}
/// A member of a contact set: (occurrence id, name).
pub(super) type Member = (u64, String);
pub(super) struct ContactGeometry {
bodies: Vec<CachedBody>,
pairs: Vec<(usize, usize, String, String)>,
/// Body indices, and the set members (occurrence id, name) they are under.
pairs: Vec<(usize, usize, Member, Member)>,
step: f64,
}
@@ -41,6 +45,12 @@ impl ContactGeometry {
} else {
assembly.contact_sets.iter().filter(|set| !set.suppressed).map(|set| set.occurrences.clone()).collect()
};
Self::of_sets(s, &sets)
}
/// The geometry of the bodies under `sets` (occurrence ids), paired as contact sets pair
/// them: bodies under different members of one set.
pub(super) fn of_sets(s: &Session, sets: &[Vec<u64>]) -> Result<Self> {
let mut out = Self { bodies: Vec::new(), pairs: Vec::new(), step: f64::INFINITY };
if sets.is_empty() {
return Ok(out);
@@ -120,7 +130,7 @@ impl ContactGeometry {
}
}
let mut pairs = BTreeSet::new();
for set in &sets {
for set in sets {
let tagged: Vec<(usize, u64)> =
out.bodies.iter().enumerate().filter_map(|(i, b)| b.path.iter().rev().find(|id| set.contains(id)).map(|id| (i, *id))).collect();
for (i, (a, oa)) in tagged.iter().enumerate() {
@@ -132,7 +142,7 @@ impl ContactGeometry {
}
}
let name = |id| s.doc.occurrences.iter().find(|o| o.id == id).map(|o| o.name.clone()).unwrap_or_default();
out.pairs = pairs.into_iter().map(|(a, b, oa, ob)| (a, b, name(oa), name(ob))).collect();
out.pairs = pairs.into_iter().map(|(a, b, oa, ob)| (a, b, (oa, name(oa)), (ob, name(ob)))).collect();
Ok(out)
}
@@ -140,6 +150,21 @@ impl ContactGeometry {
!self.pairs.is_empty()
}
/// Keeps only the pairs of set members (occurrence ids) `keep` takes.
pub(super) fn keep_pairs(&mut self, keep: impl Fn(u64, u64) -> bool) {
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.min(b.0), a.0.max(b.0))).collect::<BTreeSet<_>>().len()
}
fn frame(body: &CachedBody, s: &Session) -> Result<Mat> {
let mut frame = IDENTITY;
for id in &body.path {
@@ -205,6 +230,16 @@ impl ContactGeometry {
}
pub(super) fn first_hit(&self, s: &Session, work: &mut usize) -> Result<Option<(String, String, f64)>> {
Ok(self.hits(s, work, MAX_WORK, false)?.into_iter().next().map(|(a, b, depth)| (a.1, b.1, depth)))
}
/// The pairs of set members whose bodies pass through each other in `s`, with how deep the
/// first two bodies found go (every pair, or the first found when `all` is false). `work`
/// adds up the cost, refused past `budget`.
pub(super) fn hits(&self, s: &Session, work: &mut usize, budget: usize, all: bool) -> Result<Vec<(Member, Member, f64)>> {
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<Mesh> = self
.bodies
.iter()
@@ -214,11 +249,19 @@ impl ContactGeometry {
Ok(Mesh { positions, triangles: body.mesh.triangles.clone(), ..Mesh::default() })
})
.collect::<Result<_>>()?;
for (a, b, aname, bname) in &self.pairs {
// Each mesh's box once, not once per pair it is in.
let boxes: Vec<Aabb3> = 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 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 {
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
@@ -235,14 +278,18 @@ impl ContactGeometry {
.saturating_mul(mb.triangles.len())
.saturating_add(mb.positions.len().saturating_add(mb.triangles.len().saturating_mul(4)).saturating_mul(ma.triangles.len()));
*work = work.saturating_add(cost);
if *work > MAX_WORK {
if *work > budget {
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)) {
return Ok(Some((aname.clone(), bname.clone(), depth)));
found.insert((oa.0.min(ob.0), oa.0.max(ob.0)));
out.push((oa.clone(), ob.clone(), depth));
if !all {
break;
}
}
}
Ok(None)
Ok(out)
}
}
+4
View File
@@ -1448,3 +1448,7 @@ mod coupling;
#[path = "joints_cam_tests.rs"]
mod cam;
// Plots and interference of motion studies, on the four-bar and slider-crank above.
#[path = "motion_analysis_tests.rs"]
mod motion_analysis;
+36 -21
View File
@@ -14,6 +14,8 @@ use super::joints::{cannot_move, effective_values, joint_id, occurrence_id, reso
use crate::params::{bad, bool_, str_, vec3};
use crate::{EngineError, Result, Session};
#[path = "motion_analysis.rs"]
mod analysis;
#[path = "motion_animation.rs"]
mod animation;
#[path = "contact_sweep.rs"]
@@ -69,6 +71,8 @@ pub static COMMANDS: &[CommandSpec] = &[
"; name? (default Motion TraceN) - keeps the trace as a 3D sketch spline through its points (a new sketch with 3D Sketch \
on, in the root component, where no joint moves it; a fixed curve, it does not follow later changes) → sketch, name, curves, points"
)),
CommandSpec::new("motion.plot", "Plot Motion Study", analysis::plot).noundo().params(analysis::PLOT_DOC),
CommandSpec::new("motion.interference", "Motion Interference", analysis::interference).noundo().params(analysis::INTERFERENCE_DOC),
CommandSpec::new("motion.list", "List Motion Studies", list_studies).noundo(),
CommandSpec::new("motion.delete", "Delete Motion Study", delete_study).params("study"),
CommandSpec::new("explode.create", "Exploded View", explode_create).at("SOLID", "ASSEMBLE").icon("joint").params(
@@ -472,12 +476,15 @@ fn check(s: &mut Session, _p: &Value) -> Result<Value> {
// ---- Motion studies ----
fn study_index(s: &Session, p: &Value, cmd: &str) -> Result<usize> {
let key = match p.get("study") {
Some(Value::Number(n)) => n.to_string(),
Some(Value::String(x)) => x.clone(),
// A number is an id first, text a name first: a study may be named like another's id.
let (key, id_first) = match p.get("study") {
Some(Value::Number(n)) => (n.to_string(), true),
Some(Value::String(x)) => (x.clone(), false),
_ => return Err(bad(cmd, "`study` must be an id or a name")),
};
s.doc.assembly.studies.iter().position(|m| m.id.to_string() == key || m.name == key).ok_or_else(|| bad(cmd, format!("no motion study `{key}`")))
let studies = &s.doc.assembly.studies;
let (by_id, by_name) = (studies.iter().position(|m| m.id.to_string() == key), studies.iter().position(|m| m.name == key));
if id_first { by_id.or(by_name) } else { by_name.or(by_id) }.ok_or_else(|| bad(cmd, format!("no motion study `{key}`")))
}
fn study(s: &mut Session, p: &Value) -> Result<Value> {
@@ -688,13 +695,11 @@ const MAX_TRACE: usize = solidcraft_sketch::MAX_FIT_POINTS;
/// The joint values a trace drives to at sample `k` of `n`.
type Plan = Box<dyn Fn(&Session, usize, usize) -> Vec<(u64, Vec<f64>)>>;
/// Where a point of an occurrence goes as a study plays or a joint is driven: its world
/// positions, sampled on a copy (the design is not changed). Each sample is driven from the one
/// before, so closed loops follow on their branch and contact sets stop them; the trace ends at
/// the sample where the motion stops short. Returns the points and the answer without them.
fn sample_trace(s: &Session, p: &Value, cmd: &str) -> Result<(Vec<Vec3>, Value)> {
// The placement traced: an occurrence path through nested sub-assemblies (one instance of
// each), or an occurrence at its first placement (none: the root).
/// The point `p` names (`occurrence` and `point`): the occurrence path placing it and the point
/// in that placement's frame. The occurrence is an occurrence path through nested sub-assemblies
/// (one instance of each), or an occurrence at its first placement (none: the root). The point
/// is a world point where the parts are placed (moves only shown are left out).
fn traced_point(s: &Session, p: &Value, cmd: &str) -> Result<(Vec<u64>, Vec3)> {
let path: Vec<u64> = match p.get("occurrence") {
Some(Value::Array(ids)) if ids.len() <= 64 => {
ids.iter().map(|v| v.as_u64().ok_or_else(|| bad(cmd, "an occurrence path lists occurrence ids"))).collect::<Result<_>>()?
@@ -710,19 +715,29 @@ fn sample_trace(s: &Session, p: &Value, cmd: &str) -> Result<(Vec<Vec3>, Value)>
};
let at = p.get("point").and_then(vec3).filter(|v| v.len() < 1e7).ok_or_else(|| bad(cmd, "`point` must be [x, y, z], a world point"))?;
let mut t = s.scratch();
t.pending_moves.clear();
let local = solidcraft_doc::mat_inverse(&path_world(&t, &path, cmd)?)
.map(|m| solidcraft_doc::apply_point(&m, at))
.ok_or_else(|| bad(cmd, "the occurrence's placement is not invertible"))?;
Ok((path, local))
}
/// The world placement of an occurrence path (empty: the root).
fn path_world(t: &Session, path: &[u64], cmd: &str) -> Result<Mat> {
if path.is_empty() { Ok(solidcraft_doc::IDENTITY) } else { t.occurrence_placement(path).map(|o| o.world).map_err(|e| bad(cmd, e.to_string())) }
}
/// Where a point of an occurrence goes as a study plays or a joint is driven: its world
/// positions, sampled on a copy (the design is not changed). Each sample is driven from the one
/// before, so closed loops follow on their branch and contact sets stop them; the trace ends at
/// the sample where the motion stops short. Returns the points and the answer without them.
fn sample_trace(s: &Session, p: &Value, cmd: &str) -> Result<(Vec<Vec3>, Value)> {
let (path, local) = traced_point(s, p, cmd)?;
let mut t = s.scratch();
// Traced from where the parts are placed: moves only shown (an exploded view, a move not
// captured yet) are not where the joints are solved.
t.pending_moves.clear();
let world = |t: &Session| -> Result<Mat> {
if path.is_empty() {
Ok(solidcraft_doc::IDENTITY)
} else {
t.occurrence_placement(&path).map(|o| o.world).map_err(|e| bad(cmd, e.to_string()))
}
};
let local = solidcraft_doc::mat_inverse(&world(&t)?)
.map(|m| solidcraft_doc::apply_point(&m, at))
.ok_or_else(|| bad(cmd, "the occurrence's placement is not invertible"))?;
let world = |t: &Session| path_world(t, &path, cmd);
let (default, given, plan) = motion_plan(s, p, cmd)?;
let n = sample_count(p, default.min(MAX_TRACE), MAX_TRACE, cmd)?;
let mut points = Vec::new();
+456
View File
@@ -0,0 +1,456 @@
//! Motion analysis: a study played sample by sample on a copy of the design, the way
//! `motion.play` places it (closed loops following on their branch). `motion.plot` answers the
//! joint values and a point's path with their velocities and accelerations; `motion.interference`
//! answers the pairs of parts that pass through each other along the way.
use std::collections::BTreeMap;
use std::sync::Arc;
use serde_json::{Value, json};
use solidcraft_doc::joints;
use solidcraft_doc::motion::MotionStudy;
use solidcraft_doc::{Document, Mat, apply_point, mat_mul};
use solidcraft_geom::{Aabb3, Vec3};
use super::contact_sweep::{ContactGeometry, Member};
use super::{drive_with_contact, joint_id, keyed, occurrences, path_world, study_index, traced_point, values_at};
use crate::params::{bad, bool_};
use crate::{EngineError, Result, Session};
pub const PLOT_DOC: &str = "study: id|name; joints?: [id|name] (default: every joint that moves); occurrence? and point? (a point \
of a part to follow, as motion.trace takes them); samples? (3…1000, default the study's steps + 1); duration?: seconds the \
whole study takes, 0.001 or more (velocities and accelerations then per second, else per step) → step: [the study step of each sample], \
time?: [s], joints: [{joint, name, index, dof, unit, value, velocity, acceleration, velocity_unit, acceleration_unit}] \
(degrees for angles, mm for distances; angles unwrapped), point?: {occurrence, position: [[x,y,z]…], velocity, \
acceleration (vectors), speed, acceleration_norm, unit, velocity_unit, acceleration_unit}, samples (taken), stopped_at, \
stopped_by_loop, stopped_by_contact. The study plays as motion.play plays it, each sample driven from the one before, on \
a copy (the design is not changed); velocities and accelerations are finite differences between samples (central, \
one-sided at the ends)";
pub const INTERFERENCE_DOC: &str = "study: id|name; occurrences?: [ids or names] (two or more, checked against each other; \
default every occurrence), with against?: [ids or names] (then each of `occurrences` is checked only against these, such \
as an arm against the parts it must miss), or contact_sets?: true (the pairs of the design's contact sets); samples? \
(2…1000, default the study's steps + 1) → collisions: [{a, b, first_step, last_step, steps: [[from, to]…] (the step ranges where they overlap), \
depth_mm (how far into each other, the most at deepest_step), volume_mm3 (their overlap there; null when it was not \
cheap to work out)}], clear, \
pairs (checked), samples (taken), truncated (the work budget ran out; checked_to: the last step checked), stopped_at, \
stopped_by_loop. The study plays as motion.play plays it but contact sets do not stop it, on a copy (the design is not \
changed). Parts are tested at the samples only: one that passes wholly through another between two samples is missed";
/// Most samples a plot or an interference check takes: each is a solve.
const MAX_SAMPLES: usize = 1000;
/// Most joint values one plot answers.
const MAX_SERIES: usize = 256;
/// Most collision work over a whole study (see `ContactGeometry::hits`).
const MAX_WORK: usize = 500_000_000;
/// Most colliding pairs whose overlap volume is worked out, and most body booleans that takes.
const MAX_VOLUMES: usize = 16;
const MAX_BOOLEANS: usize = 64;
/// How many samples `p` asks for (`min`…`MAX_SAMPLES`, default the study's steps + 1).
fn samples(p: &Value, m: &MotionStudy, min: usize, cmd: &str) -> Result<usize> {
if m.steps == 0 {
return Err(bad(cmd, "the study has no steps"));
}
match p.get("samples") {
Some(v) => v
.as_f64()
.filter(|n| n.is_finite() && *n >= min as f64 && *n <= MAX_SAMPLES as f64)
.map(|n| n.round() as usize)
.ok_or_else(|| bad(cmd, format!("`samples` must be {min}…{MAX_SAMPLES}"))),
None => Ok((m.steps as usize).saturating_add(1).clamp(min, MAX_SAMPLES)),
}
}
/// The study step sample `k` of `n` is at.
fn step_of(m: &MotionStudy, k: usize, n: usize) -> f64 {
f64::from(m.steps) * k as f64 / n.saturating_sub(1).max(1) as f64
}
/// Plays study `m` on a copy of the design in `n` samples, each driven from the one before (so
/// closed loops follow on their branch, as `motion.play` places them; contact sets stop it only
/// when `contact`). `each` sees the copy at every sample and answers whether to go on. Returns
/// where the motion stopped short (all null when it did not).
fn play(s: &Session, m: &MotionStudy, n: usize, contact: bool, mut each: impl FnMut(&Session, usize) -> Result<bool>) -> Result<Value> {
let mut t = s.scratch();
// Played from where the parts are placed: moves only shown are not where joints are solved.
t.pending_moves.clear();
if !contact {
t.doc_mut().assembly.contact_enabled = false;
}
let given = keyed(m);
for k in 0..n {
let want = values_at(&t, m, step_of(m, k, n));
let (r, _) = drive_with_contact(&mut t, &want, &given)?;
let go = each(&t, k)?;
if r["stopped_by_loop"].is_object() || r["stopped_by_contact"].is_object() {
return Ok(json!({"stopped_at": k, "stopped_by_loop": r["stopped_by_loop"], "stopped_by_contact": r["stopped_by_contact"]}));
}
if !go {
break;
}
}
Ok(json!({"stopped_at": null, "stopped_by_loop": null, "stopped_by_contact": null}))
}
/// The derivative of samples `v` spaced `h` apart: central differences inside, second-order
/// one-sided at the ends (a plain difference with two samples, 0 with one).
fn derivative(v: &[f64], h: f64) -> Vec<f64> {
let n = v.len();
let at = |i: usize| v.get(i).copied().unwrap_or(0.0);
(0..n)
.map(|k| match n {
0 | 1 => 0.0,
2 => (at(1) - at(0)) / h,
_ if k == 0 => (-3.0 * at(0) + 4.0 * at(1) - at(2)) / (2.0 * h),
_ if k + 1 == n => (3.0 * at(k) - 4.0 * at(k - 1) + at(k - 2)) / (2.0 * h),
_ => (at(k + 1) - at(k - 1)) / (2.0 * h),
})
.collect()
}
/// The second derivative of samples `v` spaced `h` apart: central differences inside,
/// second-order one-sided at the ends (the one difference there is with three samples, 0 with
/// fewer).
fn second_derivative(v: &[f64], h: f64) -> Vec<f64> {
let n = v.len();
let at = |i: usize| v.get(i).copied().unwrap_or(0.0);
let h2 = h * h;
(0..n)
.map(|k| match n {
0..=2 => 0.0,
3 => (at(0) - 2.0 * at(1) + at(2)) / h2,
_ if k == 0 => (2.0 * at(0) - 5.0 * at(1) + 4.0 * at(2) - at(3)) / h2,
_ if k + 1 == n => (2.0 * at(k) - 5.0 * at(k - 1) + 4.0 * at(k - 2) - at(k - 3)) / h2,
_ => (at(k + 1) - 2.0 * at(k) + at(k - 1)) / h2,
})
.collect()
}
/// Angles in degrees made continuous: a jump of more than half a turn between samples is taken
/// 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::<f64>);
for x in v.iter_mut() {
let raw = *x;
if let Some(p) = prev
&& (raw - p).abs() > 180.0
{
turns -= 360.0 * ((raw - p) / 360.0).round();
}
prev = Some(raw);
*x = raw + turns;
}
}
/// The joint values a plot follows: (joint id, value index).
fn plotted(s: &Session, p: &Value, cmd: &str) -> Result<Vec<(u64, usize)>> {
let joints = &s.doc.assembly.joints;
let ids: Vec<u64> = match p.get("joints") {
None | Some(Value::Null) => joints.iter().filter(|j| !j.suppressed).map(|j| j.id).collect(),
Some(Value::Array(list)) if list.len() <= MAX_SERIES => list.iter().map(|v| joint_id(s, Some(v), cmd)).collect::<Result<_>>()?,
Some(_) => return Err(bad(cmd, format!("`joints` must list at most {MAX_SERIES} joints"))),
};
let mut out = Vec::new();
for id in ids {
let dofs = joints.iter().find(|j| j.id == id).map_or(0, |j| j.kind.dofs().len());
for i in 0..dofs {
if !out.contains(&(id, i)) {
out.push((id, i));
}
}
}
if out.len() > MAX_SERIES {
return Err(bad(cmd, format!("more than {MAX_SERIES} joint values: list the `joints` to plot")));
}
Ok(out)
}
pub(super) fn plot(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "motion.plot";
let m = s.doc.assembly.studies.get(study_index(s, p, cmd)?).cloned().ok_or_else(|| bad(cmd, "study"))?;
let n = samples(p, &m, 3, cmd)?;
let duration = match p.get("duration") {
None | Some(Value::Null) => None,
Some(v) => {
// At least a millisecond: shorter sample spacings square to nothing.
Some(v.as_f64().filter(|d| (1e-3..=1e9).contains(d)).ok_or_else(|| bad(cmd, "`duration` must be seconds, 0.001 or more"))?)
}
};
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<f64>> = vec![Vec::new(); series.len()];
let mut path: Vec<Vec3> = Vec::new();
let mut taken = 0;
let stop = play(s, &m, n, true, |t, _| {
taken += 1;
let now = joints::joint_values(&t.doc.assembly);
for ((id, i), out) in series.iter().zip(values.iter_mut()) {
out.push(now.get(id).and_then(|q| q.get(*i)).copied().unwrap_or(0.0));
}
if let Some((occ, local)) = &point {
path.push(apply_point(&path_world(t, occ, cmd)?, *local));
}
Ok(true)
})?;
// Sample spacing in steps, or in seconds when the study has a duration.
let seconds_per_step = duration.map(|d| d / f64::from(m.steps));
let h = f64::from(m.steps) / (n - 1) as f64 * seconds_per_step.unwrap_or(1.0);
let per = if duration.is_some() { "s" } else { "step" };
let steps: Vec<f64> = (0..taken).map(|k| step_of(&m, k, n)).collect();
let mut out = json!({"study": m.name, "steps": m.steps, "samples": taken, "step": steps});
if let Some(sps) = seconds_per_step {
out["time"] = steps.iter().map(|st| st * sps).collect();
}
let joints_out: Vec<Value> = series
.iter()
.zip(values.iter_mut())
.map(|((id, i), v)| {
let j = s.doc.assembly.joints.iter().find(|j| j.id == *id);
let angle = j.is_some_and(|j| j.kind.is_angle(*i));
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, 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);
}
}
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}²"),
"value": v, "velocity": derivative(v, h), "acceleration": second_derivative(v, h)})
})
.collect();
out["joints"] = json!(joints_out);
if let Some((occ, _)) = &point {
let axis = |c: usize| -> Vec<f64> { path.iter().map(|q| [q.x, q.y, q.z].get(c).copied().unwrap_or(0.0)).collect() };
let d: Vec<(Vec<f64>, Vec<f64>)> = (0..3).map(|c| (derivative(&axis(c), h), second_derivative(&axis(c), h))).collect();
let vec_at = |k: usize, second: bool| -> Vec3 {
let c = |i: usize| d.get(i).and_then(|(v, a)| if second { a.get(k) } else { v.get(k) }).copied().unwrap_or(0.0);
Vec3::new(c(0), c(1), c(2))
};
let (vel, acc): (Vec<Vec3>, Vec<Vec3>) = (0..path.len()).map(|k| (vec_at(k, false), vec_at(k, true))).unzip();
let xyz = |list: &[Vec3]| -> Value { list.iter().map(|v| json!([v.x, v.y, v.z])).collect() };
let name = s.doc.occurrences.iter().find(|o| Some(&o.id) == occ.last()).map_or_else(|| "root".to_string(), |o| o.name.clone());
out["point"] = json!({"occurrence": name, "unit": "mm", "velocity_unit": format!("mm/{per}"), "acceleration_unit": format!("mm/{per}²"),
"position": xyz(&path), "velocity": xyz(&vel), "acceleration": xyz(&acc),
"speed": vel.iter().map(|v| v.len()).collect::<Vec<_>>(), "acceleration_norm": acc.iter().map(|v| v.len()).collect::<Vec<_>>()});
}
for k in ["stopped_at", "stopped_by_loop", "stopped_by_contact"] {
out[k] = stop[k].clone();
}
Ok(out)
}
/// One pair of parts that collides along a study.
struct Collision {
names: (String, String),
/// The samples where they overlap, in order.
samples: Vec<usize>,
depth: f64,
deepest: usize,
/// The design at the deepest sample (its placements).
at_deepest: Arc<Document>,
}
/// The volume the parts under occurrences `a` and `b` share, placed as in `doc` (`None`: not
/// worked out). Only bodies whose boxes overlap are intersected, at most `booleans` of them.
fn shared_volume(s: &Session, doc: &Document, a: u64, b: u64, booleans: &mut usize) -> Option<f64> {
let st = s.model.state();
// The bodies under each (and not under the other), with their world frames and boxes.
let mut sides: [Vec<(Mat, Aabb3, &solidcraft_doc::ModelBody)>; 2] = Default::default();
for (comp, path, m) in doc.placements() {
// A body belongs to the innermost of the two on its path, as contact sets pair them.
let side = match path.iter().rev().find(|o| **o == a || **o == b) {
Some(o) if *o == a => 0,
Some(_) => 1,
None => continue,
};
for body in st.bodies.iter().filter(|x| doc.model_body_component(x) == comp && !x.body.is_mesh()) {
let frame = doc.model_body_offset(body).map(|o| mat_mul(&m, o)).unwrap_or(m);
let local = body.mesh().bounds();
let mut world = Aabb3::EMPTY;
for c in 0..8 {
let pick = |bit: usize, lo: f64, hi: f64| if c & bit == 0 { lo } else { hi };
let corner = Vec3::new(pick(1, local.min.x, local.max.x), pick(2, local.min.y, local.max.y), pick(4, local.min.z, local.max.z));
world.add(apply_point(&frame, corner));
}
sides.get_mut(side)?.push((frame, world, body));
}
}
let mut total = 0.0;
for (fa, xa, ba) in sides.first()? {
for (fb, xb, bb) in sides.get(1)? {
if xa.max.x < xb.min.x || xb.max.x < xa.min.x || xa.max.y < xb.min.y || xb.max.y < xa.min.y || xa.max.z < xb.min.z || xb.max.z < xa.min.z
{
continue;
}
*booleans = booleans.checked_sub(1)?;
let (pa, pb) = (solidcraft_kernel::transform_matrix(&ba.body, *fa).ok()?, solidcraft_kernel::transform_matrix(&bb.body, *fb).ok()?);
if let Some(shared) = solidcraft_kernel::boolean(&pa, &pb, solidcraft_kernel::BoolOp::Intersect).ok()? {
total += solidcraft_kernel::measure(&shared).ok()?.volume;
}
}
}
Some(total)
}
/// Contiguous runs of samples, as study step ranges.
fn ranges(m: &MotionStudy, samples: &[usize], n: usize) -> Vec<[f64; 2]> {
let mut out: Vec<(usize, usize)> = Vec::new();
for &k in samples {
match out.last_mut() {
Some(run) if run.1 + 1 == k => run.1 = k,
_ => out.push((k, k)),
}
}
out.into_iter().map(|(a, b)| [step_of(m, a, n), step_of(m, b, n)]).collect()
}
pub(super) fn interference(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "motion.interference";
let m = s.doc.assembly.studies.get(study_index(s, p, cmd)?).cloned().ok_or_else(|| bad(cmd, "study"))?;
let n = samples(p, &m, 2, cmd)?;
let everything = || vec![s.doc.occurrences.iter().map(|o| o.id).collect::<Vec<u64>>()];
// Each of `occurrences` only against these.
let against = match p.get("against") {
None | Some(Value::Null) => None,
Some(_) if p.get("occurrences").is_none() => return Err(bad(cmd, "`against` checks the `occurrences` against other parts: list them too")),
Some(v) => Some(occurrences(s, &json!({"occurrences": v}), cmd)?),
};
let sets: Vec<Vec<u64>> = match (p.get("occurrences"), bool_(p, "contact_sets").unwrap_or(false)) {
(Some(_), true) => return Err(bad(cmd, "give `occurrences` or `contact_sets`, not both")),
(Some(_), false) => {
let mut list = occurrences(s, p, cmd)?;
if list.len() + against.as_ref().map_or(0, Vec::len) < 2 {
return Err(bad(cmd, "list two or more occurrences to check against each other"));
}
for o in against.iter().flatten() {
if !list.contains(o) {
list.push(*o);
}
}
vec![list]
}
(None, true) => {
let a = &s.doc.assembly;
let sets: Vec<Vec<u64>> =
if a.contact_all { everything() } else { a.contact_sets.iter().filter(|c| !c.suppressed).map(|c| c.occurrences.clone()).collect() };
if sets.is_empty() {
return Err(bad(cmd, "the design has no contact sets"));
}
sets
}
(None, false) => everything(),
};
let mut geometry = ContactGeometry::of_sets(s, &sets).map_err(|e| match e {
EngineError::BadParams { msg, .. } => bad(cmd, format!("{msg} (check fewer parts: list the `occurrences`)")),
e => e,
})?;
if let Some(b) = &against {
let a = occurrences(s, p, cmd)?;
geometry.keep_pairs(|x, y| (a.contains(&x) && b.contains(&y)) || (a.contains(&y) && b.contains(&x)));
}
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 => {
truncated = true;
return Ok(false);
}
Err(e) => return Err(e),
};
checked = k + 1;
for ((a, an), (b, bn), depth) in hits {
// Each pair once, the older occurrence (the lower id) first.
let ((a, an), (b, bn)) = if a <= b { ((a, an), (b, bn)) } else { ((b, bn), (a, an)) };
let c = found.entry((a, b)).or_insert_with(|| Collision {
names: (an, bn),
samples: Vec::new(),
depth: 0.0,
deepest: k,
at_deepest: t.doc.clone(),
});
if c.samples.last() != Some(&k) {
c.samples.push(k);
}
if depth > c.depth {
(c.depth, c.deepest, c.at_deepest) = (depth, k, t.doc.clone());
}
}
Ok(true)
})?;
// Nothing checked is not clear.
if truncated && checked == 0 {
return Err(bad(cmd, "the check ran out of its work budget before its first step: check fewer parts (occurrences, against)"));
}
let mut list: Vec<(&(u64, u64), &Collision)> = found.iter().collect();
list.sort_by_key(|(_, c)| c.samples.first().copied().unwrap_or(usize::MAX));
let mut booleans = MAX_BOOLEANS;
let collisions: Vec<Value> = list
.iter()
.enumerate()
.map(|(i, ((a, b), c))| {
let volume = if i < MAX_VOLUMES { shared_volume(s, &c.at_deepest, *a, *b, &mut booleans) } else { None };
let first = c.samples.first().map(|k| step_of(&m, *k, n));
let last = c.samples.last().map(|k| step_of(&m, *k, n));
json!({"a": c.names.0, "b": c.names.1, "first_step": first, "last_step": last, "steps": ranges(&m, &c.samples, n),
"depth_mm": c.depth, "deepest_step": step_of(&m, c.deepest, n), "volume_mm3": volume})
})
.collect();
let mut out = json!({"study": m.name, "steps": m.steps, "clear": collisions.is_empty(), "collisions": collisions,
"pairs": geometry.pair_count(), "samples": checked, "truncated": truncated});
if truncated {
out["checked_to"] = json!(checked.checked_sub(1).map(|k| step_of(&m, k, n)));
}
out["stopped_at"] = stop["stopped_at"].clone();
out["stopped_by_loop"] = stop["stopped_by_loop"].clone();
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
/// The differences are exact for a quadratic: its velocity and acceleration everywhere,
/// the ends too.
#[test]
fn finite_differences_follow_a_polynomial() {
let h = 0.5;
let f = |x: f64| 2.0 * x * x - 3.0 * x + 1.0;
let v: Vec<f64> = (0..7).map(|k| f(k as f64 * h)).collect();
let d = derivative(&v, h);
let a = second_derivative(&v, h);
for (k, (dk, ak)) in d.iter().zip(&a).enumerate() {
let x = k as f64 * h;
assert!((dk - (4.0 * x - 3.0)).abs() < 1e-9, "{k}: {dk}");
assert!((ak - 4.0).abs() < 1e-9, "{k}: {ak}");
}
assert_eq!(derivative(&[1.0, 3.0], 2.0), vec![1.0, 1.0]);
assert_eq!(second_derivative(&[1.0, 3.0], 2.0), vec![0.0, 0.0]);
assert_eq!(derivative(&[5.0], 1.0), vec![0.0]);
assert!(derivative(&[], 1.0).is_empty());
}
#[test]
fn angles_unwrap_across_half_turns() {
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]);
}
}
@@ -0,0 +1,211 @@
//! `motion.plot` against the analytic motion of a four-bar and a slider-crank, and
//! `motion.interference` on two blocks pushed through each other at a known step.
use serde_json::{Value, json};
use solidcraft_geom::Vec3;
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.
fn series<'a>(r: &'a Value, name: &str) -> &'a Value {
r["joints"].as_array().and_then(|list| list.iter().find(|j| j["name"] == name)).unwrap_or(&Value::Null)
}
fn nums(v: &Value) -> Vec<f64> {
v.as_array().into_iter().flatten().map(|x| x.as_f64().unwrap_or(f64::NAN)).collect()
}
/// The crank turns at a constant 50°/s (a full turn in 7.2 s). The rocker turns at
/// ω4 = ω2·a·sin(θ2 − θ3) / (c·sin(θ4 − θ3)) (crank a = 20, rocker c = 40; θ2, θ3 and θ4 the
/// crank, coupler and rocker angles), from differentiating the loop a·e^iθ2 + b·e^iθ3 = d + c·e^iθ4.
#[test]
fn four_bar_rocker_velocity_matches_its_analytic_derivative() {
let (mut s, _, q0) = four_bar();
run(&mut s, "motion.study", json!({"name": "Turn", "steps": 360, "keys": [{"joint": "Crank", "points": [[0, 0], [360, 360]]}]}));
let before = s.doc.clone();
let r = run(&mut s, "motion.plot", json!({"study": "Turn", "joints": ["Crank", "Rocker"], "samples": 181, "duration": 7.2}));
assert_eq!(*s.doc, *before, "a plot leaves the design as it was");
assert!(r["stopped_at"].is_null() && r["samples"] == 181, "{}", r["stopped_by_loop"]);
assert_eq!(r["joints"].as_array().map(Vec::len), Some(2));
let (crank, rocker) = (series(&r, "Crank"), series(&r, "Rocker"));
assert_eq!((rocker["unit"].as_str(), rocker["velocity_unit"].as_str()), (Some("deg"), Some("deg/s")));
let time = nums(&r["time"]);
assert!((time.last().copied().unwrap_or(0.0) - 7.2).abs() < 1e-9 && (time[1] - 0.04).abs() < 1e-12, "{time:?}");
// The crank: exactly as keyed, at a constant speed.
for ((v, w), a) in nums(&crank["value"]).iter().zip(nums(&crank["velocity"])).zip(nums(&crank["acceleration"])) {
assert!((w - 50.0).abs() < 1e-6 && a.abs() < 1e-6, "{v}°: {w} deg/s, {a} deg/s²");
}
let (value, velocity) = (nums(&rocker["value"]), nums(&rocker["velocity"]));
let start = q0.y.atan2(q0.x - 45.0);
let (mut q, mut peak, mut worst) = (q0, 0.0f64, 0.0f64);
for k in 0..181 {
let v = 2.0 * k as f64;
if k > 0 {
q = rocker_pin(v - 2.0, v, q);
}
let th2 = (90.0 + v).to_radians();
let p = Vec3::new(20.0 * th2.cos(), 20.0 * th2.sin(), 0.0);
let (th3, th4) = ((q - p).y.atan2((q - p).x), q.y.atan2(q.x - 45.0));
// The rocker's value is its turn from where it was assembled, made continuous.
let turned = (th4 - start).to_degrees();
assert!(((value[k] - turned + 540.0).rem_euclid(360.0) - 180.0).abs() < 1e-6, "{v}°: {} wants {turned}", value[k]);
let w4 = 50.0 * 20.0 * (th2 - th3).sin() / (40.0 * (th4 - th3).sin());
peak = peak.max(w4.abs());
worst = worst.max((velocity[k] - w4).abs());
}
// Central differences 0.04 s apart: within 0.2 % of the fastest swing.
assert!(peak > 40.0 && worst < 2e-3 * peak, "worst {worst} deg/s of {peak} deg/s");
}
/// The piston of a slider-crank (crank a = 15, rod b = 40) turning at 100°/s sits at
/// x = a·cos θ + S with S = √(b² − a²·sin²θ), so it accelerates at
/// ω²·(−a·cos θ − a²·cos 2θ / S − a⁴·sin²θ·cos²θ / S³).
#[test]
fn slider_crank_piston_acceleration_matches_its_analytic_form() {
let (mut s, _, s0) = slider_crank();
run(&mut s, "motion.study", json!({"name": "Turn", "steps": 360, "keys": [{"joint": "Crank", "points": [[0, 0], [360, 360]]}]}));
let p = json!({"study": "Turn", "joints": ["Bore"], "occurrence": "Piston", "point": [s0.x, s0.y, s0.z], "samples": 181, "duration": 3.6});
let r = run(&mut s, "motion.plot", p);
assert!(r["stopped_at"].is_null() && r["samples"] == 181, "{}", r["stopped_by_loop"]);
let point = &r["point"];
assert_eq!((point["occurrence"].as_str(), point["acceleration_unit"].as_str()), (Some("Piston:1"), Some("mm/s²")));
let at = |key: &str, k: usize, c: usize| point[key][k][c].as_f64().unwrap_or(f64::NAN);
let (a, b, w) = (15.0f64, 40.0f64, 100.0f64.to_radians());
let (mut peak, mut worst) = (0.0f64, 0.0f64);
for k in 0..181 {
let th = (90.0 + 2.0 * k as f64).to_radians();
let root = (b * b - (a * th.sin()).powi(2)).sqrt();
assert!((at("position", k, 0) - (a * th.cos() + root)).abs() < 1e-6, "{k}");
let want = w * w * (-a * th.cos() - a * a * (2.0 * th).cos() / root - a.powi(4) * (th.sin() * th.cos()).powi(2) / root.powi(3));
peak = peak.max(want.abs());
// The ends take one-sided differences; inside, central ones.
let tolerance = if k == 0 || k == 180 { 2e-2 } else { 2e-3 };
worst = worst.max((at("acceleration", k, 0) - want).abs() / tolerance);
assert!(at("acceleration", k, 1).abs() < 1e-6 && at("acceleration", k, 2).abs() < 1e-6, "it stays on its axis");
}
assert!(peak > 40.0 && worst < peak, "worst {worst} of {peak} mm/s² (scaled by tolerance)");
// The bore's value is the piston's travel, so its acceleration is the piston's along x.
let bore = series(&r, "Bore");
assert_eq!(bore["acceleration_unit"], "mm/s²");
let along = |k: usize| nums(&bore["value"])[k] - nums(&bore["value"])[0];
let sign = if along(90) * (at("position", 90, 0) - at("position", 0, 0)) < 0.0 { -1.0 } else { 1.0 };
for (k, acc) in nums(&bore["acceleration"]).iter().enumerate() {
assert!((acc - sign * at("acceleration", k, 0)).abs() < 1e-6, "{k}: {acc}");
}
// Per step without a duration: 2 steps between samples, 1° a step.
let r = run(&mut s, "motion.plot", json!({"study": "Turn", "joints": ["Crank"], "samples": 181}));
let crank = series(&r, "Crank");
assert!(r["time"].is_null() && crank["velocity_unit"] == "deg/step", "{crank}");
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<f64> = 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();
for (name, x) in [("A", 0), ("B", 20)] {
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "component.create", json!({"name": name}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0]}));
}
run(&mut s, "component.activate", json!({"component": "root"}));
let a = occ(&s, "A");
run(&mut s, "occurrence.ground", json!({"occurrence": a, "grounded": true}));
run(&mut s, "joint.create", json!({"type": "slider", "a": {"face": [10, 5, 5]}, "b": {"face": [20, 5, 5]}, "name": "Rail"}));
run(&mut s, "joint.drive", json!({"joint": "Rail", "value": 10}));
s
}
/// B is pushed 1 mm a step from 10 mm away to 5 mm into A: the faces touch at step 10 and the
/// blocks overlap from step 11 to the end, 100 mm³ more each step.
#[test]
fn interference_names_the_step_two_parts_start_to_collide() {
let mut s = rail();
run(&mut s, "motion.study", json!({"name": "Push", "steps": 15, "keys": [{"joint": "Rail", "points": [[0, 10], [15, -5]]}]}));
// A contact set would stop the motion where the faces touch; the check plays through.
run(&mut s, "contact.create", json!({"occurrences": ["A", "B"]}));
let before = s.doc.clone();
for p in [json!({"study": "Push"}), json!({"study": "Push", "occurrences": ["A", "B"]}), json!({"study": "Push", "contact_sets": true})] {
let r = run(&mut s, "motion.interference", p.clone());
assert_eq!(*s.doc, *before, "the design is left as it was");
assert!(r["clear"] == false && r["samples"] == 16 && r["pairs"] == 1 && r["truncated"] == false, "{p}: {r}");
let c = &r["collisions"][0];
assert_eq!((c["a"].as_str(), c["b"].as_str()), (Some("A:1"), Some("B:1")), "{r}");
assert_eq!((c["first_step"].as_f64(), c["last_step"].as_f64()), (Some(11.0), Some(15.0)), "{r}");
assert_eq!(c["steps"], json!([[11.0, 15.0]]), "{r}");
let deepest = c["deepest_step"].as_f64().unwrap_or(f64::NAN);
assert!(c["depth_mm"].as_f64().unwrap_or(0.0) > 1.0 && (11.0..=15.0).contains(&deepest), "{r}");
let volume = c["volume_mm3"].as_f64().unwrap_or(f64::NAN);
assert!((volume - 100.0 * (deepest - 10.0)).abs() < 1e-6, "{r}");
}
// B against a far block C only: B's pass through A is not asked about.
run(&mut s, "component.create", json!({"name": "C"}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [0, 50, 0]}));
run(&mut s, "component.activate", json!({"component": "root"}));
let r = run(&mut s, "motion.interference", json!({"study": "Push", "occurrences": ["B"], "against": ["C"]}));
assert!(r["clear"] == true && r["pairs"] == 1, "{r}");
let r = run(&mut s, "motion.interference", json!({"study": "Push", "occurrences": ["B"], "against": ["A", "C"]}));
assert!(r["pairs"] == 2 && r["collisions"].as_array().map(Vec::len) == Some(1) && r["collisions"][0]["first_step"] == 11.0, "{r}");
assert!(s.execute("motion.interference", &json!({"study": "Push", "against": ["A"]})).is_err());
// Pushed only to touching, they never pass through each other.
run(&mut s, "motion.study", json!({"study": "Push", "keys": [{"joint": "Rail", "points": [[0, 10], [15, 0]]}]}));
let r = run(&mut s, "motion.interference", json!({"study": "Push"}));
assert!(r["clear"] == true && r["collisions"] == json!([]), "{r}");
}
/// A number names a study by its id first, text by its name first: a study may be named like
/// another's id.
#[test]
fn studies_by_id_or_name() {
let mut s = rail();
let keys = json!([{"joint": "Rail", "points": [[0, 10], [15, -5]]}]);
let first = run(&mut s, "motion.study", json!({"name": "Late", "steps": 15, "keys": keys}))["study"].as_u64().unwrap_or(0);
run(&mut s, "motion.study", json!({"name": first.to_string(), "steps": 15, "keys": keys}));
assert_eq!(run(&mut s, "motion.plot", json!({"study": first, "samples": 3}))["study"], "Late");
assert_eq!(run(&mut s, "motion.plot", json!({"study": first.to_string(), "samples": 3}))["study"], first.to_string());
}
/// Hostile parameters are refused, never a panic.
#[test]
fn motion_analysis_refuses_what_it_cannot_do() {
let mut s = rail();
run(&mut s, "motion.study", json!({"name": "Push", "steps": 15, "keys": [{"joint": "Rail", "points": [[0, 10], [15, -5]]}]}));
let refused = [
("motion.plot", json!({"study": "Nope"})),
("motion.plot", json!({"study": "Push", "samples": 2})),
("motion.plot", json!({"study": "Push", "samples": 1e9})),
("motion.plot", json!({"study": "Push", "samples": "many"})),
("motion.plot", json!({"study": "Push", "duration": 0})),
("motion.plot", json!({"study": "Push", "duration": -3})),
("motion.plot", json!({"study": "Push", "duration": 1e-200})),
("motion.plot", json!({"study": "Push", "joints": ["Nope"]})),
("motion.plot", json!({"study": "Push", "joints": vec![json!("Rail"); 300]})),
("motion.plot", json!({"study": "Push", "point": [1, 2]})),
("motion.plot", json!({"study": "Push", "occurrence": "B", "point": [1e300, 0, 0]})),
("motion.interference", json!({"study": "Push", "samples": 1})),
("motion.interference", json!({"study": "Push", "samples": f64::MAX})),
("motion.interference", json!({"study": "Push", "occurrences": ["A"]})),
("motion.interference", json!({"study": "Push", "occurrences": ["A", "B"], "contact_sets": true})),
("motion.interference", json!({"study": "Push", "contact_sets": true})),
];
for (cmd, p) in refused {
assert!(s.execute(cmd, &p).is_err(), "{cmd} {p}");
}
// A point of a part that does not move has no velocity.
let r = run(&mut s, "motion.plot", json!({"study": "Push", "occurrence": "A", "point": [5, 5, 5]}));
assert!(r["point"]["speed"].as_array().is_some_and(|v| v.len() == 16 && v.iter().all(|x| x.as_f64() == Some(0.0))), "{r}");
}
+11 -3
View File
@@ -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)");
}
+4 -4
View File
@@ -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),
+98 -7
View File
@@ -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 }
+508
View File
@@ -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(&params);
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]]);
}
}
+10 -5
View File
@@ -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();