Joints: a replaced motion link keeps the driven joint where it is; links count in dof (#2898)

Thanks @SS-EN.
This commit is contained in:
SS-EN
2026-10-19 17:29:40 -04:00
committed by GitHub
parent 4d99d79205
commit 98c30d28f9
4 changed files with 246 additions and 20 deletions
+84 -2
View File
@@ -846,7 +846,10 @@ pub struct Solution {
pub conflicts: Vec<(u64, String, String)>,
/// Joints that could not be applied (bad origin…): (joint id, name, why).
pub errors: Vec<(u64, String, String)>,
/// Remaining degrees of freedom per occurrence (6 = free, 0 = fixed).
/// Remaining degrees of freedom per occurrence (6 = free, 0 = fixed): how many independent
/// values still move it, from the fixed occurrences, with loops kept closed and a value a
/// motion link sets moving with the values its link chain starts from (a driven gear has its
/// driving gear's one, a screw's travel its turn's).
pub dof: BTreeMap<u64, usize>,
/// Occurrences fixed in place (grounded, or the root's).
pub fixed: Vec<u64>,
@@ -1043,6 +1046,46 @@ fn link_chains(asm: &Assembly, tree: &Tree, around: &BTreeSet<u64>, linked: &BTr
(feeds, starts)
}
/// The values that move each value a motion link sets: the values its link chain starts from,
/// which no link sets (none for a chain that comes back round on itself, which only a hand-edited
/// design has). A driven gear's turn is its driving gear's: one degree of freedom, not two.
fn link_sources(asm: &Assembly) -> BTreeMap<(u64, usize), BTreeSet<(u64, usize)>> {
let mut into: BTreeMap<(u64, usize), Vec<(u64, usize)>> = BTreeMap::new();
for l in &asm.links {
into.entry((l.b, l.ib)).or_default().push((l.a, l.ia));
}
let mut sources = BTreeMap::new();
if into.is_empty() {
return sources;
}
// A link from a value its joint doesn't have (a hand-edited design) moves nothing.
let real: BTreeSet<(u64, usize)> = asm.joints.iter().flat_map(|j| (0..j.kind.dofs().len()).map(|i| (j.id, i))).collect();
for &v in into.keys() {
// Back along the links, as far as a link chain carries a value (`MAX_LINK_PASSES`).
let (mut from, mut seen) = (BTreeSet::new(), BTreeSet::from([v]));
let mut frontier = vec![v];
for _ in 0..MAX_LINK_PASSES {
let mut next = Vec::new();
for w in frontier.iter().filter_map(|w| into.get(w)).flatten() {
if !seen.insert(*w) {
continue;
}
if into.contains_key(w) {
next.push(*w);
} else if real.contains(w) {
from.insert(*w);
}
}
if next.is_empty() {
break;
}
frontier = next;
}
sources.insert(v, from);
}
sources
}
/// The held values among `touches` that move: (joint, index, from, to), `from` within the
/// joint's limits. A suppressed joint still sets values through its motion links.
fn held_moves(asm: &Assembly, held: &Held, touches: &BTreeSet<(u64, usize)>, vals: &Values) -> Vec<(u64, usize, f64, f64)> {
@@ -1725,6 +1768,14 @@ fn build<'a>(doc: &'a Document, st: &ModelState, vals: &Values, sol: &mut Soluti
Tree { joints: asm.joints.iter().map(|j| (j.id, j)).collect(), frames, fixed: placed.clone(), parent: BTreeMap::new(), order: Vec::new() };
let mut used: BTreeSet<u64> = BTreeSet::new();
let mut queue: VecDeque<u64> = placed.keys().copied().collect();
// An occurrence's degrees of freedom are the distinct values that move it: those of the
// joints on its path, a value a motion link sets standing for the values its link chain
// starts from (a driven gear turns with its driving gear's one value). Kept per occurrence as
// a count of the values no link reads or sets, which no other path value can repeat, and the
// set of the ones that may be repeated (links' sources).
let sources = link_sources(asm);
let shared: BTreeSet<(u64, usize)> = sources.values().flatten().copied().collect();
let mut moved_by: BTreeMap<u64, (usize, BTreeSet<(u64, usize)>)> = BTreeMap::new();
// The joints at each occurrence, in the joints' order (a large assembly is not walked joint by
// joint for every occurrence).
let mut at: BTreeMap<u64, Vec<&Joint>> = BTreeMap::new();
@@ -1755,7 +1806,18 @@ fn build<'a>(doc: &'a Document, st: &ModelState, vals: &Values, sol: &mut Soluti
placed.insert(other, m);
tree.parent.insert(other, (occ, j.id, from_a));
tree.order.push(other);
dof.insert(other, dof.get(&occ).copied().unwrap_or(0) + j.kind.dofs().len());
let (mut own, mut common) = moved_by.get(&occ).cloned().unwrap_or_default();
for v in (0..j.kind.dofs().len()).map(|i| (j.id, i)) {
match sources.get(&v) {
Some(from) => common.extend(from.iter().copied()),
None if shared.contains(&v) => {
common.insert(v);
}
None => own += 1,
}
}
dof.insert(other, own + common.len());
moved_by.insert(other, (own, common));
queue.push_back(other);
}
}
@@ -1892,6 +1954,26 @@ mod tests {
Assembly { joints, links, ..Assembly::default() }
}
/// A linked value moves with the free value its chain starts from, however the chain is
/// stored; a chain that comes back round on itself, or a link from a value its joint lacks
/// (hand-edited designs), moves it with nothing.
#[test]
fn linked_values_move_with_their_chains_start() {
let mut asm = chain(5, 0.0);
let sources = link_sources(&asm);
for b in 2..=5 {
assert_eq!(sources.get(&(b, 0)), Some(&BTreeSet::from([(1, 0)])), "J{b}: {sources:?}");
}
assert!(!sources.contains_key(&(1, 0)));
let link = |a: u64, ia: usize, b: u64| MotionLink { a, ia, b, ib: 0, ratio: 1.0, offset: 0.0, coupling: None, lift: Vec::new() };
asm.links.push(link(5, 0, 1));
assert!(link_sources(&asm).values().all(BTreeSet::is_empty), "{:?}", link_sources(&asm));
let mut asm = chain(2, 0.0);
asm.links = vec![link(1, 7, 2)];
assert_eq!(link_sources(&asm).get(&(2, 0)), Some(&BTreeSet::new()));
assert!(link_sources(&Assembly::default()).is_empty());
}
/// Links carry a value down a chain stored from its end, each linked value within its limits
/// as it is set, and applying them again changes nothing.
#[test]
+12 -10
View File
@@ -53,21 +53,23 @@ pub static COMMANDS: &[CommandSpec] = &[
"a, b: joints that turn (revolute), b driven by a; ra, rb: the gears' pitch radii (or tooth counts): mm, an expression, or \
{circle: [x,y,z], occurrence?}: the round face there, its radius (read again whenever the design changes, so the ratio follows \
edits); internal?: bool (one is a ring gear: both turn the same way; by default b turns the other way); reverse?: bool; \
link?: index (replace that motion link) → link, ratio (b's degrees per a's); b keeps where it is",
link?: index (replace that motion link) → link, ratio (b's degrees per a's); b keeps where it is, also when the link replaces \
another (at the current pose)",
),
CommandSpec::new("joint.rack_pinion", "Rack and Pinion", rack_pinion).at("SOLID", "ASSEMBLE").icon("rack_pinion").params(
"a, b: a joint that turns (revolute: the pinion) and one that slides (slider: the rack), in either order, b driven by a; \
radius: the pinion's pitch radius (mm, an expression, or {circle: [x,y,z], occurrence?}): the rack travels radius × the turn in \
radians; reverse?: bool; link?: index (replace that motion link) → link, ratio (mm per degree, or degrees per mm); b keeps \
where it is",
where it is, also when the link replaces another (at the current pose)",
),
CommandSpec::new("joint.screw", "Screw", screw).at("SOLID", "ASSEMBLE").icon("screw").params(
"joint: a cylindrical joint (its turn drives its travel) | a, b: a joint that turns and one that slides, b driven by a; pitch: \
travel per turn (mm or an expression); reverse?: bool (a left-hand thread); link?: index (replace that motion link) → link, \
ratio (mm per degree, or degrees per mm); b keeps where it is",
ratio (mm per degree, or degrees per mm); b keeps where it is, also when the link replaces another (at the current \
pose)",
),
CommandSpec::new("joint.cam", "Cam", cam_cmd).at("SOLID", "ASSEMBLE").icon("cam").params(
"a: the cam's joint (revolute); b: the follower's joint, a slider (a translating follower) or a revolute (an oscillating one), b driven by a; profile: {sketch: id|name, index?: the profile (default: the largest)} | {edges: [[x,y,z]…] (points on edges of the cam's bodies forming one closed loop, world), occurrence?}: the cam's outline, in a plane square to its axis, read again whenever the design changes; follower?: knife|roller|flat (default knife; flat: a face square to a slider's line); radius?: the roller's radius (mm, an expression or {circle: [x,y,z], occurrence?}: the roller's round face, which is its tip too); tip?: a joint origin where the follower touches: its knife's point or its roller's centre, such as {circle: [x,y,z]} on the roller (needed for an oscillating follower; a translating one's line runs through it, by default along its slider's axis); flip?: bool (ride the cam from the other side than the one the follower is on now); link?: index (replace that motion link) → link, lift: {min, max, rise, unit} (b's travel over a turn of the cam), reverse (the side it rides from), follower_offset (mm: how far a translating follower's line passes from the cam's axis); b keeps where it is",
"a: the cam's joint (revolute); b: the follower's joint, a slider (a translating follower) or a revolute (an oscillating one), b driven by a; profile: {sketch: id|name, index?: the profile (default: the largest)} | {edges: [[x,y,z]…] (points on edges of the cam's bodies forming one closed loop, world), occurrence?}: the cam's outline, in a plane square to its axis, read again whenever the design changes; follower?: knife|roller|flat (default knife; flat: a face square to a slider's line); radius?: the roller's radius (mm, an expression or {circle: [x,y,z], occurrence?}: the roller's round face, which is its tip too); tip?: a joint origin where the follower touches: its knife's point or its roller's centre, such as {circle: [x,y,z]} on the roller (needed for an oscillating follower; a translating one's line runs through it, by default along its slider's axis); flip?: bool (ride the cam from the other side than the one the follower is on now); link?: index (replace that motion link) → link, lift: {min, max, rise, unit} (b's travel over a turn of the cam), reverse (the side it rides from), follower_offset (mm: how far a translating follower's line passes from the cam's axis); b keeps where it is, also when the link replaces another (at the current pose)",
),
CommandSpec::new("joint.limits", "Joint Limits", limits).icon("joint").params("joint; index?: value index (default 0); min?, max? (omit both to clear)"),
CommandSpec::new("joint.edit", "Edit Joint", edit)
@@ -1092,7 +1094,8 @@ fn cam_profile(s: &Session, p: &Value, cmd: &str) -> Result<CamProfile> {
}
/// Add a coupling's motion link, or put it in place of link `link`: its ratio worked out from its
/// sizes, a new link's offset leaving b where it is (the teeth mesh as they are modelled).
/// sizes, its offset leaving b where it is (the teeth mesh as they are now), for a new link and
/// one put in place of another alike.
fn couple(s: &mut Session, p: &Value, cmd: &str, mut l: MotionLink) -> Result<Value> {
let links = &s.doc.assembly.links;
let replace = match p.get("link") {
@@ -1124,11 +1127,10 @@ fn couple(s: &mut Session, p: &Value, cmd: &str, mut l: MotionLink) -> Result<Va
joints::coupling_values(&s.doc, &s.model.state(), &l).unwrap_or(Ok((l.ratio, Vec::new()))).map_err(|e| bad(cmd, e.to_string()))?;
let vals = joints::joint_values(&s.doc.assembly);
let value = |id: u64, i: usize| vals.get(&id).and_then(|q| q.get(i)).copied().unwrap_or(0.0);
// A link put in place of another keeps its offset, as a radius edited in the design does.
l.offset = match replace.and_then(|i| links.get(i)) {
Some(old) => old.offset,
None => value(l.b, l.ib) - MotionLink { offset: 0.0, ..l.clone() }.follow(value(l.a, l.ia)),
};
// New or put in place of another (turned the other way, a ring gear now), b keeps where it
// is: the gears stay in mesh at the pose they are in. (A radius edited in the design keeps
// the offset instead, so the gears turn from where they meshed when they were coupled.)
l.offset = value(l.b, l.ib) - MotionLink { offset: 0.0, ..l.clone() }.follow(value(l.a, l.ia));
if !l.offset.is_finite() {
return Err(bad(cmd, "the coupling gives the driven joint no value where it is"));
}
+145 -6
View File
@@ -19,10 +19,15 @@ fn close_to(got: f64, want: f64) -> bool {
/// A round disc (a gear's pitch circle) of radius `r` about a vertical axis through (x, 0), 5 mm
/// thick, in a component of its own, on a revolute joint to the grounded base.
fn disc(s: &mut Session, name: &str, x: f64, r: &str) {
loose_disc(s, name, x, r);
pin(s, "revolute", name, "Base", name, Vec3::new(x, 0.0, 0.0), Vec3::Z);
}
/// The disc alone, on no joint.
fn loose_disc(s: &mut Session, name: &str, x: f64, r: &str) {
run(s, "component.activate", json!({"component": "root"}));
run(s, "component.create", json!({"name": name}));
run(s, "solid.cylinder", json!({"base": [x, 0, 0], "axis": [0, 0, 1], "radius": r, "height": 5}));
pin(s, "revolute", name, "Base", name, Vec3::new(x, 0.0, 0.0), Vec3::Z);
}
fn base(s: &mut Session) {
@@ -80,11 +85,13 @@ fn a_gear_train_of_three_turns_by_its_radii() {
run(&mut s, "motion.study", json!({"name": "Spin", "steps": 10, "keys": [{"joint": "G1", "points": [[0, 0], [10, 30]]}]}));
run(&mut s, "motion.play", json!({"study": "Spin", "step": 10}));
assert!(close_to(shown(&mut s, "G3"), 24.0), "{}", shown(&mut s, "G3"));
// An internal (ring) gear turns its mate the same way; redefining a link keeps its offset.
// An internal (ring) gear turns its mate the same way; the redefined link leaves G3 where it
// is (G2 at -18°), and G1 turning on 30° more turns G2 on -18° and G3 on -24°.
let r = run(&mut s, "joint.gear", json!({"a": "G2", "b": "G3", "ra": 20, "rb": 15, "internal": true, "link": 1}));
assert_eq!(r["link"], 1, "{r}");
assert!(close_to(shown(&mut s, "G3"), 24.0), "{}", run(&mut s, "joint.list", json!({})));
run(&mut s, "joint.drive", json!({"joint": "G1", "value": 60}));
assert!(close_to(shown(&mut s, "G2"), -36.0) && close_to(shown(&mut s, "G3"), -48.0), "{}", run(&mut s, "joint.list", json!({})));
assert!(close_to(shown(&mut s, "G2"), -36.0) && close_to(shown(&mut s, "G3"), 0.0), "{}", run(&mut s, "joint.list", json!({})));
// Undo takes the redefinition back.
run(&mut s, "edit.undo", json!({}));
run(&mut s, "edit.undo", json!({}));
@@ -162,10 +169,10 @@ fn a_screw_advances_its_pitch_per_turn() {
let travel = r["values"][1]["value"].as_f64().unwrap_or(f64::NAN);
assert!(close_to(travel, 3.0), "{r}");
assert!(near(place(&s, "Nut", Vec3::new(-1.0, -1.0, 0.0)), Vec3::new(-1.0, -1.0, 3.0)));
// A left-hand thread goes the other way.
// A left-hand thread goes the other way, from where the nut is.
run(&mut s, "joint.screw", json!({"joint": "Thread", "pitch": 1.5, "reverse": true, "link": 0}));
let r = run(&mut s, "joint.drive", json!({"joint": "Thread", "values": [360]}));
assert!(close_to(r["values"][1]["value"].as_f64().unwrap_or(f64::NAN), -1.5), "{r}");
let r = run(&mut s, "joint.drive", json!({"joint": "Thread", "values": [1080]}));
assert!(close_to(r["values"][1]["value"].as_f64().unwrap_or(f64::NAN), 1.5), "{r}");
// A revolute joint and a slider, the pitch a parameter: changing it changes the lead.
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name": "lead", "expression": "2 mm"}));
@@ -253,3 +260,135 @@ fn couplings_refuse_what_they_cannot_couple() {
}
assert_eq!(s.doc.assembly, before);
}
/// Gears of radii 10 and 20 on the grounded base, turned to 30° and 40° before they are coupled.
fn meshed_at_30_and_40() -> Session {
let mut s = Session::default();
base(&mut s);
disc(&mut s, "GA", 0.0, "10 mm");
disc(&mut s, "GB", 30.0, "20 mm");
run(&mut s, "joint.drive", json!({"joint": "GA", "value": 30}));
run(&mut s, "joint.drive", json!({"joint": "GB", "value": 40}));
let r = run(&mut s, "joint.gear", json!({"a": "GA", "b": "GB", "ra": 10, "rb": 20}));
assert!(close_to(r["ratio"].as_f64().unwrap_or(f64::NAN), -0.5), "{r}");
s
}
/// The first motion link's offset in degrees, and whether its gear is reversed.
fn gear_link(s: &Session) -> (f64, Option<bool>) {
let l = s.doc.assembly.links.first();
let reverse = l.and_then(|l| match &l.coupling {
Some(Coupling::Gear { reverse, .. }) => Some(*reverse),
_ => None,
});
(l.map(|l| l.offset.to_degrees()).unwrap_or(f64::NAN), reverse)
}
/// A gear link redefined in place (turned the other way with `reverse`) leaves the driven gear
/// where it is, as a new link does, so the gears stay in mesh at the pose they are in: GB stays
/// at 40° rather than jumping to 70° (the old offset kept, the flip pivoting about GA at 0°).
/// Undo, redo and saving keep each definition with its pose.
#[test]
fn a_redefined_gear_stays_in_mesh_where_it_is() {
let mut s = meshed_at_30_and_40();
// b = -0.5·a + 55: GB keeps its 40° at GA's 30°.
let (offset, reverse) = gear_link(&s);
assert!(close_to(offset, 55.0) && reverse == Some(false), "{offset} {reverse:?}");
assert!(close_to(shown(&mut s, "GB"), 40.0), "{}", shown(&mut s, "GB"));
let r = run(&mut s, "joint.gear", json!({"a": "GA", "b": "GB", "ra": 10, "rb": 20, "reverse": true, "link": 0}));
assert!(r["link"] == 0 && close_to(r["ratio"].as_f64().unwrap_or(f64::NAN), 0.5), "{r}");
// b = 0.5·a + 25.
let (offset, reverse) = gear_link(&s);
assert!(close_to(offset, 25.0) && reverse == Some(true), "{offset} {reverse:?}");
assert!(close_to(shown(&mut s, "GA"), 30.0) && close_to(shown(&mut s, "GB"), 40.0), "{}", run(&mut s, "joint.list", json!({})));
assert!(near(rim(&s, "GB", 30.0, 20.0), turned(30.0, 20.0, 40.0)), "{:?}", rim(&s, "GB", 30.0, 20.0));
// It turns from there, now the same way as GA.
run(&mut s, "joint.drive", json!({"joint": "GA", "value": 0}));
assert!(close_to(shown(&mut s, "GB"), 25.0), "{}", shown(&mut s, "GB"));
assert!(near(rim(&s, "GB", 30.0, 20.0), turned(30.0, 20.0, 25.0)), "{:?}", rim(&s, "GB", 30.0, 20.0));
// Undo goes back through the drive and the redefinition, redo forward again.
run(&mut s, "edit.undo", json!({}));
run(&mut s, "edit.undo", json!({}));
let (offset, reverse) = gear_link(&s);
assert!(close_to(offset, 55.0) && reverse == Some(false), "{offset} {reverse:?}");
assert!(close_to(shown(&mut s, "GA"), 30.0) && close_to(shown(&mut s, "GB"), 40.0), "{}", run(&mut s, "joint.list", json!({})));
run(&mut s, "edit.redo", json!({}));
let (offset, reverse) = gear_link(&s);
assert!(close_to(offset, 25.0) && reverse == Some(true), "{offset} {reverse:?}");
assert!(close_to(shown(&mut s, "GB"), 40.0), "{}", shown(&mut s, "GB"));
run(&mut s, "edit.redo", json!({}));
assert!(close_to(shown(&mut s, "GA"), 0.0) && close_to(shown(&mut s, "GB"), 25.0), "{}", run(&mut s, "joint.list", json!({})));
// Saved and opened again: the same link, the same pose.
let back = solidcraft_doc::format::read(&s.doc.to_json()).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(back.assembly.links, s.doc.assembly.links);
let vals = solidcraft_doc::joints::joint_values(&back.assembly);
let gb = back.assembly.joints.iter().find(|j| j.name == "GB").map(|j| j.id).unwrap_or(0);
assert!(vals.get(&gb).and_then(|q| q.first()).is_some_and(|v| close_to(v.to_degrees(), 25.0)), "{vals:?}");
}
/// The other couplings redefined in place keep their driven joint where it is too.
#[test]
fn a_redefined_rack_keeps_the_rack_where_it_is() {
let mut s = rack();
run(&mut s, "joint.rack_pinion", json!({"a": "Pinion", "b": "Rail", "radius": 8}));
run(&mut s, "joint.drive", json!({"joint": "Pinion", "value": 45}));
let travel = 8.0 * std::f64::consts::FRAC_PI_4;
run(&mut s, "joint.rack_pinion", json!({"a": "Pinion", "b": "Rail", "radius": 4, "reverse": true, "link": 0}));
assert!(close_to(shown(&mut s, "Rail"), travel), "{}", shown(&mut s, "Rail"));
run(&mut s, "joint.drive", json!({"joint": "Pinion", "value": 0}));
assert!(close_to(shown(&mut s, "Rail"), travel + 4.0 * std::f64::consts::FRAC_PI_4), "{}", shown(&mut s, "Rail"));
}
/// A joint a motion link drives adds no freedom of its own: gears on a grounded base have one
/// between them, each turning (and draggable) with it; a part hinged on the driven gear has one
/// more; a cylindrical joint whose turn drives its travel (a screw) has one, not two.
#[test]
fn a_motion_link_takes_its_driven_joints_freedom_on_a_grounded_base() {
let mut s = Session::default();
base(&mut s);
disc(&mut s, "GA", 0.0, "10 mm");
disc(&mut s, "GB", 30.0, "20 mm");
run(&mut s, "joint.gear", json!({"a": "GA", "b": "GB", "ra": 10, "rb": 20}));
for (part, want) in [("Base", 0), ("GA", 1), ("GB", 1)] {
assert_eq!(dof_of(&mut s, part), want, "{part}");
}
part(&mut s, "Arm", [45.0, -1.0, 5.0]);
pin(&mut s, "revolute", "Arm", "GB", "Arm", Vec3::new(46.0, 0.0, 5.0), Vec3::Z);
assert_eq!(dof_of(&mut s, "Arm"), 2);
// The arm's hinge driven by GA too: still the one value.
run(&mut s, "joint.motion_link", json!({"a": "GA", "b": "Arm", "ratio": 2}));
assert_eq!(dof_of(&mut s, "Arm"), 1);
let mut s = Session::default();
base(&mut s);
part(&mut s, "Nut", [-1.0, -1.0, 0.0]);
pin(&mut s, "cylindrical", "Thread", "Base", "Nut", Vec3::ZERO, Vec3::Z);
assert_eq!(dof_of(&mut s, "Nut"), 2);
run(&mut s, "joint.screw", json!({"joint": "Thread", "pitch": 1.5}));
assert_eq!(dof_of(&mut s, "Nut"), 1);
}
/// With nothing grounded, the first joint's A side stays put. A gear there (its joint to the
/// housing made from the gear) leaves the housing one turn about it, and the driven gear on the
/// housing turns with that same value once the gears are coupled: one, not two. With the
/// housing first, each gear has one.
#[test]
fn a_motion_link_takes_its_driven_joints_freedom_with_nothing_grounded() {
for gear_first in [true, false] {
let mut s = Session::default();
part(&mut s, "Housing", [-10.0, -10.0, -20.0]);
loose_disc(&mut s, "GA", 0.0, "10 mm");
loose_disc(&mut s, "GB", 30.0, "20 mm");
let (a, b) = if gear_first { ("GA", "Housing") } else { ("Housing", "GA") };
pin(&mut s, "revolute", "JA", a, b, Vec3::ZERO, Vec3::Z);
pin(&mut s, "revolute", "JB", "Housing", "GB", Vec3::new(30.0, 0.0, 0.0), Vec3::Z);
let (fixed, free) = if gear_first { ("GA", "Housing") } else { ("Housing", "GA") };
assert_eq!(dof_of(&mut s, fixed), 0, "gear first: {gear_first}");
assert_eq!(dof_of(&mut s, free), 1, "gear first: {gear_first}");
// Before the link GB turns on JB, and with the housing about GA when GA stays put.
assert_eq!(dof_of(&mut s, "GB"), if gear_first { 2 } else { 1 }, "gear first: {gear_first}");
run(&mut s, "joint.gear", json!({"a": "JA", "b": "JB", "ra": 10, "rb": 20}));
for (part, want) in [(fixed, 0), (free, 1), ("GB", 1)] {
assert_eq!(dof_of(&mut s, part), want, "{part}, gear first: {gear_first}");
}
}
}
+5 -2
View File
@@ -108,7 +108,9 @@ ratio comes from the parts rather than a typed number:
A radius or pitch may be an expression of the design's parameters, and a picked face is found
again whenever the design changes. Change the parameter or the gear's diameter, and the ratio
follows. The new link leaves the driven joint where it is, so teeth modelled in mesh stay in mesh.
**Reverse** turns the driven joint the other way, for joints whose axes point opposite ways. These
**Reverse** turns the driven joint the other way, for joints whose axes point opposite ways.
Redefining a link (`link: i`, such as turning it the other way with **Reverse**) also leaves the
driven joint where it is, so the gears stay in mesh at the pose they are in. These
couplings are motion links, so they work in closed loops, with contact sets and in motion studies:
a rack can push the piston of a slider-crank, or a pinion can steer through a rack and its tie
rods. Drive the joint that drives the coupling (joint 1, the pinion, or the screw's turn), not the
@@ -158,7 +160,8 @@ example driving a rocker beyond its swing, the motion stops at the last position
closes, and the drive reports `stopped_by_loop`. A loop that cannot close at all is listed
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
loop locked by a rigid joint has none. A joint a motion link drives adds no freedom of its own:
two meshed gears on a fixed housing have one between them, each turning with the driving gear. 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. The dialog opens on the joint