Files
solidcraft-upstream/crates/engine/src/cmd/motion.rs
T
Mateusz Sych 94faf9ce1b Close kinematic loops: driven joints move four-bars and sliders (#1141)
Driven joints now solve closed kinematic loops, so a four-bar or slider-crank moves as a whole when one joint is driven.
2026-10-14 13:40:16 -04:00

764 lines
31 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::{joint_id, occurrence_id, resolve_holding, value};
use crate::params::{bad, bool_, str_, vec3};
use crate::{EngineError, Result, Session};
#[path = "contact_sweep.rs"]
mod contact_sweep;
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) → positions"),
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.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.
fn drive_with_contact(s: &mut Session, want: &[(u64, Vec<f64>)], given: &BTreeSet<(u64, usize)>) -> Result<Value> {
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": s.doc.assembly.joints.iter().find(|j| j.id == *id).map(|j| j.values.clone())}))
.collect();
Ok(json!({"values": values, "stopped_by_contact": stopped, "stopped_by_loop": stopped_by_loop, "conflicts": sol.conflicts.len()}))
}
/// 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();
drive_with_contact(s, &[(id, q)], &given)
}
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> {
let key = match p.get("study") {
Some(Value::Number(n)) => n.to_string(),
Some(Value::String(x)) => x.clone(),
_ => 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}`")))
}
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).
fn positions(s: &Session) -> Vec<(String, Mat)> {
s.doc.occurrences.iter().map(|o| (o.name.clone(), joints::occurrence_world(&s.doc, o.id))).collect()
}
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 mut out = drive_with_contact(s, &want, &keyed(&m))?;
out["positions"] = positions(s).iter().map(|(n, t)| json!({"occurrence": n, "pose": pose_json(t)})).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)
}
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;