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
@@ -3,7 +3,7 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use solidcraft_doc::Document;
|
||||
use solidcraft_doc::joints::{Assembly, JointOrigin, Snap};
|
||||
use solidcraft_doc::joints::{Assembly, Coupling, JointOrigin, Size, Snap};
|
||||
|
||||
use crate::{EngineError, Result};
|
||||
|
||||
@@ -105,6 +105,14 @@ fn budget(assembly: &Assembly) -> Result<()> {
|
||||
if !assembly.links.iter().all(|l| l.ratio.is_finite() && l.offset.is_finite()) {
|
||||
return Err(invalid("motion link contains non-finite values"));
|
||||
}
|
||||
for size in assembly.links.iter().filter_map(|l| l.coupling.as_ref()).flat_map(Coupling::sizes) {
|
||||
match size {
|
||||
Size::Length(v) if !v.is_finite() => return Err(invalid("motion link contains non-finite values")),
|
||||
Size::Expr(e) => text(e)?,
|
||||
Size::Circle(o) => finite_origin(o)?,
|
||||
Size::Length(_) => {}
|
||||
}
|
||||
}
|
||||
if references > MAX_REFERENCES {
|
||||
return Err(invalid("assembly references exceed the processing budget"));
|
||||
}
|
||||
@@ -291,9 +299,16 @@ pub(super) fn clone_assembly(
|
||||
if !touched_joints.contains(&old.a) && !touched_joints.contains(&old.b) {
|
||||
continue;
|
||||
}
|
||||
let mut item = old.clone();
|
||||
// A gear's picked round faces are on occurrences of the copy too.
|
||||
let faces = item.coupling.as_mut().is_none_or(|c| {
|
||||
c.sizes_mut().into_iter().all(|size| match size {
|
||||
Size::Circle(o) => origin(o, occurrences, features, bodies).map(|new| *o = new).is_some(),
|
||||
_ => true,
|
||||
})
|
||||
});
|
||||
match (joints.get(&old.a), joints.get(&old.b)) {
|
||||
(Some(aa), Some(bb)) => {
|
||||
let mut item = old.clone();
|
||||
(Some(aa), Some(bb)) if faces => {
|
||||
item.a = *aa;
|
||||
item.b = *bb;
|
||||
a.links.push(item);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#![allow(clippy::unwrap_used, clippy::panic)]
|
||||
use super::*;
|
||||
use solidcraft_doc::joints::{Joint, JointKind, MotionLink, NamedOrigin};
|
||||
use solidcraft_doc::joints::{Coupling, Joint, JointKind, MotionLink, NamedOrigin, Size};
|
||||
use solidcraft_doc::motion::{ContactSet, ExplodedView, MotionStudy, StudyKey};
|
||||
use solidcraft_doc::{IDENTITY, Occurrence};
|
||||
use solidcraft_geom::Vec3;
|
||||
@@ -43,8 +43,10 @@ fn documents() -> (Document, Document) {
|
||||
},
|
||||
NamedOrigin { name: "Outside".into(), origin: at(9) },
|
||||
];
|
||||
source.assembly.links =
|
||||
vec![MotionLink { a: 1, ia: 0, b: 2, ib: 0, ratio: -2.0, offset: 0.3 }, MotionLink { a: 1, ia: 0, b: 3, ib: 0, ratio: 1.0, offset: 0.0 }];
|
||||
source.assembly.links = vec![
|
||||
MotionLink { a: 1, ia: 0, b: 2, ib: 0, ratio: -2.0, offset: 0.3, coupling: None },
|
||||
MotionLink { a: 1, ia: 0, b: 3, ib: 0, ratio: 1.0, offset: 0.0, coupling: None },
|
||||
];
|
||||
source.assembly.contact_sets = vec![
|
||||
ContactSet { id: 1, name: "Internal contact".into(), occurrences: vec![1, 2], suppressed: true },
|
||||
ContactSet { id: 2, name: "Cross contact".into(), occurrences: vec![1, 9], suppressed: false },
|
||||
@@ -96,6 +98,29 @@ fn internal_relationships_receive_independent_ids_and_remapped_typed_references(
|
||||
assert_eq!(source.assembly.joints[0].values[0], 0.25);
|
||||
}
|
||||
|
||||
/// A gear's picked round face is on the copy's occurrence and face; one picked on geometry the
|
||||
/// copy doesn't have is reported, not pointed back at the original.
|
||||
#[test]
|
||||
fn copied_gears_take_their_picked_faces_along() {
|
||||
let (mut source, _) = documents();
|
||||
let circle = |face: &str| Size::Circle(JointOrigin { occurrence: 1, snap: Snap::CircleCenter { pick: Vec3::X, face: Some(face.into()) } });
|
||||
let gear = |ra| Coupling::Gear { ra, rb: Size::Length(5.0), internal: false, reverse: false };
|
||||
source.assembly.links = vec![
|
||||
MotionLink { a: 1, ia: 0, b: 2, ib: 0, ratio: -2.0, offset: 0.0, coupling: Some(gear(circle("F1:side"))) },
|
||||
MotionLink { a: 2, ia: 0, b: 1, ib: 0, ratio: -0.5, offset: 0.0, coupling: Some(gear(circle("F99:side"))) },
|
||||
];
|
||||
let mut destination = source.clone();
|
||||
destination.occurrences.extend([101, 102].map(occurrence));
|
||||
let report =
|
||||
clone_assembly(&mut destination, &source, &BTreeMap::from([(1, 101), (2, 102)]), &BTreeMap::from([(1, 11), (10, 110)]), &BTreeMap::new())
|
||||
.unwrap();
|
||||
let copied = destination.assembly.links.get(2).and_then(|l| l.coupling.clone());
|
||||
let Some(Coupling::Gear { ra: Size::Circle(o), .. }) = copied else { panic!("{:?}", destination.assembly.links) };
|
||||
assert!(o.occurrence == 101 && matches!(&o.snap, Snap::CircleCenter { face: Some(f), .. } if f == "F11:side"), "{o:?}");
|
||||
assert_eq!(destination.assembly.links.len(), 3, "the gear on foreign geometry stays behind");
|
||||
assert!(report.iter().any(|m| m.contains("motion link")), "{report:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn naming_remaps_exact_tokens_unicode_and_legacy_body_identity() {
|
||||
let features = BTreeMap::from([(1, 21), (10, 30)]);
|
||||
|
||||
@@ -7,7 +7,7 @@ use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_doc::expr::Kind;
|
||||
use solidcraft_doc::joints::{self, Joint, JointKind, JointOrigin, MotionLink, NamedOrigin, Snap};
|
||||
use solidcraft_doc::joints::{self, Coupling, Joint, JointKind, JointOrigin, MotionLink, NamedOrigin, Size, Snap};
|
||||
use solidcraft_geom::{Aabb3, Vec3};
|
||||
|
||||
use super::CommandSpec;
|
||||
@@ -40,6 +40,23 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.at("SOLID", "ASSEMBLE")
|
||||
.icon("motion_link")
|
||||
.params("a, b: joints; ratio (b = ratio · a + offset): b's units per a's (mm per deg for an angle driving a distance); offset?: in b's units (deg for an angle, mm for a distance, or an expression); ia?, ib?: value indices (default 0)"),
|
||||
CommandSpec::new("joint.gear", "Gear", gear).at("SOLID", "ASSEMBLE").icon("gear").params(
|
||||
"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",
|
||||
),
|
||||
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",
|
||||
),
|
||||
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",
|
||||
),
|
||||
CommandSpec::new("joint.limits", "Joint Limits", limits).params("joint; index?: value index (default 0); min?, max? (omit both to clear)"),
|
||||
CommandSpec::new("joint.edit", "Edit Joint", edit).params("joint; type?, flip?, offset?, angle?, suppressed?, name?"),
|
||||
CommandSpec::new("joint.delete", "Delete Joint", delete).params("joint: id|name (or origin: name, link: index)"),
|
||||
@@ -53,11 +70,40 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
),
|
||||
];
|
||||
|
||||
/// Re-solve the joints and move the occurrences they place (after any edit).
|
||||
/// Re-solve the joints and move the occurrences they place (after any edit). Gears, racks and
|
||||
/// screws first take their ratios from the radii and pitches they are defined by.
|
||||
pub fn resolve(s: &mut Session) -> joints::Solution {
|
||||
recouple(s);
|
||||
resolve_holding(s, &BTreeMap::new())
|
||||
}
|
||||
|
||||
/// Work out the ratios of the coupled motion links again (a radius or a parameter may have
|
||||
/// changed). A coupling that can't be worked out (its face gone) keeps its last ratio;
|
||||
/// `joint.list` says why.
|
||||
fn recouple(s: &mut Session) {
|
||||
if s.doc.assembly.links.iter().all(|l| l.coupling.is_none()) {
|
||||
return;
|
||||
}
|
||||
let st = s.model.state();
|
||||
let changed: Vec<(usize, f64)> = s
|
||||
.doc
|
||||
.assembly
|
||||
.links
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, l)| joints::coupling_ratio(&s.doc, &st, l)?.ok().filter(|r| *r != l.ratio).map(|r| (i, r)))
|
||||
.collect();
|
||||
if changed.is_empty() {
|
||||
return;
|
||||
}
|
||||
let doc = s.doc_mut();
|
||||
for (i, r) in changed {
|
||||
if let Some(l) = doc.assembly.links.get_mut(i) {
|
||||
l.ratio = r;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-solve the joints, keeping the `held` values (the ones being driven, mapped to the values
|
||||
/// they move from) where loops are closed.
|
||||
pub fn resolve_holding(s: &mut Session, held: &joints::Held) -> joints::Solution {
|
||||
@@ -101,6 +147,14 @@ pub(crate) fn named_values(j: &Joint, q: &[f64]) -> Vec<Value> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A joint's values as the solver places it (after motion links, so a screw's travel follows its
|
||||
/// turn), named as `joint.list` gives them.
|
||||
pub(crate) fn effective_values(s: &Session, id: u64) -> Option<Vec<Value>> {
|
||||
let j = s.doc.assembly.joints.iter().find(|j| j.id == id)?;
|
||||
let vals = joints::joint_values(&s.doc.assembly);
|
||||
Some(named_values(j, vals.get(&id).map(Vec::as_slice).unwrap_or(&j.values)))
|
||||
}
|
||||
|
||||
fn occurrence_name(s: &Session, id: u64) -> String {
|
||||
s.doc.occurrences.iter().find(|o| o.id == id).map(|o| o.name.clone()).unwrap_or_else(|| id.to_string())
|
||||
}
|
||||
@@ -495,7 +549,7 @@ fn drive(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let sol = joints::solve(&s.doc, &s.model.state());
|
||||
let mut out = sol_json(&sol, p);
|
||||
out["joint"] = json!(id);
|
||||
out["values"] = json!(s.doc.assembly.joints.iter().find(|j| j.id == id).map(|j| named_values(j, &j.values)));
|
||||
out["values"] = json!(effective_values(s, id));
|
||||
out["stopped_by_contact"] = r["stopped_by_contact"].clone();
|
||||
out["stopped_by_loop"] = r["stopped_by_loop"].clone();
|
||||
out["moved"] = json!(super::motion::moved(&before, &super::motion::positions(s)).next().is_some());
|
||||
@@ -526,11 +580,149 @@ fn motion_link(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
None => 0.0,
|
||||
};
|
||||
let ratio = ratio * ratio_scale(s, a, ia, b, ib);
|
||||
s.doc_mut().assembly.links.push(MotionLink { a, ia, b, ib, ratio, offset });
|
||||
s.doc_mut().assembly.links.push(MotionLink { a, ia, b, ib, ratio, offset, coupling: None });
|
||||
let sol = resolve(s);
|
||||
Ok(sol_json(&sol, p))
|
||||
}
|
||||
|
||||
/// A coupling length: a number (mm), an expression (kept as typed, so it follows the
|
||||
/// parameters), or `{circle: [x, y, z], occurrence?}`: the round face there, by its radius.
|
||||
fn size_param(s: &Session, p: &Value, key: &str, cmd: &str) -> Result<Size> {
|
||||
match p.get(key) {
|
||||
Some(Value::Number(n)) => n.as_f64().map(Size::Length).ok_or_else(|| bad(cmd, format!("`{key}` must be a finite length"))),
|
||||
Some(Value::String(t)) if t.len() <= 1000 => Ok(Size::Expr(t.trim().to_string())),
|
||||
Some(o @ Value::Object(m)) if m.contains_key("circle") => Ok(Size::Circle(origin_param(s, Some(o), cmd)?)),
|
||||
_ => Err(bad(cmd, format!("`{key}` must be a length (mm or an expression) or a round face ({{circle: [x, y, z]}})"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// A joint's first value that turns (an angle), or that travels (a distance).
|
||||
fn value_index(s: &Session, id: u64, turn: bool) -> Option<usize> {
|
||||
let j = s.doc.assembly.joints.iter().find(|j| j.id == id)?;
|
||||
(0..j.kind.dofs().len()).find(|i| j.kind.is_angle(*i) == turn)
|
||||
}
|
||||
|
||||
/// The values a rack or a screw couples: a's turn and b's travel, or a's travel and b's turn.
|
||||
fn turn_and_travel(s: &Session, a: u64, b: u64, cmd: &str) -> Result<(usize, usize)> {
|
||||
match (value_index(s, a, true), value_index(s, b, false), value_index(s, a, false), value_index(s, b, true)) {
|
||||
(Some(ia), Some(ib), ..) | (_, _, Some(ia), Some(ib)) => Ok((ia, ib)),
|
||||
_ => Err(bad(cmd, "couple a joint that turns (revolute) with one that slides (slider)")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Two different joints `a` and `b`.
|
||||
fn joint_pair(s: &Session, p: &Value, cmd: &str) -> Result<(u64, u64)> {
|
||||
let (a, b) = (joint_id(s, p.get("a"), cmd)?, joint_id(s, p.get("b"), cmd)?);
|
||||
if a == b {
|
||||
return Err(bad(cmd, "couple two different joints"));
|
||||
}
|
||||
Ok((a, b))
|
||||
}
|
||||
|
||||
fn reverse(p: &Value) -> bool {
|
||||
bool_(p, "reverse").unwrap_or(false)
|
||||
}
|
||||
|
||||
fn gear(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "joint.gear";
|
||||
let (a, b) = joint_pair(s, p, cmd)?;
|
||||
let turn = |id: u64| value_index(s, id, true).ok_or_else(|| bad(cmd, "gears need two joints that turn (revolute)"));
|
||||
let (ia, ib) = (turn(a)?, turn(b)?);
|
||||
let c = Coupling::Gear {
|
||||
ra: size_param(s, p, "ra", cmd)?,
|
||||
rb: size_param(s, p, "rb", cmd)?,
|
||||
internal: bool_(p, "internal").unwrap_or(false),
|
||||
reverse: reverse(p),
|
||||
};
|
||||
couple(s, p, cmd, MotionLink { a, ia, b, ib, ratio: 1.0, offset: 0.0, coupling: Some(c) })
|
||||
}
|
||||
|
||||
fn rack_pinion(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "joint.rack_pinion";
|
||||
let (a, b) = joint_pair(s, p, cmd)?;
|
||||
let (ia, ib) = turn_and_travel(s, a, b, cmd)?;
|
||||
let c = Coupling::RackPinion { radius: size_param(s, p, "radius", cmd)?, reverse: reverse(p) };
|
||||
couple(s, p, cmd, MotionLink { a, ia, b, ib, ratio: 1.0, offset: 0.0, coupling: Some(c) })
|
||||
}
|
||||
|
||||
fn screw(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "joint.screw";
|
||||
let (a, ia, b, ib) = match p.get("joint") {
|
||||
Some(v) => {
|
||||
let id = joint_id(s, Some(v), cmd)?;
|
||||
match (value_index(s, id, true), value_index(s, id, false)) {
|
||||
(Some(ia), Some(ib)) => (id, ia, id, ib),
|
||||
_ => return Err(bad(cmd, "a screw on one joint needs a joint that turns and slides (cylindrical); or give a and b")),
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let (a, b) = joint_pair(s, p, cmd)?;
|
||||
let (ia, ib) = turn_and_travel(s, a, b, cmd)?;
|
||||
(a, ia, b, ib)
|
||||
}
|
||||
};
|
||||
let c = Coupling::Screw { pitch: size_param(s, p, "pitch", cmd)?, reverse: reverse(p) };
|
||||
couple(s, p, cmd, MotionLink { a, ia, b, ib, ratio: 1.0, offset: 0.0, coupling: Some(c) })
|
||||
}
|
||||
|
||||
/// 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).
|
||||
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") {
|
||||
None => None,
|
||||
Some(v) => {
|
||||
Some(v.as_u64().and_then(|i| usize::try_from(i).ok()).filter(|i| *i < links.len()).ok_or_else(|| bad(cmd, "no such motion link"))?)
|
||||
}
|
||||
};
|
||||
if replace.is_none() && links.len() >= joints::MAX_JOINTS {
|
||||
return Err(bad(cmd, "too many motion links"));
|
||||
}
|
||||
let others: Vec<&MotionLink> = links.iter().enumerate().filter(|(i, _)| Some(*i) != replace).map(|(_, k)| k).collect();
|
||||
let name = |id: u64| s.doc.assembly.joints.iter().find(|j| j.id == id).map(|j| j.name.clone()).unwrap_or_default();
|
||||
if others.iter().any(|k| (k.b, k.ib) == (l.b, l.ib)) {
|
||||
return Err(bad(cmd, format!("{} already follows a motion link: couple it the other way round (as a)", name(l.b))));
|
||||
}
|
||||
// The values that set a's, back through the links: b among them would make a cycle.
|
||||
let mut at = (l.a, l.ia);
|
||||
for _ in 0..=others.len() {
|
||||
if at == (l.b, l.ib) {
|
||||
return Err(bad(cmd, format!("{} already drives {} through motion links", name(l.b), name(l.a))));
|
||||
}
|
||||
match others.iter().find(|k| (k.b, k.ib) == at) {
|
||||
Some(k) => at = (k.a, k.ia),
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
l.ratio = joints::coupling_ratio(&s.doc, &s.model.state(), &l).unwrap_or(Ok(l.ratio)).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) - l.ratio * value(l.a, l.ia),
|
||||
};
|
||||
let shown = l.ratio / ratio_scale(s, l.a, l.ia, l.b, l.ib);
|
||||
let doc = s.doc_mut();
|
||||
let index = match replace {
|
||||
Some(i) => {
|
||||
if let Some(slot) = doc.assembly.links.get_mut(i) {
|
||||
*slot = l;
|
||||
}
|
||||
i
|
||||
}
|
||||
None => {
|
||||
doc.assembly.links.push(l);
|
||||
doc.assembly.links.len() - 1
|
||||
}
|
||||
};
|
||||
let sol = resolve(s);
|
||||
let mut out = sol_json(&sol, p);
|
||||
out["link"] = json!(index);
|
||||
out["ratio"] = json!(shown);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn is_angle(s: &Session, joint: u64, i: usize) -> bool {
|
||||
s.doc.assembly.joints.iter().find(|j| j.id == joint).is_some_and(|j| j.kind.is_angle(i))
|
||||
}
|
||||
@@ -666,7 +858,15 @@ fn list(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let angle = is_angle(s, l.b, l.ib);
|
||||
let offset = if angle { l.offset.to_degrees() } else { l.offset };
|
||||
let ratio = l.ratio / ratio_scale(s, l.a, l.ia, l.b, l.ib);
|
||||
json!({"a": l.a, "ia": l.ia, "b": l.b, "ib": l.ib, "ratio": ratio, "offset": offset, "unit": if angle { "deg" } else { "mm" }})
|
||||
let mut v =
|
||||
json!({"a": l.a, "ia": l.ia, "b": l.b, "ib": l.ib, "ratio": ratio, "offset": offset, "unit": if angle { "deg" } else { "mm" }});
|
||||
if let Some(c) = &l.coupling {
|
||||
v["coupling"] = json!(c);
|
||||
if let Some(Err(e)) = joints::coupling_ratio(&s.doc, &st, l) {
|
||||
v["error"] = json!(e.to_string());
|
||||
}
|
||||
}
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
let origins: Vec<Value> = s.doc.assembly.origins.iter().map(|o| json!({"name": o.name, "occurrence": o.origin.occurrence})).collect();
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
//! Gears, racks and screws: motion links whose ratio comes from radii or a pitch. Expected values
|
||||
//! are analytic: a gear train turns by the ratio of its radii, a rack travels r·θ, a screw
|
||||
//! advances its pitch per turn.
|
||||
|
||||
use super::*;
|
||||
use solidcraft_doc::joints::{Coupling, Size};
|
||||
|
||||
/// A joint's value as `joint.list` gives it (degrees or mm).
|
||||
fn shown(s: &mut Session, joint: &str) -> f64 {
|
||||
let l = run(s, "joint.list", json!({"quiet": true}));
|
||||
let j = l["joints"].as_array().into_iter().flatten().find(|j| j["name"] == joint).cloned().unwrap_or_default();
|
||||
j["values"][0]["value"].as_f64().unwrap_or(f64::NAN)
|
||||
}
|
||||
|
||||
fn close_to(got: f64, want: f64) -> bool {
|
||||
(got - want).abs() < 1e-6
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
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) {
|
||||
part(s, "Base", [-60.0, -60.0, -20.0]);
|
||||
let g = occ(s, "Base");
|
||||
run(s, "occurrence.ground", json!({"occurrence": g, "grounded": true}));
|
||||
}
|
||||
|
||||
/// Gears of pitch radii r1 (a parameter), 20 and 15 in a row: G1 meshes G2 (radii picked on their
|
||||
/// discs), G2 meshes G3 (radii typed).
|
||||
fn gear_train() -> Session {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.add", json!({"name": "r1", "expression": "10 mm"}));
|
||||
base(&mut s);
|
||||
disc(&mut s, "G1", 0.0, "r1");
|
||||
disc(&mut s, "G2", 30.0, "20 mm");
|
||||
disc(&mut s, "G3", 65.0, "15 mm");
|
||||
let r = run(
|
||||
&mut s,
|
||||
"joint.gear",
|
||||
json!({"a": "G1", "b": "G2", "ra": {"occurrence": "G1", "circle": [10, 0, 2.5]}, "rb": {"occurrence": "G2", "circle": [50, 0, 2.5]}}),
|
||||
);
|
||||
assert_eq!((r["link"].clone(), r["ratio"].as_f64().map(|x| close_to(x, -0.5))), (json!(0), Some(true)), "{r}");
|
||||
run(&mut s, "joint.gear", json!({"a": "G2", "b": "G3", "ra": 20, "rb": "15 mm"}));
|
||||
s
|
||||
}
|
||||
|
||||
/// A point on a disc's rim, where the disc is placed.
|
||||
fn rim(s: &Session, name: &str, x: f64, r: f64) -> Vec3 {
|
||||
place(s, name, Vec3::new(x + r, 0.0, 2.5))
|
||||
}
|
||||
|
||||
fn turned(x: f64, r: f64, deg: f64) -> Vec3 {
|
||||
let a = deg.to_radians();
|
||||
Vec3::new(x + r * a.cos(), r * a.sin(), 2.5)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gear_train_of_three_turns_by_its_radii() {
|
||||
let mut s = gear_train();
|
||||
let r = run(&mut s, "joint.drive", json!({"joint": "G1", "value": 90}));
|
||||
assert_eq!(r["conflicts"].as_array().map(Vec::len), Some(0), "{r}");
|
||||
// ω2 = −ω1·r1/r2, ω3 = −ω2·r2/r3 = ω1·r1/r3: external gears turn the other way.
|
||||
assert!(close_to(shown(&mut s, "G2"), -45.0) && close_to(shown(&mut s, "G3"), 60.0), "{}", run(&mut s, "joint.list", json!({})));
|
||||
for (name, x, r, deg) in [("G1", 0.0, 10.0, 90.0), ("G2", 30.0, 20.0, -45.0), ("G3", 65.0, 15.0, 60.0)] {
|
||||
assert!(near(rim(&s, name, x, r), turned(x, r, deg)), "{name}: {:?}", rim(&s, name, x, r));
|
||||
}
|
||||
// The picked radius follows the design: G1 grows to 12, so G2 turns 90·12/20.
|
||||
run(&mut s, "parameters.change", json!({"name": "r1", "expression": "12 mm"}));
|
||||
assert!(close_to(shown(&mut s, "G2"), -54.0) && close_to(shown(&mut s, "G3"), 72.0), "{}", run(&mut s, "joint.list", json!({})));
|
||||
let l = run(&mut s, "joint.list", json!({}));
|
||||
assert!(close_to(l["links"][0]["ratio"].as_f64().unwrap_or(f64::NAN), -0.6), "{l}");
|
||||
assert_eq!(l["links"][0]["coupling"]["type"], "gear", "{l}");
|
||||
// A motion study playing G1 turns the train the same way.
|
||||
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.
|
||||
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}");
|
||||
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!({})));
|
||||
// Undo takes the redefinition back.
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.assembly.links.get(1).and_then(|l| l.coupling.clone()).map(|c| matches!(c, Coupling::Gear { internal: false, .. })), Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gears_save_and_open_with_their_definition() {
|
||||
let mut s = gear_train();
|
||||
run(&mut s, "joint.drive", json!({"joint": "G1", "value": 30}));
|
||||
let text = s.doc.to_json();
|
||||
let back = solidcraft_doc::format::read(&text).unwrap_or_else(|e| panic!("{e}"));
|
||||
assert_eq!(back.assembly.links, s.doc.assembly.links);
|
||||
assert!(matches!(back.assembly.links.first().and_then(|l| l.coupling.as_ref()), Some(Coupling::Gear { ra: Size::Circle(_), .. })));
|
||||
// A link saved before couplings (no `coupling`) opens as a plain link.
|
||||
let mut v: Value = serde_json::from_str(&text).unwrap_or_default();
|
||||
for l in v["assembly"]["links"].as_array_mut().into_iter().flatten() {
|
||||
l.as_object_mut().map(|o| o.remove("coupling"));
|
||||
}
|
||||
let old = solidcraft_doc::format::read(&v.to_string()).unwrap_or_else(|e| panic!("{e}"));
|
||||
assert!(old.assembly.links.iter().all(|l| l.coupling.is_none()) && old.assembly.links.len() == 2);
|
||||
}
|
||||
|
||||
/// A pinion of radius 8 about +Z at the origin, a rack under it sliding along +X, and a stop the
|
||||
/// rack runs into after 10 mm.
|
||||
fn rack() -> Session {
|
||||
let mut s = Session::default();
|
||||
base(&mut s);
|
||||
disc(&mut s, "Pinion", 0.0, "8 mm");
|
||||
for (name, corner, length) in [("Rack", [-20.0, -14.0, 0.0], 40.0), ("Stop", [30.0, -14.0, 0.0], 4.0)] {
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
run(&mut s, "component.create", json!({"name": name}));
|
||||
run(&mut s, "solid.box", json!({"length": length, "width": 4, "height": 4, "corner": corner}));
|
||||
}
|
||||
pin(&mut s, "slider", "Rail", "Base", "Rack", Vec3::new(0.0, -10.0, 0.0), Vec3::X);
|
||||
pin(&mut s, "rigid", "Stop", "Base", "Stop", Vec3::new(30.0, -10.0, 0.0), Vec3::Z);
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rack_travels_r_times_theta() {
|
||||
let mut s = rack();
|
||||
let r = run(&mut s, "joint.rack_pinion", json!({"a": "Pinion", "b": "Rail", "radius": {"occurrence": "Pinion", "circle": [8, 0, 2]}}));
|
||||
// Per degree in the answer, as joint.list gives a link between an angle and a distance.
|
||||
assert!(close_to(r["ratio"].as_f64().unwrap_or(f64::NAN), 8f64.to_radians()), "{r}");
|
||||
run(&mut s, "joint.drive", json!({"joint": "Pinion", "value": 45}));
|
||||
let travel = 8.0 * std::f64::consts::FRAC_PI_4;
|
||||
assert!(close_to(shown(&mut s, "Rail"), travel), "{}", shown(&mut s, "Rail"));
|
||||
// The pinion turns counter-clockwise about +Z; its bottom, where the rack is, moves along +X.
|
||||
assert!(near(place(&s, "Rack", Vec3::new(-20.0, -14.0, 0.0)), Vec3::new(-20.0 + travel, -14.0, 0.0)));
|
||||
// Contact sets hold the rack at the stop, and the pinion with it: 10 mm is 10/8 rad.
|
||||
run(&mut s, "contact.create", json!({"occurrences": ["Rack", "Stop"]}));
|
||||
let r = run(&mut s, "joint.drive", json!({"joint": "Pinion", "value": 180}));
|
||||
assert!(r["stopped_by_contact"].is_object(), "{r}");
|
||||
let (angle, travel) = (shown(&mut s, "Pinion"), shown(&mut s, "Rail"));
|
||||
assert!((angle.to_radians() - 1.25).abs() < 0.01, "{angle}");
|
||||
assert!(close_to(travel, 8.0 * angle.to_radians()), "{travel} at {angle}°");
|
||||
// Either may drive: the rack turning the pinion, reversed.
|
||||
let mut s = rack();
|
||||
let r = run(&mut s, "joint.rack_pinion", json!({"a": "Rail", "b": "Pinion", "radius": "8 mm", "reverse": true}));
|
||||
assert!(close_to(r["ratio"].as_f64().unwrap_or(f64::NAN), -1.0 / 8f64.to_radians()), "{r}");
|
||||
run(&mut s, "joint.drive", json!({"joint": "Rail", "value": 5}));
|
||||
assert!(close_to(shown(&mut s, "Pinion"), -(5.0f64 / 8.0).to_degrees()), "{}", shown(&mut s, "Pinion"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_screw_advances_its_pitch_per_turn() {
|
||||
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);
|
||||
run(&mut s, "joint.screw", json!({"joint": "Thread", "pitch": 1.5}));
|
||||
let r = run(&mut s, "joint.drive", json!({"joint": "Thread", "values": [720]}));
|
||||
assert_eq!(r["conflicts"].as_array().map(Vec::len), Some(0), "{r}");
|
||||
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.
|
||||
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}");
|
||||
// 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"}));
|
||||
base(&mut s);
|
||||
part(&mut s, "Shaft", [-1.0, -1.0, 0.0]);
|
||||
part(&mut s, "Carriage", [10.0, -1.0, 0.0]);
|
||||
pin(&mut s, "revolute", "Spin", "Base", "Shaft", Vec3::ZERO, Vec3::Z);
|
||||
pin(&mut s, "slider", "Lift", "Base", "Carriage", Vec3::new(11.0, 0.0, 0.0), Vec3::Z);
|
||||
run(&mut s, "joint.screw", json!({"a": "Spin", "b": "Lift", "pitch": "lead"}));
|
||||
run(&mut s, "joint.drive", json!({"joint": "Spin", "value": 900}));
|
||||
assert!(close_to(shown(&mut s, "Lift"), 5.0), "{}", shown(&mut s, "Lift"));
|
||||
run(&mut s, "parameters.change", json!({"name": "lead", "expression": "4 mm"}));
|
||||
assert!(close_to(shown(&mut s, "Lift"), 10.0), "{}", shown(&mut s, "Lift"));
|
||||
}
|
||||
|
||||
/// A motor gear turns the four-bar's crank: half as fast, the other way round, and the loop
|
||||
/// follows.
|
||||
#[test]
|
||||
fn a_gear_drives_a_closed_loop() {
|
||||
let (mut s, _, q0) = four_bar();
|
||||
part(&mut s, "Motor", [80.0, -1.0, -5.0]);
|
||||
pin(&mut s, "revolute", "Motor", "Ground", "Motor", Vec3::new(80.0, 0.0, 0.0), Vec3::Z);
|
||||
run(&mut s, "joint.gear", json!({"a": "Motor", "b": "Crank", "ra": 10, "rb": 20}));
|
||||
let r = run(&mut s, "joint.drive", json!({"joint": "Motor", "value": -120}));
|
||||
assert_eq!(r["conflicts"].as_array().map(Vec::len), Some(0), "{r}");
|
||||
assert!(r["stopped_by_loop"].is_null(), "{r}");
|
||||
assert!(close_to(shown(&mut s, "Crank"), 60.0), "{}", shown(&mut s, "Crank"));
|
||||
assert!(near(place(&s, "Rocker", q0), rocker_pin(0.0, 60.0, q0)), "{:?}", place(&s, "Rocker", q0));
|
||||
}
|
||||
|
||||
/// A pinion moves the piston of a slider-crank: the piston travels r·θ along its bore and the
|
||||
/// crank follows, the rod keeping its length.
|
||||
#[test]
|
||||
fn a_rack_drives_a_slider_crank_from_its_piston() {
|
||||
let (mut s, p0, s0) = slider_crank();
|
||||
part(&mut s, "Pinion", [0.0, -30.0, -5.0]);
|
||||
pin(&mut s, "revolute", "Pinion", "Ground", "Pinion", Vec3::new(0.0, -30.0, 0.0), Vec3::Z);
|
||||
run(&mut s, "joint.rack_pinion", json!({"a": "Pinion", "b": "Bore", "radius": 5}));
|
||||
let r = run(&mut s, "joint.drive", json!({"joint": "Pinion", "value": 60}));
|
||||
assert_eq!(r["conflicts"].as_array().map(Vec::len), Some(0), "{r}");
|
||||
assert!(r["stopped_by_loop"].is_null(), "{r}");
|
||||
let travel = 5.0 * 60f64.to_radians();
|
||||
assert!(close_to(shown(&mut s, "Bore"), travel), "{}", shown(&mut s, "Bore"));
|
||||
let wrist = place(&s, "Piston", s0);
|
||||
assert!(near(wrist, s0 + Vec3::X * travel), "{wrist:?}");
|
||||
let pin_at = place(&s, "Rod", p0);
|
||||
assert!((pin_at.dist(wrist) - 40.0).abs() < 1e-6 && (pin_at.len() - 15.0).abs() < 1e-6, "{pin_at:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn couplings_refuse_what_they_cannot_couple() {
|
||||
let mut s = gear_train();
|
||||
let refused = |s: &mut Session, id: &str, p: Value| s.execute(id, &p).err().map(|e| e.to_string()).unwrap_or_default();
|
||||
// G3 already follows G2; a second writer, or a cycle back to G1, is refused.
|
||||
assert!(refused(&mut s, "joint.gear", json!({"a": "G1", "b": "G3", "ra": 1, "rb": 1})).contains("already follows"));
|
||||
assert!(refused(&mut s, "joint.gear", json!({"a": "G3", "b": "G1", "ra": 1, "rb": 1})).contains("already drives"));
|
||||
assert!(refused(&mut s, "joint.gear", json!({"a": "G1", "b": "G1", "ra": 1, "rb": 1})).contains("two different"));
|
||||
// A gear needs turns, a rack a turn and a travel.
|
||||
assert!(refused(&mut s, "joint.rack_pinion", json!({"a": "G1", "b": "G3", "radius": 5})).contains("slides"));
|
||||
// Hostile sizes and indices never panic and change nothing.
|
||||
let before = s.doc.assembly.clone();
|
||||
for bad in [
|
||||
json!(-3),
|
||||
json!(0),
|
||||
json!(1e300),
|
||||
json!("nope"),
|
||||
json!("1/0"),
|
||||
json!({"circle": [1e308, 0, 0]}),
|
||||
json!({"circle": "x"}),
|
||||
json!([1]),
|
||||
Value::Null,
|
||||
] {
|
||||
for (id, p) in [
|
||||
("joint.gear", json!({"a": "G3", "b": "G1", "ra": bad, "rb": 1})),
|
||||
("joint.gear", json!({"a": "G1", "b": "G2", "ra": 1, "rb": bad, "link": 0})),
|
||||
("joint.rack_pinion", json!({"a": "G1", "b": "G2", "radius": bad})),
|
||||
("joint.screw", json!({"joint": "G1", "pitch": bad})),
|
||||
] {
|
||||
assert!(s.execute(id, &p).is_err(), "{id} {p}");
|
||||
}
|
||||
}
|
||||
for bad in [json!(-1), json!(2), json!(1.5), json!(1e300), json!("0"), json!([0]), Value::Null] {
|
||||
let p = json!({"a": "G1", "b": "G2", "ra": 1, "rb": 1, "link": bad});
|
||||
assert!(s.execute("joint.gear", &p).is_err(), "{p}");
|
||||
}
|
||||
assert_eq!(s.doc.assembly, before);
|
||||
}
|
||||
@@ -1178,3 +1178,6 @@ fn a_new_joint_starts_inside_its_limits_and_says_so() {
|
||||
let r = run(&mut s, "joint.as_built", json!({"type": "cylindrical", "a": "A", "b": "B", "name": "Spindle", "limits": [[-30, 30], [0, 5]]}));
|
||||
assert!(r.get("warnings").is_none(), "{r}");
|
||||
}
|
||||
|
||||
#[path = "joints_coupling_tests.rs"]
|
||||
mod coupling;
|
||||
@@ -10,7 +10,7 @@ use solidcraft_geom::Vec3;
|
||||
use solidcraft_kernel::Body;
|
||||
|
||||
use super::CommandSpec;
|
||||
use super::joints::{cannot_move, joint_id, named_values, occurrence_id, resolve_holding, value};
|
||||
use super::joints::{cannot_move, effective_values, joint_id, occurrence_id, resolve_holding, value};
|
||||
use crate::params::{bad, bool_, str_, vec3};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
@@ -350,10 +350,7 @@ fn drive_with_contact(s: &mut Session, want: &[(u64, Vec<f64>)], given: &BTreeSe
|
||||
stopped = None;
|
||||
stopped_by_loop = Some(json!({"joint": name, "fraction": lo}));
|
||||
}
|
||||
let values: Vec<Value> = want
|
||||
.iter()
|
||||
.map(|(id, _)| json!({"joint": id, "values": s.doc.assembly.joints.iter().find(|j| j.id == *id).map(|j| named_values(j, &j.values))}))
|
||||
.collect();
|
||||
let values: Vec<Value> = want.iter().map(|(id, _)| json!({"joint": id, "values": effective_values(s, *id)})).collect();
|
||||
let out = json!({"values": values, "stopped_by_contact": stopped, "stopped_by_loop": stopped_by_loop, "conflicts": sol.conflicts.len()});
|
||||
Ok((out, sol))
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user