Joints: gears, rack and pinion, and screws (#1524)

Motion links can carry a coupling (gear pair, rack and pinion, screw) whose ratio follows radii, picked round faces or pitch. Design format 23.

Co-authored-by: SychPL <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-15 18:47:38 -04:00
1 parent a519f3cf74
commit f7967b1ff6
29 files changed
+1476 -54

No files matched your search

+10 -2
View File
@@ -222,13 +222,20 @@
//! parameters); the source's `at`, `height` and `angle` then hold the numbers they last came
//! to. A text without `exprs` is set as it always was; 0.12.0 asks for an update instead of
//! dropping the expressions and keeping the text at its last numbers.
//! - 23: a motion link can carry a `coupling`: a gear pair (`ra`, `rb`, `internal`), a rack and
//! pinion (`radius`) or a screw (`pitch`), each size a number in mm, an expression or a picked
//! round face, and `reverse`. The link's `ratio` is worked out from it whenever the design
//! changes and saved as well, so a link reads the same without it. Version 22 designs need no
//! transformation: a link without `coupling` is a plain motion link, as it always was; 0.13.0
//! (which reads up to 22) asks for an update instead of keeping a gear's ratio when its radius
//! changes.
use serde_json::Value;
use crate::{DocError, Document, Result};
/// The format this build writes.
pub const FORMAT: u32 = 22;
pub const FORMAT: u32 = 23;
pub fn format_string(n: u32) -> String {
format!("solidcraft/{n}")
@@ -245,7 +252,7 @@ pub fn version_of(s: &str) -> Option<u32> {
/// JSON-level upgrades: entry `i` turns format `i + 1` into `i + 2`.
type JsonStep = fn(&mut Value) -> Result<()>;
const JSON_STEPS: [JsonStep; 21] = [
const JSON_STEPS: [JsonStep; 22] = [
|_| Ok(()),
v2_to_v3,
v3_to_v4,
@@ -267,6 +274,7 @@ const JSON_STEPS: [JsonStep; 21] = [
|_| Ok(()),
|_| Ok(()),
|_| Ok(()),
|_| Ok(()),
];
/// Stored face and edge names were worked out from geometry before format 14; they get the
+183 -18
View File
@@ -1,7 +1,8 @@
//! Joints between occurrences: joint origins (snap frames on component geometry, re-found on
//! every solve so they follow edits), joints (rigid, revolute, slider, cylindrical, pin-slot,
//! planar, ball) with their motion values and limits, motion links, and the solver that places
//! occurrences from the joint graph.
//! planar, ball) with their motion values and limits, motion links (gears, racks and screws are
//! links whose ratio comes from radii or a pitch), and the solver that places occurrences from
//! the joint graph.
//!
//! A joint makes B's joint frame coincide with A's frame moved by the joint's motion:
//! `T_B · F_b = T_A · F_a · M(q) · Flip`, so `T_B = T_A · F_a · M(q) · Flip · F_b⁻¹`, where `T`
@@ -151,6 +152,115 @@ pub struct MotionLink {
pub ratio: f64,
#[serde(default)]
pub offset: f64,
/// Gears, a rack and pinion or a screw: what the ratio comes from. The ratio is worked out
/// from it again whenever the design changes (`coupling_ratio`), so it follows the radii and
/// the pitch; the last one worked out is kept in `ratio`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub coupling: Option<Coupling>,
}
/// A length a coupling's ratio comes from: millimetres, an expression over the design's
/// parameters, or the radius of the round face picked on an occurrence (a circle-centre snap,
/// found again on every solve like a joint origin).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum Size {
Length(f64),
Expr(String),
Circle(JointOrigin),
}
/// The geometry a motion link's ratio comes from. `reverse` turns the follower the other way.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Coupling {
/// Two gears on revolute joints, `ra` on a's and `rb` on b's (pitch radii, or any two lengths
/// in their proportion such as tooth counts): b turns ra/rb as far as a, the other way round,
/// or the same way when one is an internal (ring) gear.
Gear {
ra: Size,
rb: Size,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
internal: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
reverse: bool,
},
/// A pinion of pitch radius `radius` on a revolute joint and a rack on a slider: the rack
/// travels the radius times the pinion's turn in radians (either joint may drive).
RackPinion {
radius: Size,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
reverse: bool,
},
/// A screw: the travel advances `pitch` per turn (a cylindrical joint's angle and distance,
/// or a revolute joint and a slider). Right-handed unless reversed.
Screw {
pitch: Size,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
reverse: bool,
},
}
impl Coupling {
/// The lengths the ratio comes from: a's gear and b's, or the pinion's radius or the pitch.
pub fn sizes(&self) -> Vec<&Size> {
match self {
Coupling::Gear { ra, rb, .. } => vec![ra, rb],
Coupling::RackPinion { radius: s, .. } | Coupling::Screw { pitch: s, .. } => vec![s],
}
}
pub fn sizes_mut(&mut self) -> Vec<&mut Size> {
match self {
Coupling::Gear { ra, rb, .. } => vec![ra, rb],
Coupling::RackPinion { radius: s, .. } | Coupling::Screw { pitch: s, .. } => vec![s],
}
}
}
/// Largest length a coupling takes (mm).
const MAX_SIZE: f64 = 1e7;
/// A coupling length in millimetres: positive and finite.
fn size_of(doc: &Document, st: &ModelState, s: &Size) -> Result<f64> {
let v = match s {
Size::Length(v) => *v,
Size::Expr(e) => doc.eval(e, crate::expr::Kind::Length)?,
Size::Circle(o) => circle_at(doc, st, o)?.1.radius,
};
if v.is_finite() && v > 1e-9 && v <= MAX_SIZE {
Ok(v)
} else {
Err(DocError::Invalid(format!("a gear, rack or screw needs a positive length, not {v}")))
}
}
/// The ratio a link's coupling gives it (b's value per a's, in radians and mm), worked out from
/// the geometry and parameters it is defined by; `None` for a plain link.
pub fn coupling_ratio(doc: &Document, st: &ModelState, l: &MotionLink) -> Option<Result<f64>> {
l.coupling.as_ref().map(|c| coupled(doc, st, l, c))
}
fn coupled(doc: &Document, st: &ModelState, l: &MotionLink, c: &Coupling) -> Result<f64> {
let angle = |id: u64, i: usize| doc.assembly.joints.iter().find(|j| j.id == id).is_some_and(|j| j.kind.is_angle(i));
let (turn_a, turn_b) = (angle(l.a, l.ia), angle(l.b, l.ib));
// Travel per radian, for a turn coupled to a travel either way round.
let travel = |k: f64| match (turn_a, turn_b) {
(true, false) => Ok(k),
(false, true) => Ok(1.0 / k),
_ => Err(DocError::Invalid("a rack or a screw couples a turn and a travel".into())),
};
let sign = |reverse: bool| if reverse { -1.0 } else { 1.0 };
match c {
Coupling::Gear { ra, rb, internal, reverse } => {
if !(turn_a && turn_b) {
return Err(DocError::Invalid("gears couple two turns".into()));
}
Ok(size_of(doc, st, ra)? / size_of(doc, st, rb)? * sign(!internal) * sign(*reverse))
}
Coupling::RackPinion { radius, reverse } => Ok(travel(size_of(doc, st, radius)?)? * sign(*reverse)),
Coupling::Screw { pitch, reverse } => Ok(travel(size_of(doc, st, pitch)? / std::f64::consts::TAU)? * sign(*reverse)),
}
}
/// Joints, joint origins and motion links of a design.
@@ -272,27 +382,35 @@ pub fn resolve_origin(doc: &Document, st: &ModelState, o: &JointOrigin) -> Resul
let (_, c, n) = best.ok_or_else(|| DocError::Invalid("joint origin: no planar face there".into()))?;
frame(c, n, None).ok_or_else(bad)
}
Snap::CircleCenter { pick, face } => {
let pick = &named_pick(st, *pick, face.as_deref())?;
let comp = component_of(doc, o.occurrence)?;
let cyl = component_bodies(doc, st, comp)
.into_iter()
.filter_map(|b| solidcraft_kernel::cylinder_face_at(&b.body, *pick))
.min_by(|a, b| {
let off = |c: &solidcraft_kernel::CylinderFace| {
let v = *pick - c.axis_point;
((v - c.axis * v.dot(c.axis)).len() - c.radius).abs()
};
off(a).total_cmp(&off(b))
})
.ok_or_else(|| DocError::Invalid("joint origin: no cylindrical face there".into()))?;
let s = (*pick - cyl.axis_point).dot(cyl.axis);
Snap::CircleCenter { .. } => {
let (pick, cyl) = circle_at(doc, st, o)?;
let s = (pick - cyl.axis_point).dot(cyl.axis);
let (at, dir) = if (s - cyl.start).abs() <= (cyl.end - s).abs() { (cyl.start, -1.0) } else { (cyl.end, 1.0) };
frame(cyl.axis_point + cyl.axis * at, cyl.axis * dir, None).ok_or_else(bad)
}
}
}
/// The pick of a circle-centre snap (on its named face) and the cylindrical face there
/// (component coordinates). Any other snap is refused.
fn circle_at(doc: &Document, st: &ModelState, o: &JointOrigin) -> Result<(Vec3, solidcraft_kernel::CylinderFace)> {
let Snap::CircleCenter { pick, face } = &o.snap else { return Err(DocError::Invalid("pick a round face".into())) };
let pick = named_pick(st, *pick, face.as_deref())?;
let comp = component_of(doc, o.occurrence)?;
let cyl = component_bodies(doc, st, comp)
.into_iter()
.filter_map(|b| solidcraft_kernel::cylinder_face_at(&b.body, pick))
.min_by(|a, b| {
let off = |c: &solidcraft_kernel::CylinderFace| {
let v = pick - c.axis_point;
((v - c.axis * v.dot(c.axis)).len() - c.radius).abs()
};
off(a).total_cmp(&off(b))
})
.ok_or_else(|| DocError::Invalid("joint origin: no cylindrical face there".into()))?;
Ok((pick, cyl))
}
/// World transform of an occurrence (through its parents).
pub fn occurrence_world(doc: &Document, occ: u64) -> Mat {
let mut m = IDENTITY;
@@ -1283,7 +1401,7 @@ mod tests {
suppressed: false,
})
.collect();
let links = (1..n).rev().map(|a| MotionLink { a, ia: 0, b: a + 1, ib: 0, ratio: 2.0, offset: 0.0 }).collect();
let links = (1..n).rev().map(|a| MotionLink { a, ia: 0, b: a + 1, ib: 0, ratio: 2.0, offset: 0.0, coupling: None }).collect();
Assembly { joints, links, ..Assembly::default() }
}
@@ -1306,6 +1424,53 @@ mod tests {
assert_eq!(again, vals);
}
/// A coupling saves as its definition: sizes as numbers, expressions or picked faces, flags
/// only when set; a link without one (as every design before couplings has) reads as before.
#[test]
fn couplings_read_and_write_their_definition() {
let old: MotionLink = serde_json::from_str(r#"{"a": 1, "b": 2, "ratio": 2}"#).unwrap();
assert_eq!((old.coupling, old.offset, old.ia), (None, 0.0, 0));
let text = r#"{"a": 1, "b": 2, "ratio": -0.5, "coupling": {"type": "gear", "ra": 10, "rb": "r2 * 2",
"internal": true}}"#;
let l: MotionLink = serde_json::from_str(text).unwrap();
let Some(Coupling::Gear { ra, rb, internal, reverse }) = &l.coupling else { panic!("{l:?}") };
assert_eq!((ra, rb, *internal, *reverse), (&Size::Length(10.0), &Size::Expr("r2 * 2".into()), true, false));
let back = serde_json::to_value(&l).unwrap();
assert_eq!(back["coupling"], serde_json::json!({"type": "gear", "ra": 10.0, "rb": "r2 * 2", "internal": true}));
let picked: Size = serde_json::from_str(r#"{"occurrence": 3, "snap": {"type": "circle_center", "pick": [1, 0, 0]}}"#).unwrap();
assert!(matches!(picked, Size::Circle(JointOrigin { occurrence: 3, snap: Snap::CircleCenter { .. } })));
let screw: Coupling = serde_json::from_str(r#"{"type": "screw", "pitch": 1.5, "reverse": true}"#).unwrap();
assert_eq!(screw, Coupling::Screw { pitch: Size::Length(1.5), reverse: true });
}
/// Ratios from the sizes: gears turn by their radii (the other way unless internal), a rack
/// and a screw travel per radian whichever of the two joints drives.
#[test]
fn coupling_ratios_follow_their_sizes() {
let mut doc = Document { assembly: chain(3, 0.0), ..Document::default() };
if let Some(j) = doc.assembly.joints.get_mut(2) {
j.kind = JointKind::Slider;
}
let st = ModelState::default();
let ratio = |a: u64, b: u64, c: Coupling| {
let l = MotionLink { a, ia: 0, b, ib: 0, ratio: 0.0, offset: 0.0, coupling: Some(c) };
coupling_ratio(&doc, &st, &l).map(|r| r.map_err(|e| e.to_string()))
};
let len = Size::Length;
assert_eq!(ratio(1, 2, Coupling::Gear { ra: len(10.0), rb: len(40.0), internal: false, reverse: false }), Some(Ok(-0.25)));
assert_eq!(ratio(1, 2, Coupling::Gear { ra: len(10.0), rb: len(40.0), internal: true, reverse: false }), Some(Ok(0.25)));
assert_eq!(ratio(1, 3, Coupling::RackPinion { radius: len(8.0), reverse: false }), Some(Ok(8.0)));
assert_eq!(ratio(3, 1, Coupling::RackPinion { radius: len(8.0), reverse: true }), Some(Ok(-0.125)));
let screw = ratio(1, 3, Coupling::Screw { pitch: len(std::f64::consts::TAU), reverse: false });
assert_eq!(screw, Some(Ok(1.0)));
assert!(matches!(ratio(1, 2, Coupling::RackPinion { radius: len(8.0), reverse: false }), Some(Err(_))));
assert!(matches!(ratio(1, 3, Coupling::Gear { ra: len(1.0), rb: len(1.0), internal: false, reverse: false }), Some(Err(_))));
for bad in [0.0, -2.0, f64::NAN, f64::INFINITY, 1e300] {
assert!(matches!(ratio(1, 2, Coupling::Gear { ra: len(bad), rb: len(1.0), internal: false, reverse: false }), Some(Err(_))), "{bad}");
}
assert!(matches!(ratio(1, 2, Coupling::Gear { ra: Size::Expr("nope".into()), rb: len(1.0), internal: false, reverse: false }), Some(Err(_))));
}
#[test]
fn motions_compose() {
assert!(close(&motion(JointKind::Revolute, &[std::f64::consts::FRAC_PI_2]), &rot_z(std::f64::consts::FRAC_PI_2)));