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:
29 files changed
+1476
-54
No files matched your search
@@ -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
@@ -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)));
|
||||
|
||||
Reference in new issue
Block a user