diff --git a/crates/doc/src/joints.rs b/crates/doc/src/joints.rs index c2e78c94..8bc4703f 100644 --- a/crates/doc/src/joints.rs +++ b/crates/doc/src/joints.rs @@ -544,35 +544,7 @@ fn close_loops(tree: &Tree, closing: &[u64], asm: &Assembly, held: &Held, vals: return (Values::new(), stuck); } let linked: BTreeSet<(u64, usize)> = asm.links.iter().map(|l| (l.b, l.ib)).collect(); - // Each loop: its closing joint and the joints on the tree paths between its sides; loops that - // share a joint (or a value feeding them through links) are solved together. - let mut groups: Vec<(Vec, BTreeSet, BTreeSet<(u64, usize)>)> = Vec::new(); - for id in closing { - let Some(j) = tree.joints.get(id) else { continue }; - // A joint between an occurrence and itself (only a hand-edited file has one) is only checked. - if j.a.occurrence == j.b.occurrence { - continue; - } - let side = |occ: u64| -> BTreeSet { tree.path(occ).1.iter().map(|(k, _)| *k).collect() }; - let mut around: BTreeSet = side(j.a.occurrence).symmetric_difference(&side(j.b.occurrence)).copied().collect(); - around.insert(*id); - // The values the loop depends on: its joints' and those its links read. Loops tie together - // through a shared value only (a rigid joint on both paths has none; two values of one - // joint can feed two independent loops). - let mut touches = link_chains(asm, tree, &around, &linked).0; - touches.extend(around.iter().filter_map(|id| tree.joints.get(id)).flat_map(|j| (0..j.kind.dofs().len()).map(|i| (j.id, i)))); - let mut members = vec![*id]; - groups.retain(|(c, a, t)| { - if t.is_disjoint(&touches) { - return true; - } - members.extend(c.iter().copied()); - around.extend(a.iter().copied()); - touches.extend(t.iter().copied()); - false - }); - groups.push((members, around, touches)); - } + let groups = loop_groups(tree, closing, asm, &linked); let spent = std::cell::Cell::new(0usize); for (group, around, touches) in &groups { let moves = held_moves(asm, held, touches, vals); @@ -599,6 +571,225 @@ fn close_loops(tree: &Tree, closing: &[u64], asm: &Assembly, held: &Held, vals: (moved, stuck) } +/// A group of loops solved together: its closing joints, the joints around them, and the values +/// it touches. +type Group = (Vec, BTreeSet, BTreeSet<(u64, usize)>); + +/// The loops' groups. Each loop: its closing joint and the joints on the tree paths between its +/// sides; loops that share a value (of a joint, or one feeding them through links) are solved +/// together. +fn loop_groups(tree: &Tree, closing: &[u64], asm: &Assembly, linked: &BTreeSet<(u64, usize)>) -> Vec { + let mut groups: Vec = Vec::new(); + for id in closing { + let Some(j) = tree.joints.get(id) else { continue }; + // A joint between an occurrence and itself (only a hand-edited file has one) is only checked. + if j.a.occurrence == j.b.occurrence { + continue; + } + let side = |occ: u64| -> BTreeSet { tree.path(occ).1.iter().map(|(k, _)| *k).collect() }; + let mut around: BTreeSet = side(j.a.occurrence).symmetric_difference(&side(j.b.occurrence)).copied().collect(); + around.insert(*id); + // The values the loop depends on: its joints' and those its links read. Loops tie together + // through a shared value only (a rigid joint on both paths has none; two values of one + // joint can feed two independent loops). + let mut touches = link_chains(asm, tree, &around, linked).0; + touches.extend(around.iter().filter_map(|id| tree.joints.get(id)).flat_map(|j| (0..j.kind.dofs().len()).map(|i| (j.id, i)))); + let mut members = vec![*id]; + groups.retain(|(c, a, t)| { + if t.is_disjoint(&touches) { + return true; + } + members.extend(c.iter().copied()); + around.extend(a.iter().copied()); + touches.extend(t.iter().copied()); + false + }); + groups.push((members, around, touches)); + } + groups +} + +/// The values a solve of the loops `around` moves: every value of their joints but the `held` +/// ones and motion-link targets, and the values the targets' link chains start from. +fn unknowns(tree: &Tree, around: &BTreeSet, asm: &Assembly, linked: &BTreeSet<(u64, usize)>, held: &Held) -> Vec<(u64, usize)> { + let mut vars: Vec<(u64, usize)> = Vec::new(); + for j in around.iter().filter_map(|id| tree.joints.get(id)) { + vars.extend((0..j.kind.dofs().len()).map(|i| (j.id, i)).filter(|v| !linked.contains(v) && !held.contains_key(v))); + } + for v in link_chains(asm, tree, around, linked).1 { + if !held.contains_key(&v) && !vars.contains(&v) { + vars.push(v); + } + } + vars +} + +/// The loops' size: the diagonal of the box around their joints' A frames, 1 mm to 1 km. Points +/// that far apart weigh turns like moves. +fn loop_scale(tree: &Tree, around: &BTreeSet, vals: &Values) -> f64 { + let (mut lo, mut hi) = (Vec3::new(f64::MAX, f64::MAX, f64::MAX), Vec3::new(f64::MIN, f64::MIN, f64::MIN)); + for id in around { + let (Some(j), Some((fa, _))) = (tree.joints.get(id), tree.frames.get(id)) else { continue }; + let Some(t) = tree.world(j.a.occurrence, vals) else { continue }; + let p = apply_point(&mat_mul(&t, fa), Vec3::ZERO); + lo = Vec3::new(lo.x.min(p.x), lo.y.min(p.y), lo.z.min(p.z)); + hi = Vec3::new(hi.x.max(p.x), hi.y.max(p.y), hi.z.max(p.z)); + } + if lo.x <= hi.x { (hi - lo).len().clamp(1.0, 1e6) } else { 1.0 } +} + +/// How far each closing joint is from meeting: three points of B's joint frame, `scale` apart, +/// where the joint wants them against where the tree puts them (appended to `out`). +fn closure_gaps(tree: &Tree, closing: &[u64], vals: &Values, scale: f64, out: &mut Vec) -> Option<()> { + for id in closing { + let (j, (_, fb)) = (tree.joints.get(id)?, tree.frames.get(id)?); + let tb = tree.world(j.b.occurrence, vals)?; + let want = tree.step(*id, true, &tree.world(j.a.occurrence, vals)?, vals)?; + for p in [Vec3::ZERO, Vec3::X * scale, Vec3::Y * scale] { + let at = apply_point(fb, p); + let d = apply_point(&want, at) - apply_point(&tb, at); + out.extend([d.x, d.y, d.z]); + } + } + Some(()) +} + +/// Occurrences whose degrees of freedom are worked out through their loops, at most; the rest +/// keep the tree's count. +const MAX_DOF_OCCURRENCES: usize = 256; + +/// The degrees of freedom of the occurrences loops constrain: how many independent ways each can +/// still move with every loop kept closed, the rank of its placement over the joint values once +/// the loops' closure is taken out (rank [J_loops; J_placement] - rank J_loops). An occurrence's +/// values are those on its tree path and the unknowns of the loops on that path. The tree's +/// count stands where a loop group is over the solver's caps or the work runs out. +fn loop_dof(tree: &Tree, closing: &[u64], asm: &Assembly, vals: &Values, dof: &mut BTreeMap) { + if closing.len() > MAX_LOOPS { + return; + } + let linked: BTreeSet<(u64, usize)> = asm.links.iter().map(|l| (l.b, l.ib)).collect(); + let groups = loop_groups(tree, closing, asm, &linked); + // The joints loops depend on: those around them, those whose values they read, and those a + // motion link carries any of these values into. + let mut relevant: BTreeSet = groups.iter().flat_map(|(_, a, t)| a.iter().copied().chain(t.iter().map(|(j, _)| *j))).collect(); + for _ in 0..MAX_LINK_PASSES { + let more: Vec = asm.links.iter().filter(|l| relevant.contains(&l.a) && !relevant.contains(&l.b)).map(|l| l.b).collect(); + if more.is_empty() { + break; + } + relevant.extend(more); + } + // The occurrences a loop may hold or move: placed by such a joint, or below one that is + // (worked out down the tree, parents first). The others keep the tree's count. + let mut candidates: BTreeSet = BTreeSet::new(); + for occ in &tree.order { + if let Some(&(from, joint, _)) = tree.parent.get(occ) + && (relevant.contains(&joint) || candidates.contains(&from)) + { + candidates.insert(*occ); + } + } + let (mut spent, mut counted) = (0usize, 0usize); + for occ in tree.order.iter().filter(|o| candidates.contains(o)) { + let path: Vec = tree.path(*occ).1.iter().map(|(k, _)| *k).collect(); + let (mut values, mut have): (Vec<(u64, usize)>, BTreeSet<(u64, usize)>) = (Vec::new(), BTreeSet::new()); + let mut add = |values: &mut Vec<(u64, usize)>, v: (u64, usize)| { + if have.insert(v) { + values.push(v); + } + }; + for j in path.iter().filter_map(|k| tree.joints.get(k)) { + for v in (0..j.kind.dofs().len()).map(|i| (j.id, i)).filter(|v| !linked.contains(v)) { + add(&mut values, v); + } + if values.len() > MAX_LOOP_VALUES { + break; + } + } + if values.len() > MAX_LOOP_VALUES { + continue; + } + // A value on the path a motion link sets moves with the values its link chain starts from. + let on_path: BTreeSet = path.iter().copied().collect(); + for v in link_chains(asm, tree, &on_path, &linked).1 { + add(&mut values, v); + } + // The loops on the path, and every loop whose values those reach (a linked source may be + // held by a loop of its own), each once. + let (mut closers, mut around) = (Vec::new(), BTreeSet::new()); + let mut taken: BTreeSet = BTreeSet::new(); + loop { + let more: Vec = groups + .iter() + .enumerate() + .filter(|(g, _)| !taken.contains(g)) + .filter(|(_, (_, a, t))| path.iter().any(|k| a.contains(k)) || t.iter().any(|v| values.contains(v))) + .map(|(g, _)| g) + .collect(); + if more.is_empty() { + break; + } + for (c, a, _) in more.iter().filter_map(|g| groups.get(*g)) { + closers.extend(c.iter().copied()); + around.extend(a.iter().copied()); + for v in unknowns(tree, a, asm, &linked, &Held::new()) { + add(&mut values, v); + } + } + taken.extend(more); + } + // No loop reaches this occurrence's values (not even through a motion link it drives): + // the tree's count stands. + if taken.is_empty() { + continue; + } + counted += 1; + if counted > MAX_DOF_OCCURRENCES || spent > MAX_LOOP_WORK { + return; + } + if values.len() > MAX_LOOP_VALUES { + continue; + } + let scale = loop_scale(tree, &around, vals); + let mut work = vals.clone(); + // The loops' gaps, then three points of the occurrence `scale` apart. + let mut f = |x: &[f64]| -> Option> { + spent += 1; + for ((id, i), v) in values.iter().zip(x) { + if let Some(slot) = work.get_mut(id).and_then(|q| q.get_mut(*i)) { + *slot = *v; + } + } + apply_links(asm, &mut work); + let mut out = Vec::with_capacity(closers.len() * 9 + 9); + closure_gaps(tree, &closers, &work, scale, &mut out)?; + let t = tree.world(*occ, &work)?; + for p in [Vec3::ZERO, Vec3::X * scale, Vec3::Y * scale] { + let q = apply_point(&t, p); + out.extend([q.x, q.y, q.z]); + } + out.iter().all(|v| v.is_finite()).then_some(out) + }; + let x0: Vec = values.iter().map(|(id, i)| vals.get(id).and_then(|q| q.get(*i)).copied().unwrap_or(0.0)).collect(); + let (n, gaps) = (values.len(), closers.len() * 9); + let Some(jac) = jacobian(&mut f, &x0, gaps + 9) else { continue }; + let rank = |rows: usize| { + let mut a = linalg::Mat::zeros(rows, n); + let mut big = 0.0f64; + for r in 0..rows { + for c in 0..n { + let v = jac.get(r * n + c).copied().unwrap_or(0.0); + big = big.max(v.abs()); + a.set(r, c, v); + } + } + linalg::rref(&mut a, 1e-6 * big.max(1.0)).len() + }; + let free = rank(gaps + 9).saturating_sub(rank(gaps)); + dof.insert(*occ, free.min(6)); + } +} + /// Close one group of loops: move the values of the joints `around` them (all but the `held` /// values, motion-link targets and rigid joints, within their limits; a linked value moves /// through the start of its link chain) until every loop-closing joint meets. A loop open at the @@ -619,16 +810,7 @@ fn close_group( spent: &std::cell::Cell, vals: &mut Values, ) -> Option> { - let starts = link_chains(asm, tree, around, linked).1; - let mut vars: Vec<(u64, usize)> = Vec::new(); - for j in around.iter().filter_map(|id| tree.joints.get(id)) { - vars.extend((0..j.kind.dofs().len()).map(|i| (j.id, i)).filter(|v| !linked.contains(v) && !held.contains_key(v))); - } - for v in starts { - if !held.contains_key(&v) && !vars.contains(&v) { - vars.push(v); - } - } + let vars = unknowns(tree, around, asm, linked, held); if vars.len() > MAX_LOOP_VALUES { return Some(BTreeMap::new()); } @@ -639,16 +821,7 @@ fn close_group( None => (f64::NEG_INFINITY, f64::INFINITY), }) .collect(); - // Turns weigh like moves through points a loop's size apart. - let (mut lo, mut hi) = (Vec3::new(f64::MAX, f64::MAX, f64::MAX), Vec3::new(f64::MIN, f64::MIN, f64::MIN)); - for id in around { - let (Some(j), Some((fa, _))) = (tree.joints.get(id), tree.frames.get(id)) else { continue }; - let Some(t) = tree.world(j.a.occurrence, vals) else { continue }; - let p = apply_point(&mat_mul(&t, fa), Vec3::ZERO); - lo = Vec3::new(lo.x.min(p.x), lo.y.min(p.y), lo.z.min(p.z)); - hi = Vec3::new(hi.x.max(p.x), hi.y.max(p.y), hi.z.max(p.z)); - } - let scale = if lo.x <= hi.x { (hi - lo).len().clamp(1.0, 1e6) } else { 1.0 }; + let scale = loop_scale(tree, around, vals); // Closer than the conflict check needs: 1e-7 mm, and turns of 1e-10 rad across the loop. let tol = (1e-10 * scale).min(1e-7); // The values with the held ones `t` of the way along (and the unknowns at `x`). @@ -699,16 +872,7 @@ fn close_group( } place(&mut work, x, t); let mut out = Vec::with_capacity(closing.len() * 9); - for id in closing { - let (j, (_, fb)) = (tree.joints.get(id)?, tree.frames.get(id)?); - let tb = tree.world(j.b.occurrence, &work)?; - let want = tree.step(*id, true, &tree.world(j.a.occurrence, &work)?, &work)?; - for p in [Vec3::ZERO, Vec3::X * scale, Vec3::Y * scale] { - let at = apply_point(fb, p); - let d = apply_point(&want, at) - apply_point(&tb, at); - out.extend([d.x, d.y, d.z]); - } - } + closure_gaps(tree, closing, &work, scale, &mut out)?; out.iter().all(|v| v.is_finite()).then_some(out) }; // Already closed, with no held value moving. (A moving one is followed all the way: a @@ -764,8 +928,28 @@ fn largest(r: &[f64]) -> f64 { /// Levenberg–Marquardt: move `x` (kept within `bounds`) until every value of `f` is within /// `tol` of zero. False when it can't get there. -fn least_squares(f: &mut dyn FnMut(&[f64]) -> Option>, x: &mut Vec, bounds: &[(f64, f64)], tol: f64) -> bool { +/// The Jacobian of `f` (`m` values) at `x` by central differences, row-major. +fn jacobian(f: &mut dyn FnMut(&[f64]) -> Option>, x: &[f64], m: usize) -> Option> { const H: f64 = 1e-6; + let n = x.len(); + let mut jac = vec![0.0; m * n]; + for k in 0..n { + let (mut xp, mut xm) = (x.to_vec(), x.to_vec()); + if let (Some(p), Some(q)) = (xp.get_mut(k), xm.get_mut(k)) { + *p += H; + *q -= H; + } + let (rp, rm) = (f(&xp)?, f(&xm)?); + for (i, (a, b)) in rp.iter().zip(&rm).enumerate().take(m) { + if let Some(slot) = jac.get_mut(i * n + k) { + *slot = (a - b) / (2.0 * H); + } + } + } + Some(jac) +} + +fn least_squares(f: &mut dyn FnMut(&[f64]) -> Option>, x: &mut Vec, bounds: &[(f64, f64)], tol: f64) -> bool { let n = x.len(); let norm = |r: &[f64]| r.iter().map(|v| v * v).sum::(); let Some(mut r) = f(x) else { return false }; @@ -779,20 +963,7 @@ fn least_squares(f: &mut dyn FnMut(&[f64]) -> Option>, x: &mut Vec } // The Jacobian by central differences, then the normal equations JᵀJ δ = −Jᵀr. let m = r.len(); - let mut jac = vec![0.0; m * n]; - for k in 0..n { - let (mut xp, mut xm) = (x.clone(), x.clone()); - if let (Some(p), Some(q)) = (xp.get_mut(k), xm.get_mut(k)) { - *p += H; - *q -= H; - } - let (Some(rp), Some(rm)) = (f(&xp), f(&xm)) else { return false }; - for (i, (a, b)) in rp.iter().zip(&rm).enumerate() { - if let Some(slot) = jac.get_mut(i * n + k) { - *slot = (a - b) / (2.0 * H); - } - } - } + let Some(jac) = jacobian(f, x, m) else { return false }; let mut jtj = linalg::Mat::zeros(n, n); let mut g = vec![0.0; n]; for (i, ri) in r.iter().enumerate() { @@ -940,6 +1111,9 @@ pub fn solve_holding(doc: &Document, st: &ModelState, held: &Held) -> Solution { sol.conflicts.push((j.id, j.name.clone(), "the loop can't stay closed on the way to these values".into())); } } + if !closing.is_empty() { + loop_dof(&tree, &closing, asm, &vals, &mut dof); + } for o in &doc.occurrences { dof.entry(o.id).or_insert(6); } diff --git a/crates/engine/src/cmd/joints_tests.rs b/crates/engine/src/cmd/joints_tests.rs index e74cdc29..857b5c0d 100644 --- a/crates/engine/src/cmd/joints_tests.rs +++ b/crates/engine/src/cmd/joints_tests.rs @@ -467,10 +467,9 @@ fn values_left_out_of_a_drive_follow_the_loop() { assert!(near(place(&s, "Plate", Vec3::ZERO), Vec3::new(10.0, 10.0, 0.0)), "{:?}", place(&s, "Plate", Vec3::ZERO)); } -/// A slider-crank: crank 15 about the origin, rod 40, a piston sliding along X. The piston -/// sits at a·cos θ + √(b² − (a·sin θ)²). -#[test] -fn slider_crank_moves_the_piston() { +/// A slider-crank: crank 15 about the origin, rod 40, a piston sliding along X. Returns the crank +/// pin and the wrist pin. +fn slider_crank() -> (Session, Vec3, Vec3) { let mut s = Session::default(); let (o, p0) = (Vec3::ZERO, Vec3::new(0.0, 15.0, 0.0)); let s0 = Vec3::new((40.0f64 * 40.0 - 15.0 * 15.0).sqrt(), 0.0, 0.0); @@ -483,6 +482,13 @@ fn slider_crank_moves_the_piston() { pin(&mut s, "revolute", "Crank pin", "Crank", "Rod", p0, Vec3::Z); pin(&mut s, "revolute", "Wrist pin", "Rod", "Piston", s0, Vec3::Z); pin(&mut s, "slider", "Bore", "Ground", "Piston", s0, Vec3::X); + (s, p0, s0) +} + +/// The piston sits at a·cos θ + √(b² − (a·sin θ)²). +#[test] +fn slider_crank_moves_the_piston() { + let (mut s, p0, s0) = slider_crank(); for v in [40.0, 135.0, 270.0] { let r = run(&mut s, "joint.drive", json!({"joint": "Crank", "value": v})); assert_eq!(r["conflicts"].as_array().map(Vec::len), Some(0), "{v}°: {r}"); @@ -721,6 +727,71 @@ fn a_clamped_link_is_followed_step_by_step() { assert_eq!(r["conflicts"].as_array().map(Vec::len), Some(0), "{r}"); } +/// Each occurrence's degrees of freedom as `joint.list` reports them. +fn dof_of(s: &mut Session, component: &str) -> u64 { + let o = occ(s, component); + let l = run(s, "joint.list", json!({})); + l["dof"].as_array().into_iter().flatten().find(|d| d["occurrence"] == o).and_then(|d| d["dof"].as_u64()).unwrap_or(99) +} + +/// Loops take degrees of freedom away: a four-bar's moving parts have one between them (its +/// mobility), a part hinged to the coupler one more, a slider-crank one, a parallelogram one, and a +/// four-bar locked rigid none. +#[test] +fn loops_report_their_mobility() { + let (mut s, p0, q0) = four_bar(); + for (part, want) in [("Ground", 0), ("Crank", 1), ("Coupler", 1), ("Rocker", 1)] { + assert_eq!(dof_of(&mut s, part), want, "four-bar {part}"); + } + // Still one after a drive moved it. + run(&mut s, "joint.drive", json!({"joint": "Crank", "value": 70})); + assert_eq!(dof_of(&mut s, "Coupler"), 1); + // A flag hinged to the coupler half-way along: one more. + let mid = (p0 + q0) * 0.5; + part(&mut s, "Flag", [mid.x, mid.y, 5.0]); + run(&mut s, "joint.as_built", json!({"type": "revolute", "a": "Coupler", "b": "Flag", "name": "Flag hinge"})); + assert_eq!(dof_of(&mut s, "Flag"), 2); + // Still two when a rotor elsewhere turns the flag's hinge through a motion link. + part(&mut s, "Rotor", [100.0, 0.0, -5.0]); + pin(&mut s, "revolute", "Rotor", "Ground", "Rotor", Vec3::new(100.0, 0.0, 0.0), Vec3::Z); + run(&mut s, "joint.motion_link", json!({"a": "Rotor", "b": "Flag hinge", "ratio": 1})); + assert_eq!(dof_of(&mut s, "Flag"), 2); + // A rotor locked by a loop of its own leaves the flag only the mechanism's one. + run(&mut s, "joint.as_built", json!({"type": "rigid", "a": "Ground", "b": "Rotor", "name": "Rotor lock"})); + assert_eq!(dof_of(&mut s, "Flag"), 1); + // Locked: nothing moves. + let (mut s, _, _) = four_bar(); + run(&mut s, "joint.as_built", json!({"type": "rigid", "a": "Ground", "b": "Rocker", "name": "Lock"})); + for part in ["Crank", "Coupler", "Rocker"] { + assert_eq!(dof_of(&mut s, part), 0, "locked {part}"); + } + // A rotor whose turn a motion link carries into the locked crank can't turn either. + part(&mut s, "Rotor", [100.0, 0.0, -5.0]); + pin(&mut s, "revolute", "Rotor", "Ground", "Rotor", Vec3::new(100.0, 0.0, 0.0), Vec3::Z); + run(&mut s, "joint.motion_link", json!({"a": "Rotor", "b": "Crank", "ratio": 1})); + assert_eq!(dof_of(&mut s, "Rotor"), 0); + let (mut s, _, _) = slider_crank(); + for part in ["Crank", "Rod", "Piston"] { + assert_eq!(dof_of(&mut s, part), 1, "slider-crank {part}"); + } + // A parallelogram (crank and rocker 20, ground and coupler 45), away from its folded poses. + let mut s = Session::default(); + let (o2, o4) = (Vec3::ZERO, Vec3::new(45.0, 0.0, 0.0)); + let (p, q) = (Vec3::new(12.0, 16.0, 0.0), Vec3::new(57.0, 16.0, 0.0)); + for (name, c) in [("Ground", o2), ("Crank", p), ("Coupler", (p + q) * 0.5), ("Rocker", q)] { + part(&mut s, name, [c.x, c.y, -5.0]); + } + let g = occ(&s, "Ground"); + run(&mut s, "occurrence.ground", json!({"occurrence": g, "grounded": true})); + pin(&mut s, "revolute", "Crank", "Ground", "Crank", o2, Vec3::Z); + pin(&mut s, "revolute", "Crank pin", "Crank", "Coupler", p, Vec3::Z); + pin(&mut s, "revolute", "Rocker pin", "Coupler", "Rocker", q, Vec3::Z); + pin(&mut s, "revolute", "Rocker", "Ground", "Rocker", o4, Vec3::Z); + for part in ["Crank", "Coupler", "Rocker"] { + assert_eq!(dof_of(&mut s, part), 1, "parallelogram {part}"); + } +} + /// A design file path in a fresh temporary folder (removed by the caller). fn temp_design(tag: &str, ext: &str) -> (std::path::PathBuf, String) { let dir = std::env::temp_dir().join(format!("solidcraft-joint-visibility-{tag}-{}", std::process::id())); diff --git a/docs/book/src/guide/assemblies.md b/docs/book/src/guide/assemblies.md index 667efad7..5bd6ae8f 100644 --- a/docs/book/src/guide/assemblies.md +++ b/docs/book/src/guide/assemblies.md @@ -34,7 +34,9 @@ mechanism stays in the assembly mode it started in (an open four-bar does not fl crossed one). If the motion would take a loop past the point where it can still close, for example driving a rocker beyond its swing, the motion stops at the last position where the loop closes, and the drive reports `stopped_by_loop`. A loop that cannot close at all is listed -under `conflicts` and nothing in it moves. The Drive Joints dialog says when a drive stops short and +under `conflicts` and nothing in it moves. `joint.list` reports how many degrees of freedom each +part still has with its loops closed: the moving parts of a four-bar have one between them, and a +loop locked by a rigid joint has none. The Drive Joints dialog says when a drive stops short and why. While it previews, a row under the values gives the value the joint reached and the reason (the loop that can't close further, or the two parts a contact set keeps apart), and after OK the status line repeats it. The value box keeps what you typed.