Files
solidcraft-upstream/crates/engine/src/cmd/motion.rs
T

997 lines
45 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Contact Sets, Motion Study and Exploded views.
use std::collections::BTreeSet;
use serde_json::{Value, json};
use solidcraft_doc::Mat;
use solidcraft_doc::joints::{self, JointKind};
use solidcraft_doc::motion::{ContactSet, ExplodedView, MotionStudy, StudyKey};
use solidcraft_geom::Vec3;
use solidcraft_kernel::Body;
use super::CommandSpec;
use super::joints::{cannot_move, effective_values, joint_id, occurrence_id, resolve_holding, value};
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"]
mod contact_sweep;
macro_rules! trace_doc {
() => {
"occurrence: id|name, or an occurrence path [ids] (an instance inside sub-assemblies); point: [x,y,z] world point on it, where it is placed now; study: id|name, or joint: id|name with from and \
to (degrees for angles, mm for distances, or expressions) and index? (which value, default 0); samples? (2…500, default \
the study's steps + 1, or 100) - the point's path as the study plays or the joint is driven, closed loops following"
};
}
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("contact.enable_sets", "Enable Contact Sets", enable_sets).at("SOLID", "ASSEMBLE").icon("joint").params(
"enabled?: bool (default true) — driving joints and motion studies stop where bodies in one contact set would pass through each other",
),
CommandSpec::new("contact.enable_all", "Enable All Contact", enable_all)
.at("SOLID", "ASSEMBLE")
.icon("joint")
.params("every occurrence takes part in contact (one set of all)"),
CommandSpec::new("contact.disable_all", "Disable Contact", disable_all).at("SOLID", "ASSEMBLE").icon("joint").params("contact off"),
CommandSpec::new("contact.create", "New Contact Set", new_set)
.at("SOLID", "ASSEMBLE")
.icon("joint")
.params("occurrences: [ids or names] (two or more); name?"),
CommandSpec::new("contact.edit", "Edit Contact Set", edit_set).params("set: id|name; occurrences?; name?; suppressed?: bool; delete?: bool"),
CommandSpec::new("contact.list", "List Contact Sets", list_sets).noundo().doc_only().params("→ sets, enabled, all"),
CommandSpec::new("contact.check", "Check Contact", check).noundo().params("→ pairs in contact sets that pass through each other now"),
CommandSpec::new("motion.study", "Motion Study", study).at("SOLID", "ASSEMBLE").icon("joint").params(
"name?; study? (edit an existing one); steps? (timeline length, default 100); keys: [{joint, index?, points: [[step, value], …]}] \
(values: degrees for angles, mm for distances, or expressions)",
),
CommandSpec::new("motion.play", "Play Motion Study", play).params(
"study: id|name; step (0…steps) — sets the joints to the study's values there (contact sets stop them); quiet?: bool \
(positions of only the occurrences that moved) → values: [{joint, values: [{name, value, unit}]}], positions, moved: whether \
any occurrence moved, unmoved: [{joint, name, reason}] (keyed joints that can move nothing, such as a chain joined to \
nothing grounded)",
),
CommandSpec::new("motion.export", "Export Motion Study", export)
.noundo()
.params("study; samples? (default steps + 1); path? (.json or .csv) → occurrence positions per step (the design is not changed)"),
CommandSpec::new("motion.export_animation", "Export Motion Animation", animation::export).noundo().params(
"study: id|name, or joint: id|name with from and to (degrees for angles, mm for distances, or expressions) and index?; path: a .gif file, or a folder for PNG frames (frame-0001.png, frame-0002.png, …); samples? (frames, 2…1000; default the study's steps + 1, or 100); width?, height? (16…4096 pixels, default 800 × 500); camera?: {target, yaw, pitch, roll?, distance, fov?, up?} (ui.camera's reply; fields left out keep the default view: view.apply's camera, or the home view framing the model); fps? (1…50, default 20); grid?, axes? (default true: the ground grid and the origin's axes) - draws the study as it plays, or the joint as it is driven, with the headless renderer → path, format, frames, width, height, fps (GIF), bytes, renderer, stopped_at, stopped_by_loop, stopped_by_contact (the design is not changed)",
),
CommandSpec::new("motion.trace", "Motion Trace", trace).at("SOLID", "ASSEMBLE").icon("trace").noundo().params(concat!(
trace_doc!(),
" → points: [[x,y,z]…] world positions, samples (taken), stopped_at (the sample where the motion stopped short; the trace \
ends there), stopped_by_loop, stopped_by_contact (the design is not changed)"
)),
CommandSpec::new("motion.trace.keep", "Keep Motion Trace", keep_trace).params(concat!(
trace_doc!(),
"; 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(
"name?; scale? (automatic: each occurrence pushed away from the assembly's centre by scale × its offset, default 1); \
moves?: [{occurrence, translate: [x,y,z]}] (instead of automatic)",
),
CommandSpec::new("explode.show", "Show Exploded View", explode_show)
.noundo()
.params("view: id|name (or none: back to the assembled placement) — shown only; the design keeps its placements"),
CommandSpec::new("explode.list", "List Exploded Views", explode_list).noundo(),
CommandSpec::new("explode.delete", "Delete Exploded View", explode_delete).params("view"),
];
const MAX_ITEMS: usize = 10_000;
fn next_id(s: &Session) -> u64 {
let a = &s.doc.assembly;
let ids = a.contact_sets.iter().map(|c| c.id).chain(a.studies.iter().map(|m| m.id)).chain(a.exploded.iter().map(|e| e.id));
ids.max().unwrap_or(0) + 1
}
fn occurrences(s: &Session, p: &Value, cmd: &str) -> Result<Vec<u64>> {
let list = p.get("occurrences").and_then(Value::as_array).ok_or_else(|| bad(cmd, "`occurrences` must list occurrences"))?;
if list.len() > MAX_ITEMS {
return Err(bad(cmd, "too many occurrences"));
}
let mut out = Vec::new();
for v in list {
let id = occurrence_id(s, Some(v), cmd)?;
if id == 0 {
return Err(bad(cmd, "the root is not an occurrence"));
}
if !out.contains(&id) {
out.push(id);
}
}
Ok(out)
}
// ---- Contact ----
/// The placed bodies of the design, each with the occurrences it sits in.
fn placed(s: &Session) -> Vec<(Vec<u64>, Body)> {
let st = s.model.state();
let mut out = Vec::new();
for (comp, path, m) in s.doc.placements() {
if comp == 0 {
continue;
}
for b in st.bodies.iter().filter(|b| s.doc.model_body_component(b) == comp && !b.body.is_mesh()) {
// Moving a body into another component preserves its world placement using an
// offset from its authoring frame. Contact and explode centres must use that
// same offset as display and physical properties.
let frame = s.doc.model_body_offset(b).map(|offset| solidcraft_doc::mat_mul(&m, offset)).unwrap_or(m);
if let Ok(t) = solidcraft_kernel::transform_matrix(&b.body, frame) {
out.push((path.clone(), t));
}
}
}
out
}
/// Pairs of bodies in one active contact set that pass through each other: (occurrence names).
pub fn collisions(s: &Session) -> Vec<(String, String, f64)> {
let a = &s.doc.assembly;
if !a.contact_enabled {
return Vec::new();
}
let sets: Vec<Vec<u64>> = if a.contact_all {
vec![s.doc.occurrences.iter().map(|o| o.id).collect()]
} else {
a.contact_sets.iter().filter(|c| !c.suppressed).map(|c| c.occurrences.clone()).collect()
};
let bodies = placed(s);
let name = |id: u64| s.doc.occurrences.iter().find(|o| o.id == id).map(|o| o.name.clone()).unwrap_or_default();
let mut out = Vec::new();
for set in &sets {
// The member of the set each body belongs to (its innermost occurrence in the set).
let tagged: Vec<(u64, &Body)> = bodies.iter().filter_map(|(path, b)| path.iter().rev().find(|o| set.contains(o)).map(|o| (*o, b))).collect();
for (i, (oa, ba)) in tagged.iter().enumerate() {
for (ob, bb) in tagged.iter().skip(i + 1) {
if oa == ob {
continue;
}
if let Some(v) = overlap(ba, bb) {
out.push((name(*oa), name(*ob), v));
}
}
}
}
out
}
/// How deep one body reaches into the other, when more than a touch (`None`: apart or
/// touching). Points of each tessellation (vertices, edge midpoints, triangle centres) are
/// tested against the other closed mesh: inside by ray parity, and deeper than the tolerance.
fn overlap(a: &Body, b: &Body) -> Option<f64> {
let (ma, mb) = (a.tessellate((a.size() * 1e-3).max(1e-3)).ok()?, b.tessellate((b.size() * 1e-3).max(1e-3)).ok()?);
mesh_overlap(&ma, &mb, 1e-4 * a.size().max(b.size()).max(1.0))
}
fn mesh_overlap(ma: &solidcraft_geom::Mesh, mb: &solidcraft_geom::Mesh, tol: f64) -> Option<f64> {
let (ba, bb) = (ma.bounds(), mb.bounds());
if ba.max.x < bb.min.x + tol
|| bb.max.x < ba.min.x + tol
|| ba.max.y < bb.min.y + tol
|| bb.max.y < ba.min.y + tol
|| ba.max.z < bb.min.z + tol
|| bb.max.z < ba.min.z + tol
{
return None;
}
let depth = deepest(ma, mb, tol).max(deepest(mb, ma, tol));
(depth > tol).then_some(depth)
}
fn tris(m: &solidcraft_geom::Mesh) -> Vec<[Vec3; 3]> {
m.triangles.iter().filter_map(|t| m.tri(t)).collect()
}
/// The deepest any sample point of `a` lies inside `b` (0 if none).
fn deepest(a: &solidcraft_geom::Mesh, b: &solidcraft_geom::Mesh, tol: f64) -> f64 {
let tb = tris(b);
let bx = b.bounds();
let mut pts: Vec<Vec3> = a.positions.clone();
for [p, q, r] in tris(a) {
pts.extend([(p + q) * 0.5, (q + r) * 0.5, (r + p) * 0.5, (p + q + r) * (1.0 / 3.0)]);
}
let mut best = 0.0f64;
for p in pts.into_iter().take(200_000) {
if p.x < bx.min.x - tol
|| p.x > bx.max.x + tol
|| p.y < bx.min.y - tol
|| p.y > bx.max.y + tol
|| p.z < bx.min.z - tol
|| p.z > bx.max.z + tol
{
continue;
}
let d = tb.iter().map(|[a, b, c]| closest(p, *a, *b, *c).dist(p)).fold(f64::MAX, f64::min);
if d > tol.max(best) && inside(p, &tb) {
best = d;
}
}
best
}
/// Ray parity along a skew direction (no axis-aligned edge hits).
fn inside(p: Vec3, tris: &[[Vec3; 3]]) -> bool {
// Fixed ray coordinates, not an approximation used to compute Euler's constant.
#[allow(clippy::approx_constant)]
let dir = Vec3::new(0.577_215_664_9, 0.327_118_441_9, 0.751_891_438_7);
let mut n = 0;
for [a, b, c] in tris {
let (e1, e2) = (*b - *a, *c - *a);
let h = dir.cross(e2);
let det = e1.dot(h);
if det.abs() < 1e-14 {
continue;
}
let f = 1.0 / det;
let sv = p - *a;
let u = f * sv.dot(h);
if !(0.0..=1.0).contains(&u) {
continue;
}
let q = sv.cross(e1);
let v = f * dir.dot(q);
if v < 0.0 || u + v > 1.0 {
continue;
}
if f * e2.dot(q) > 0.0 {
n += 1;
}
}
n % 2 == 1
}
/// The point of triangle abc nearest p.
fn closest(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> Vec3 {
let (ab, ac, ap) = (b - a, c - a, p - a);
let (d1, d2) = (ab.dot(ap), ac.dot(ap));
if d1 <= 0.0 && d2 <= 0.0 {
return a;
}
let bp = p - b;
let (d3, d4) = (ab.dot(bp), ac.dot(bp));
if d3 >= 0.0 && d4 <= d3 {
return b;
}
let vc = d1 * d4 - d3 * d2;
if vc <= 0.0 && d1 >= 0.0 && d3 <= 0.0 {
return a + ab * (d1 / (d1 - d3));
}
let cp = p - c;
let (d5, d6) = (ab.dot(cp), ac.dot(cp));
if d6 >= 0.0 && d5 <= d6 {
return c;
}
let vb = d5 * d2 - d1 * d6;
if vb <= 0.0 && d2 >= 0.0 && d6 <= 0.0 {
return a + ac * (d2 / (d2 - d6));
}
let va = d3 * d6 - d5 * d4;
if va <= 0.0 && (d4 - d3) >= 0.0 && (d5 - d6) >= 0.0 {
return b + (c - b) * ((d4 - d3) / ((d4 - d3) + (d5 - d6)));
}
let den = va + vb + vc;
if den.abs() < 1e-300 {
return a;
}
a + ab * (vb / den) + ac * (vc / den)
}
/// Set joint values, stopping short where contact sets would collide or a closed loop could no
/// longer close: the values are moved from where they are toward `want` as far as they go. The
/// `given` values (joint, index) are driven; in a closed loop the others follow. Returns the
/// answer and the last solve.
fn drive_with_contact(s: &mut Session, want: &[(u64, Vec<f64>)], given: &BTreeSet<(u64, usize)>) -> Result<(Value, joints::Solution)> {
let baseline = s.doc.clone();
let start: Vec<(u64, Vec<f64>)> =
want.iter().map(|(id, _)| (*id, s.doc.assembly.joints.iter().find(|j| j.id == *id).map(|j| j.values.clone()).unwrap_or_default())).collect();
// Closed loops follow the driven values from where they start.
let held: joints::Held = given
.iter()
.filter(|&&(id, i)| want.iter().any(|(w, q)| *w == id && i < q.len()))
.map(|&(id, i)| {
// A value the file leaves out is 0, as the solver reads it.
let from = start.iter().find(|(s, _)| *s == id).and_then(|(_, q)| q.get(i).copied());
((id, i), from.unwrap_or(0.0))
})
.collect();
let set = |s: &mut Session, t: f64| {
// Trial order must not affect free anchors or linked joint placements.
s.doc = baseline.clone();
let doc = s.doc_mut();
for ((id, a), (_, b)) in start.iter().zip(want) {
if let Some(j) = doc.assembly.joints.iter_mut().find(|j| j.id == *id) {
let mut q: Vec<f64> = b.iter().enumerate().map(|(i, bv)| a.get(i).copied().unwrap_or(0.0) * (1.0 - t) + bv * t).collect();
joints::clamp(j, &mut q);
j.values = q;
}
}
resolve_holding(s, &held)
};
// Loops already open at the start stay as they are; a loop the motion opens stops it.
let open_at_start: BTreeSet<u64> = set(s, 0.0).conflicts.iter().map(|c| c.0).collect();
let opened = |sol: &joints::Solution| sol.conflicts.iter().find(|c| !open_at_start.contains(&c.0)).map(|c| c.1.clone());
let cache = if s.doc.assembly.contact_enabled { Some(contact_sweep::ContactGeometry::new(s)?) } else { None };
let mut sol;
let mut stopped = None;
if let Some(cache) = cache.filter(|cache| cache.has_pairs()) {
let mut end = s.scratch();
set(&mut end, 1.0);
let samples = cache.samples(s, &end)?;
let mut work = 0;
set(s, 0.0);
// Keep the existing recovery behaviour: an initially overlapping assembly may move
// out of its invalid placement. Sweeps constrain motions that start collision-free.
if cache.first_hit(s, &mut work)?.is_none() {
let mut lo = 0.0;
let mut bracket = None;
for sample in 1..=samples {
let hi = sample as f64 / samples as f64;
set(s, hi);
if let Some(hit) = cache.first_hit(s, &mut work)? {
bracket = Some((lo, hi, hit));
break;
}
lo = hi;
}
if let Some((mut lo, mut hi, hit)) = bracket {
for _ in 0..14 {
let mid = (lo + hi) / 2.0;
set(s, mid);
if cache.first_hit(s, &mut work)?.is_none() { lo = mid } else { hi = mid }
}
sol = set(s, lo);
stopped = Some(json!({"a": hit.0, "b": hit.1, "fraction": lo}));
} else {
sol = set(s, 1.0);
}
} else {
sol = set(s, 1.0);
}
} else {
sol = set(s, 1.0);
}
let mut stopped_by_loop = None;
if let Some(mut name) = opened(&sol) {
// The furthest the loops still close, short of where contact stopped; the joint named is
// the one whose loop opens first.
let (mut lo, mut hi) = (0.0, stopped.as_ref().and_then(|v| v["fraction"].as_f64()).unwrap_or(1.0));
for _ in 0..14 {
let mid = (lo + hi) / 2.0;
match opened(&set(s, mid)) {
None => lo = mid,
Some(n) => (hi, name) = (mid, n),
}
}
sol = set(s, lo);
stopped = None;
stopped_by_loop = Some(json!({"joint": name, "fraction": lo}));
}
let values: Vec<Value> = want.iter().map(|(id, _)| json!({"joint": id, "values": effective_values(s, *id)})).collect();
let out = json!({"values": values, "stopped_by_contact": stopped, "stopped_by_loop": stopped_by_loop, "conflicts": sol.conflicts.len()});
Ok((out, sol))
}
/// Drive one joint (joint.drive), honouring contact sets.
pub fn drive_joint(s: &mut Session, id: u64, q: Vec<f64>, given: usize) -> Result<Value> {
let given = (0..given.min(q.len())).map(|i| (id, i)).collect();
Ok(drive_with_contact(s, &[(id, q)], &given)?.0)
}
fn enable_sets(s: &mut Session, p: &Value) -> Result<Value> {
let on = bool_(p, "enabled").unwrap_or(true);
s.doc_mut().assembly.contact_enabled = on;
Ok(json!({"enabled": on}))
}
fn enable_all(s: &mut Session, _p: &Value) -> Result<Value> {
let a = &mut s.doc_mut().assembly;
a.contact_enabled = true;
a.contact_all = true;
Ok(json!({"enabled": true, "all": true}))
}
fn disable_all(s: &mut Session, _p: &Value) -> Result<Value> {
let a = &mut s.doc_mut().assembly;
a.contact_enabled = false;
a.contact_all = false;
Ok(json!({"enabled": false}))
}
fn new_set(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "contact.create";
let occ = occurrences(s, p, cmd)?;
if occ.len() < 2 {
return Err(bad(cmd, "a contact set needs two or more occurrences"));
}
if s.doc.assembly.contact_sets.len() >= MAX_ITEMS {
return Err(bad(cmd, "too many contact sets"));
}
let id = next_id(s);
let name = str_(p, "name").map(str::to_string).unwrap_or_else(|| format!("Contact Set{}", s.doc.assembly.contact_sets.len() + 1));
let a = &mut s.doc_mut().assembly;
a.contact_sets.push(ContactSet { id, name: name.clone(), occurrences: occ, suppressed: false });
a.contact_enabled = true;
Ok(json!({"set": id, "name": name}))
}
fn set_index(s: &Session, p: &Value, cmd: &str) -> Result<usize> {
let key = match p.get("set") {
Some(Value::Number(n)) => n.to_string(),
Some(Value::String(x)) => x.clone(),
_ => return Err(bad(cmd, "`set` must be an id or a name")),
};
s.doc
.assembly
.contact_sets
.iter()
.position(|c| c.id.to_string() == key || c.name == key)
.ok_or_else(|| bad(cmd, format!("no contact set `{key}`")))
}
fn edit_set(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "contact.edit";
let i = set_index(s, p, cmd)?;
if bool_(p, "delete").unwrap_or(false) {
s.doc_mut().assembly.contact_sets.remove(i);
return Ok(json!({"deleted": true}));
}
let occ = if p.get("occurrences").is_some() { Some(occurrences(s, p, cmd)?) } else { None };
if occ.as_ref().is_some_and(|o| o.len() < 2) {
return Err(bad(cmd, "a contact set needs two or more occurrences"));
}
let Some(c) = s.doc_mut().assembly.contact_sets.get_mut(i) else { return Err(bad(cmd, "set")) };
if let Some(o) = occ {
c.occurrences = o;
}
if let Some(n) = str_(p, "name").filter(|n| !n.trim().is_empty()) {
c.name = n.to_string();
}
if let Some(x) = bool_(p, "suppressed") {
c.suppressed = x;
}
Ok(json!({"set": c.id}))
}
fn list_sets(s: &mut Session, _p: &Value) -> Result<Value> {
let a = &s.doc.assembly;
Ok(json!({"enabled": a.contact_enabled, "all": a.contact_all, "sets": a.contact_sets}))
}
fn check(s: &mut Session, _p: &Value) -> Result<Value> {
let c = collisions(s);
Ok(json!({"collisions": c.iter().map(|(a, b, d)| json!({"a": a, "b": b, "depth_mm": d})).collect::<Vec<_>>()}))
}
// ---- Motion studies ----
fn study_index(s: &Session, p: &Value, cmd: &str) -> Result<usize> {
// 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")),
};
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> {
let cmd = "motion.study";
let existing = if p.get("study").is_some() { Some(study_index(s, p, cmd)?) } else { None };
let steps = match p.get("steps") {
Some(v) => {
let n = v.as_f64().filter(|n| n.is_finite() && *n >= 1.0 && *n <= 100_000.0).ok_or_else(|| bad(cmd, "`steps` must be 1…100000"))?;
Some(n.round() as u32)
}
None => None,
};
let mut keys = Vec::new();
if let Some(list) = p.get("keys") {
let list = list.as_array().ok_or_else(|| bad(cmd, "`keys` must be a list"))?;
if list.len() > 1000 {
return Err(bad(cmd, "too many keys"));
}
for k in list {
let joint = joint_id(s, k.get("joint"), cmd)?;
let kind = s.doc.assembly.joints.iter().find(|j| j.id == joint).map(|j| j.kind).unwrap_or(JointKind::Rigid);
let index = k.get("index").and_then(Value::as_u64).unwrap_or(0) as usize;
if index >= kind.dofs().len() {
return Err(bad(cmd, format!("joint {joint} has no moving value {index}")));
}
let pts = k.get("points").and_then(Value::as_array).ok_or_else(|| bad(cmd, "each key needs `points`: [[step, value], …]"))?;
if pts.is_empty() || pts.len() > 10_000 {
return Err(bad(cmd, "a key needs 1…10000 points"));
}
let mut points = Vec::new();
for pt in pts {
let (Some(st), Some(v)) = (pt.get(0).and_then(Value::as_f64).filter(|x| x.is_finite() && x.abs() < 1e9), pt.get(1)) else {
return Err(bad(cmd, "points are [step, value]"));
};
points.push((st, value(s, v, kind.is_angle(index), cmd)?));
}
points.sort_by(|a, b| a.0.total_cmp(&b.0));
keys.push(StudyKey { joint, index, points });
}
}
let id = next_id(s);
let count = s.doc.assembly.studies.len();
let a = &mut s.doc_mut().assembly;
let out = match existing.and_then(|i| a.studies.get_mut(i)) {
Some(m) => {
if let Some(n) = steps {
m.steps = n;
}
if p.get("keys").is_some() {
m.keys = keys;
}
if let Some(n) = str_(p, "name").filter(|n| !n.trim().is_empty()) {
m.name = n.to_string();
}
m.clone()
}
None => {
if count >= MAX_ITEMS {
return Err(bad(cmd, "too many motion studies"));
}
let m = MotionStudy {
id,
name: str_(p, "name").map(str::to_string).unwrap_or_else(|| format!("Motion Study{}", count + 1)),
steps: steps.unwrap_or(100),
keys,
};
a.studies.push(m.clone());
m
}
};
Ok(json!({"study": out.id, "name": out.name, "steps": out.steps, "keys": out.keys.len()}))
}
/// Joint values of a study at a step: (joint, full value vector).
fn values_at(s: &Session, m: &MotionStudy, step: f64) -> Vec<(u64, Vec<f64>)> {
let mut out: Vec<(u64, Vec<f64>)> = Vec::new();
for k in &m.keys {
let Some(v) = k.value_at(step) else { continue };
let pos = match out.iter().position(|(j, _)| *j == k.joint) {
Some(i) => i,
None => {
let cur = s.doc.assembly.joints.iter().find(|j| j.id == k.joint).map(|j| j.values.clone()).unwrap_or_default();
out.push((k.joint, cur));
out.len() - 1
}
};
if let Some((_, q)) = out.get_mut(pos) {
if q.len() <= k.index {
q.resize(k.index + 1, 0.0);
}
if let Some(x) = q.get_mut(k.index) {
*x = v;
}
}
}
out
}
/// The values a study keys: (joint, index).
fn keyed(m: &MotionStudy) -> BTreeSet<(u64, usize)> {
m.keys.iter().map(|k| (k.joint, k.index)).collect()
}
/// Where each occurrence is: (name, world transform).
pub(super) fn positions(s: &Session) -> Vec<(String, Mat)> {
s.doc.occurrences.iter().map(|o| (o.name.clone(), joints::occurrence_world(&s.doc, o.id))).collect()
}
/// The occurrences placed differently `after` than `before` ([`positions`] of one design, so in
/// the same order even where two share a name).
pub(super) fn moved<'a>(before: &'a [(String, Mat)], after: &'a [(String, Mat)]) -> impl Iterator<Item = &'a (String, Mat)> {
after.iter().zip(before).filter(|((_, a), (_, b))| a.iter().flatten().zip(b.iter().flatten()).any(|(x, y)| (x - y).abs() > 1e-9)).map(|(a, _)| a)
}
fn pose_json(m: &Mat) -> Value {
json!({"origin": [m[3][0], m[3][1], m[3][2]], "x": [m[0][0], m[0][1], m[0][2]], "y": [m[1][0], m[1][1], m[1][2]], "z": [m[2][0], m[2][1], m[2][2]]})
}
fn play(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "motion.play";
let i = study_index(s, p, cmd)?;
let m = s.doc.assembly.studies.get(i).cloned().ok_or_else(|| bad(cmd, "study"))?;
let step = p.get("step").and_then(Value::as_f64).filter(|x| x.is_finite()).ok_or_else(|| bad(cmd, "`step` must be a number"))?;
let want = values_at(s, &m, step.clamp(0.0, f64::from(m.steps)));
let before = positions(s);
let (mut out, sol) = drive_with_contact(s, &want, &keyed(&m))?;
let after = positions(s);
let moved: Vec<&(String, Mat)> = moved(&before, &after).collect();
out["moved"] = json!(!moved.is_empty());
let shown: Vec<&(String, Mat)> = if bool_(p, "quiet").unwrap_or(false) { moved } else { after.iter().collect() };
out["positions"] = shown.iter().map(|(n, t)| json!({"occurrence": n, "pose": pose_json(t)})).collect();
let joints: BTreeSet<u64> = m.keys.iter().map(|k| k.joint).collect();
out["unmoved"] = joints
.iter()
.filter_map(|id| {
let (why, _) = cannot_move(s, &sol, *id)?;
let name = s.doc.assembly.joints.iter().find(|j| j.id == *id).map(|j| j.name.clone());
Some(json!({"joint": id, "name": name, "reason": why}))
})
.collect();
Ok(out)
}
fn export(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "motion.export";
let i = study_index(s, p, cmd)?;
let m = s.doc.assembly.studies.get(i).cloned().ok_or_else(|| bad(cmd, "study"))?;
let samples = match p.get("samples") {
Some(v) => v.as_f64().filter(|n| n.is_finite() && *n >= 2.0 && *n <= 10_000.0).ok_or_else(|| bad(cmd, "`samples` must be 2…10000"))? as usize,
None => (m.steps as usize + 1).min(10_000),
};
// On a copy: the design stays as it is.
let mut t = s.scratch();
let mut rows = Vec::new();
for k in 0..samples {
let step = f64::from(m.steps) * k as f64 / (samples - 1) as f64;
let want = values_at(&t, &m, step);
let (r, _) = drive_with_contact(&mut t, &want, &keyed(&m))?;
rows.push(json!({
"step": step,
"stopped_by_contact": r["stopped_by_contact"],
"stopped_by_loop": r["stopped_by_loop"],
"positions": positions(&t).iter().map(|(n, x)| json!({"occurrence": n, "pose": pose_json(x)})).collect::<Vec<_>>(),
}));
}
let out = json!({"study": m.name, "steps": m.steps, "samples": rows});
if let Some(path) = str_(p, "path").filter(|x| !x.trim().is_empty() && x.len() < 4096) {
let text = if path.to_ascii_lowercase().ends_with(".csv") {
let mut c = String::from("step,occurrence,x,y,z,xx,xy,xz,yx,yy,yz,zx,zy,zz\n");
for r in &rows {
for p in r["positions"].as_array().into_iter().flatten() {
let pose = &p["pose"];
let n = |k: &str, i: usize| pose[k][i].as_f64().unwrap_or(0.0);
c += &format!(
"{},{},{},{},{},{},{},{},{},{},{},{},{},{}\n",
r["step"],
p["occurrence"].as_str().unwrap_or_default().replace(',', " "),
n("origin", 0),
n("origin", 1),
n("origin", 2),
n("x", 0),
n("x", 1),
n("x", 2),
n("y", 0),
n("y", 1),
n("y", 2),
n("z", 0),
n("z", 1),
n("z", 2)
);
}
}
c
} else {
serde_json::to_string_pretty(&out).unwrap_or_default()
};
solidcraft_io::vfs::check(path).map_err(|e| EngineError::Other(e.to_string()))?;
solidcraft_io::write_atomic(std::path::Path::new(path), text.as_bytes(), false).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
return Ok(json!({"path": path, "samples": rows.len()}));
}
Ok(out)
}
/// Most samples of a motion trace: each is a solve, and a kept trace keeps every one as a fit
/// point of its spline.
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>)>>;
/// 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<_>>()?
}
Some(Value::Array(_)) => return Err(bad(cmd, "an occurrence path has at most 64 ids")),
v => {
let occ = occurrence_id(s, v, cmd)?;
match s.doc.occurrences.iter().find(|o| o.id == occ) {
Some(o) => s.doc.first_path(o.parent).into_iter().chain([o.id]).collect(),
None => Vec::new(),
}
}
};
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| 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();
let mut stop = (Value::Null, Value::Null, Value::Null);
for k in 0..n {
let want = plan(&t, k, n);
let (r, _) = drive_with_contact(&mut t, &want, &given)?;
points.push(solidcraft_doc::apply_point(&world(&t)?, local));
if r["stopped_by_loop"].is_object() || r["stopped_by_contact"].is_object() {
stop = (json!(k), r["stopped_by_loop"].clone(), r["stopped_by_contact"].clone());
break;
}
}
let name = s.doc.occurrences.iter().find(|o| Some(&o.id) == path.last()).map_or_else(|| "root".to_string(), |o| o.name.clone());
let out = json!({"occurrence": name, "samples": points.len(), "stopped_at": stop.0, "stopped_by_loop": stop.1, "stopped_by_contact": stop.2});
Ok((points, out))
}
/// `samples`, 2…`max`, or `default` without it.
fn sample_count(p: &Value, default: usize, max: usize, cmd: &str) -> Result<usize> {
match p.get("samples") {
Some(v) => v
.as_f64()
.filter(|n| n.is_finite() && *n >= 2.0 && *n <= max as f64)
.map(|n| n.round() as usize)
.ok_or_else(|| bad(cmd, format!("`samples` must be 2…{max}"))),
None => Ok(default.max(2)),
}
}
/// The motion a trace or an animation samples: a study played from start to end, or one joint
/// value driven from `from` to `to`. Returns the default sample count (the study's steps + 1, or
/// 100), the values driven and what they are at sample `k` of `n`.
fn motion_plan(s: &Session, p: &Value, cmd: &str) -> Result<(usize, BTreeSet<(u64, usize)>, Plan)> {
Ok(match (p.get("study"), p.get("joint")) {
(Some(_), Some(_)) => return Err(bad(cmd, "give `study` or `joint`, not both")),
(Some(_), None) => {
let m = s.doc.assembly.studies.get(study_index(s, p, cmd)?).cloned().ok_or_else(|| bad(cmd, "study"))?;
let n = (m.steps as usize).saturating_add(1);
(n, keyed(&m), Box::new(move |t, k, n| values_at(t, &m, f64::from(m.steps) * k as f64 / (n - 1) as f64)))
}
(None, Some(j)) => {
let id = joint_id(s, Some(j), cmd)?;
let kind = s.doc.assembly.joints.iter().find(|j| j.id == id).map(|j| j.kind).ok_or_else(|| bad(cmd, "joint"))?;
let dofs = kind.dofs().len();
if dofs == 0 {
return Err(bad(cmd, "a rigid joint does not move"));
}
let index = match p.get("index") {
None => 0,
Some(v) => v
.as_u64()
.and_then(|i| usize::try_from(i).ok())
.filter(|i| *i < dofs)
.ok_or_else(|| bad(cmd, format!("`index` must be below {dofs}, the joint's value count")))?,
};
let end = |key: &str| match p.get(key) {
Some(v) => value(s, v, kind.is_angle(index), cmd),
None => Err(bad(cmd, format!("give `from` and `to`: the joint's values the motion runs between (no `{key}`)"))),
};
let (from, to) = (end("from")?, end("to")?);
let plan: Plan = Box::new(move |t, k, n| {
let mut q = t.doc.assembly.joints.iter().find(|j| j.id == id).map(|j| j.values.clone()).unwrap_or_default();
q.resize(q.len().max(dofs), 0.0);
if let Some(x) = q.get_mut(index) {
*x = from + (to - from) * k as f64 / (n - 1) as f64;
}
vec![(id, q)]
});
(100, BTreeSet::from([(id, index)]), plan)
}
(None, None) => return Err(bad(cmd, "give a `study`, or a `joint` with `from` and `to`")),
})
}
fn trace(s: &mut Session, p: &Value) -> Result<Value> {
let (points, mut out) = sample_trace(s, p, "motion.trace")?;
out["points"] = points.iter().map(|v| json!([v.x, v.y, v.z])).collect();
Ok(out)
}
/// The trace as a 3D sketch spline, in one undo step: a new sketch of the root component (the
/// world, which no joint moves) with 3D Sketch on and the spline through the trace's points.
fn keep_trace(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "motion.trace.keep";
let (mut points, mut out) = sample_trace(s, p, cmd)?;
// Where the motion stood still (at a limit or a stop), one fit point.
points.dedup_by(|a, b| a.dist(*b) < 1e-6);
// A full cycle comes back to where it started (to the loop solver's tolerance): the spline
// closes there, smoothly, through the start itself (and a sample that would double it goes).
if points.len() > 3
&& let (Some(&first), Some(&last)) = (points.first(), points.last())
&& first.dist(last) < 1e-4
{
points.pop();
if points.last().is_some_and(|p| p.dist(first) < 1e-6) {
points.pop();
}
points.push(first);
}
if points.len() < 2 {
return Err(bad(cmd, "the point does not move: there is no trace to keep"));
}
let name = match str_(p, "name").map(str::trim).filter(|n| !n.is_empty()) {
Some(n) => n.to_string(),
None => (1..=s.doc.features.len() + 1).map(|k| format!("Motion Trace{k}")).find(|n| s.doc.find_feature(n).is_none()).unwrap_or_default(),
};
let pts: Vec<Value> = points.iter().map(|v| json!([v.x, v.y, v.z])).collect();
// Made in the root, whatever is being edited, which is then as it was (also on an error).
let before = (s.active_sketch, s.active_component, s.active_occurrence_path.take());
s.active_component = 0;
let made = (|| {
let run = |s: &mut Session, id: &str, q: Value| super::find_command(id).ok_or_else(|| bad(cmd, id.to_string()))?.run_nested(s, &q);
let made = run(s, "sketch.create", json!({"plane": "XY", "project_edges": false, "name": name}))?;
let sketch = made.get("sketch").cloned().unwrap_or(Value::Null);
run(s, "sketch.options", json!({"sketch": sketch, "sketch_3d": true}))?;
let spline = run(s, "sketch.spline3d", json!({"sketch": sketch, "points": pts}))?;
Ok::<_, EngineError>((made, spline))
})();
(s.active_sketch, s.active_component, s.active_occurrence_path) = before;
s.revision += 1;
let (made, spline) = made?;
let sketch = made.get("sketch").cloned().unwrap_or(Value::Null);
out["sketch"] = sketch;
out["name"] = made.get("name").cloned().unwrap_or(Value::Null);
out["curves"] = spline.get("curves").cloned().unwrap_or(Value::Null);
out["points"] = json!(points.len());
Ok(out)
}
fn list_studies(s: &mut Session, _p: &Value) -> Result<Value> {
Ok(json!({"studies": s.doc.assembly.studies}))
}
fn delete_study(s: &mut Session, p: &Value) -> Result<Value> {
let i = study_index(s, p, "motion.delete")?;
let m = s.doc_mut().assembly.studies.remove(i);
Ok(json!({"deleted": m.name}))
}
// ---- Exploded views ----
fn view_index(s: &Session, p: &Value, cmd: &str) -> Result<usize> {
let key = match p.get("view") {
Some(Value::Number(n)) => n.to_string(),
Some(Value::String(x)) => x.clone(),
_ => return Err(bad(cmd, "`view` must be an id or a name")),
};
s.doc.assembly.exploded.iter().position(|e| e.id.to_string() == key || e.name == key).ok_or_else(|| bad(cmd, format!("no exploded view `{key}`")))
}
/// World centre of each top-level occurrence's bodies.
fn centres(s: &Session) -> Vec<(u64, Vec3)> {
let bodies = placed(s);
let mut out = Vec::new();
for o in s.doc.occurrences.iter().filter(|o| o.parent == 0) {
let (mut lo, mut hi) = (Vec3::new(f64::MAX, f64::MAX, f64::MAX), Vec3::new(f64::MIN, f64::MIN, f64::MIN));
for (_, b) in bodies.iter().filter(|(path, _)| path.first() == Some(&o.id)) {
if let Ok(m) = b.tessellate((b.size() * 1e-2).max(1e-2)) {
let bb = m.bounds();
lo = Vec3::new(lo.x.min(bb.min.x), lo.y.min(bb.min.y), lo.z.min(bb.min.z));
hi = Vec3::new(hi.x.max(bb.max.x), hi.y.max(bb.max.y), hi.z.max(bb.max.z));
}
}
if lo.x <= hi.x {
out.push((o.id, (lo + hi) * 0.5));
}
}
out
}
fn explode_create(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "explode.create";
let moves: Vec<(u64, Vec3)> = match p.get("moves") {
Some(Value::Array(list)) => {
if list.len() > MAX_ITEMS {
return Err(bad(cmd, "too many moves"));
}
list.iter()
.map(|m| {
let o = occurrence_id(s, m.get("occurrence"), cmd)?;
let t = m
.get("translate")
.and_then(vec3)
.filter(|v| v.len() < 1e7)
.ok_or_else(|| bad(cmd, "each move needs `translate`: [x, y, z]"))?;
Ok((o, t))
})
.collect::<Result<_>>()?
}
Some(_) => return Err(bad(cmd, "`moves` must be a list")),
None => {
let scale = match p.get("scale") {
Some(v) => v.as_f64().filter(|x| x.is_finite() && x.abs() <= 100.0).ok_or_else(|| bad(cmd, "`scale` must be a number up to 100"))?,
None => 1.0,
};
let c = centres(s);
if c.len() < 2 {
return Err(bad(cmd, "an exploded view needs two or more occurrences"));
}
let mid = c.iter().fold(Vec3::ZERO, |a, (_, p)| a + *p) * (1.0 / c.len() as f64);
c.iter().map(|(o, p)| (*o, (*p - mid) * scale)).collect()
}
};
if s.doc.assembly.exploded.len() >= MAX_ITEMS {
return Err(bad(cmd, "too many exploded views"));
}
let id = next_id(s);
let name = str_(p, "name").map(str::to_string).unwrap_or_else(|| format!("Exploded View{}", s.doc.assembly.exploded.len() + 1));
s.doc_mut().assembly.exploded.push(ExplodedView { id, name: name.clone(), moves: moves.clone() });
Ok(json!({"view": id, "name": name, "moves": moves.iter().map(|(o, t)| json!({"occurrence": o, "translate": t})).collect::<Vec<_>>()}))
}
fn explode_show(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "explode.show";
if p.get("view").is_none_or(|v| v.is_null() || v.as_str().is_some_and(|x| x.eq_ignore_ascii_case("none"))) {
s.pending_moves.clear();
s.revision += 1;
return Ok(json!({"view": null}));
}
let i = view_index(s, p, cmd)?;
let v = s.doc.assembly.exploded.get(i).cloned().ok_or_else(|| bad(cmd, "view"))?;
s.pending_moves.clear();
for (o, t) in &v.moves {
let Some(occ) = s.doc.occurrences.iter().find(|x| x.id == *o) else { continue };
let mut m = occ.transform;
if let Some(r) = m.get_mut(3) {
r[0] += t.x;
r[1] += t.y;
r[2] += t.z;
}
s.pending_moves.insert(*o, m);
}
s.revision += 1;
Ok(json!({"view": v.id, "name": v.name}))
}
fn explode_list(s: &mut Session, _p: &Value) -> Result<Value> {
Ok(json!({"views": s.doc.assembly.exploded}))
}
fn explode_delete(s: &mut Session, p: &Value) -> Result<Value> {
let i = view_index(s, p, "explode.delete")?;
let v = s.doc_mut().assembly.exploded.remove(i);
Ok(json!({"deleted": v.name}))
}
/// World position of a point of an occurrence (tests).
#[cfg(test)]
pub fn world_point(s: &Session, occurrence: u64, p: Vec3) -> Vec3 {
solidcraft_doc::apply_point(&joints::occurrence_world(&s.doc, occurrence), p)
}
#[cfg(test)]
#[path = "motion_tests.rs"]
mod tests;