Joint drives say when they move nothing; drive values in degrees; quiet replies (#1304)

joint.drive answers moved, and a reason (with unconnected) when the joint can move nothing; motion.play answers moved and unmoved; joint.list answers floating chains. joint.drive values come back in joint.list's shape (deg/mm). quiet: true trims joint answers and motion.play poses. A single value keeps a joint's other values. Drive Joints shows the floating case (drive.unconnected, all locales).
This commit is contained in:
Mateusz Sych authored and GitHub committed 2026-10-15 02:12:58 -04:00
1 parent fd19efc6fc
commit 7195e323bf
15 files changed
+326 -79

No files matched your search

+38 -12
View File
@@ -10,7 +10,7 @@ use solidcraft_geom::Vec3;
use solidcraft_kernel::Body;
use super::CommandSpec;
use super::joints::{joint_id, occurrence_id, resolve_holding, value};
use super::joints::{cannot_move, joint_id, named_values, occurrence_id, resolve_holding, value};
use crate::params::{bad, bool_, str_, vec3};
use crate::{EngineError, Result, Session};
@@ -37,8 +37,12 @@ pub static COMMANDS: &[CommandSpec] = &[
"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.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)"),
@@ -258,8 +262,9 @@ fn closest(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> Vec3 {
/// 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> {
/// `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();
@@ -347,15 +352,16 @@ fn drive_with_contact(s: &mut Session, want: &[(u64, Vec<f64>)], given: &BTreeSe
}
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())}))
.map(|(id, _)| json!({"joint": id, "values": s.doc.assembly.joints.iter().find(|j| j.id == *id).map(|j| named_values(j, &j.values))}))
.collect();
Ok(json!({"values": values, "stopped_by_contact": stopped, "stopped_by_loop": stopped_by_loop, "conflicts": sol.conflicts.len()}))
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();
drive_with_contact(s, &[(id, q)], &given)
Ok(drive_with_contact(s, &[(id, q)], &given)?.0)
}
fn enable_sets(s: &mut Session, p: &Value) -> Result<Value> {
@@ -556,10 +562,16 @@ fn keyed(m: &MotionStudy) -> BTreeSet<(u64, usize)> {
}
/// Where each occurrence is: (name, world transform).
fn positions(s: &Session) -> Vec<(String, Mat)> {
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]]})
}
@@ -570,8 +582,22 @@ fn play(s: &mut Session, p: &Value) -> Result<Value> {
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();
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)
}
@@ -589,7 +615,7 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
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))?;
let (r, _) = drive_with_contact(&mut t, &want, &keyed(&m))?;
rows.push(json!({
"step": step,
"stopped_by_contact": r["stopped_by_contact"],