Joints: cam and follower coupling (joint.cam) (#1734)

Thanks @SychPL.
This commit is contained in:
Mateusz Sych authored and GitHub committed 2026-10-16 10:26:35 -04:00
1 parent 88f76adbc2
commit 6232fa33c2
27 files changed
+2176 -47

No files matched your search

@@ -0,0 +1,8 @@
//! A motion link can carry a `cam` coupling (`joint.cam`): the cam's `profile` (a sketch's closed
//! profile by its outer loop's curve ids, or picked edges of the cam's bodies with their names),
//! the `follower` (`knife`, `roller` with a `radius`, or `flat`), an optional `tip` (a joint origin
//! where the follower touches) and `reverse` (the side it rides from). The link then also saves
//! `lift`, the follower's values over one turn of the cam, worked out from the profile again
//! whenever the design changes. Older designs need no transformation: a link without them is a
//! plain motion link, as it always was; 0.13.x asks for an update instead of reading a cam's
//! follower as a plain link of ratio 1.
+61 -16
View File
@@ -1,8 +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 (gears, racks and screws are
//! links whose ratio comes from radii or a pitch), and the solver that places occurrences from
//! the joint graph.
//! links whose ratio comes from radii or a pitch; a cam's follower follows a table worked out from
//! its profile, `joints_cam.rs`), 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`
@@ -140,7 +140,8 @@ pub struct Joint {
pub suppressed: bool,
}
/// Value of joint `b` (index `ib`) = ratio · value of joint `a` (index `ia`) + offset.
/// Value of joint `b` (index `ib`) = ratio · value of joint `a` (index `ia`) + offset, or for a
/// cam, its `lift` table at a's value + offset.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct MotionLink {
pub a: u64,
@@ -152,11 +153,29 @@ 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`.
/// Gears, a rack and pinion, a screw or a cam: what the ratio (or a cam's table) comes from.
/// It is worked out from it again whenever the design changes (`coupling_values`), so it
/// follows the radii, the pitch and the cam's profile; the last one worked out is kept in
/// `ratio` and `lift`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub coupling: Option<Coupling>,
/// A cam's follower values over one turn of a (`cam::CAM_SAMPLES` of them, in b's units, the
/// offset left out); empty for every other link.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub lift: Vec<f64>,
}
impl MotionLink {
/// b's value for a's value `x` (before b's limits). A cam's table that is not whole (a
/// hand-edited file) gives no value, so b keeps where it is; a table on any other link is
/// not read.
pub fn follow(&self, x: f64) -> f64 {
match &self.coupling {
Some(Coupling::Cam(_)) if self.lift.len() == cam::CAM_SAMPLES => cam::lift_at(&self.lift, x) + self.offset,
Some(Coupling::Cam(_)) => f64::NAN,
_ => self.ratio * x + self.offset,
}
}
}
/// A length a coupling's ratio comes from: millimetres, an expression over the design's
@@ -199,14 +218,22 @@ pub enum Coupling {
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
reverse: bool,
},
/// A cam on a revolute joint (a) and its follower (b, a slider or a revolute): b rides the
/// cam's profile (`joints_cam.rs`).
Cam(cam::Cam),
}
impl Coupling {
/// The lengths the ratio comes from: a's gear and b's, or the pinion's radius or the pitch.
/// The lengths the ratio comes from: a's gear and b's, the pinion's radius, the pitch, or a
/// cam's roller radius.
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],
Coupling::Cam(c) => match &c.follower {
cam::Follower::Roller { radius } => vec![radius],
_ => Vec::new(),
},
}
}
@@ -214,6 +241,10 @@ impl Coupling {
match self {
Coupling::Gear { ra, rb, .. } => vec![ra, rb],
Coupling::RackPinion { radius: s, .. } | Coupling::Screw { pitch: s, .. } => vec![s],
Coupling::Cam(c) => match &mut c.follower {
cam::Follower::Roller { radius } => vec![radius],
_ => Vec::new(),
},
}
}
}
@@ -231,14 +262,18 @@ fn size_of(doc: &Document, st: &ModelState, s: &Size) -> Result<f64> {
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}")))
Err(DocError::Invalid(format!("a gear, rack, screw or roller 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))
/// The ratio a link's coupling gives it (b's value per a's, in radians and mm) and a cam's table
/// (`MotionLink::lift`; empty for the others), worked out from the geometry and parameters it is
/// defined by; `None` for a plain link.
pub fn coupling_values(doc: &Document, st: &ModelState, l: &MotionLink) -> Option<Result<(f64, Vec<f64>)>> {
l.coupling.as_ref().map(|c| match c {
Coupling::Cam(c) => cam::table(doc, st, l, c).map(|t| (1.0, t)),
c => coupled(doc, st, l, c).map(|r| (r, Vec::new())),
})
}
fn coupled(doc: &Document, st: &ModelState, l: &MotionLink, c: &Coupling) -> Result<f64> {
@@ -260,6 +295,8 @@ fn coupled(doc: &Document, st: &ModelState, l: &MotionLink, c: &Coupling) -> Res
}
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)),
// A cam's follower follows its table (`coupling_values`), not a ratio.
Coupling::Cam(_) => Ok(1.0),
}
}
@@ -491,7 +528,11 @@ fn apply_links(asm: &Assembly, vals: &mut Values) {
let before = linked(vals);
for l in &asm.links {
let Some(src) = vals.get(&l.a).and_then(|q| q.get(l.ia)).copied() else { continue };
let mut next = l.ratio * src + l.offset;
let mut next = l.follow(src);
// A hand-edited table (or source) that gives no number leaves b where it is.
if !next.is_finite() {
continue;
}
if let Some((lo, hi)) = limits.get(&(l.b, l.ib)) {
next = next.max(*lo).min(*hi);
}
@@ -1388,6 +1429,9 @@ impl Document {
}
}
#[path = "joints_cam.rs"]
pub mod cam;
#[path = "joints_reach.rs"]
mod reach;
pub use reach::{REACH_TOL, Reach, Reached, reach};
@@ -1425,7 +1469,8 @@ 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, coupling: None }).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, lift: Vec::new() }).collect();
Assembly { joints, links, ..Assembly::default() }
}
@@ -1477,8 +1522,8 @@ mod tests {
}
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 l = MotionLink { a, ia: 0, b, ib: 0, ratio: 0.0, offset: 0.0, coupling: Some(c), lift: Vec::new() };
coupling_values(&doc, &st, &l).map(|r| r.map(|(ratio, _)| ratio).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)));
File diff suppressed because it is too large. Load diff
@@ -3,6 +3,7 @@
use std::collections::{BTreeMap, BTreeSet};
use solidcraft_doc::Document;
use solidcraft_doc::joints::cam::CamProfile;
use solidcraft_doc::joints::{Assembly, Coupling, JointOrigin, Size, Snap};
use crate::{EngineError, Result};
@@ -102,9 +103,31 @@ fn budget(assembly: &Assembly) -> Result<()> {
return Err(invalid("exploded view contains non-finite moves"));
}
}
if !assembly.links.iter().all(|l| l.ratio.is_finite() && l.offset.is_finite()) {
if !assembly.links.iter().all(|l| l.ratio.is_finite() && l.offset.is_finite() && l.lift.iter().all(|v| v.is_finite())) {
return Err(invalid("motion link contains non-finite values"));
}
for cam in assembly.links.iter().filter_map(|l| match &l.coupling {
Some(Coupling::Cam(c)) => Some(c),
_ => None,
}) {
add_references(&mut references, cam.tip.iter().count())?;
if let Some(tip) = &cam.tip {
finite_origin(tip)?;
}
match &cam.profile {
CamProfile::Sketch { curves, .. } => {
add_references(&mut references, curves.len())?;
curves.iter().try_for_each(|c| text(c))?;
}
CamProfile::Edges { picks, names, .. } => {
add_references(&mut references, picks.len() + names.len())?;
if !picks.iter().all(|p| p.is_finite()) {
return Err(invalid("motion link contains non-finite values"));
}
names.iter().try_for_each(|n| text(n))?;
}
}
}
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")),
@@ -300,12 +323,26 @@ pub(super) fn clone_assembly(
continue;
}
let mut item = old.clone();
// A gear's picked round faces are on occurrences of the copy too.
// A gear's picked round faces are on occurrences of the copy too, and so are a cam's
// profile and its follower's tip.
let faces = item.coupling.as_mut().is_none_or(|c| {
c.sizes_mut().into_iter().all(|size| match size {
let sizes = c.sizes_mut().into_iter().all(|size| match size {
Size::Circle(o) => origin(o, occurrences, features, bodies).map(|new| *o = new).is_some(),
_ => true,
})
});
let Coupling::Cam(cam) = c else { return sizes };
let tip = match &mut cam.tip {
Some(t) => origin(t, occurrences, features, bodies).map(|new| *t = new).is_some(),
None => true,
};
let profile = match &mut cam.profile {
CamProfile::Sketch { sketch, .. } => features.get(sketch).map(|new| *sketch = *new).is_some(),
CamProfile::Edges { occurrence, names, .. } => {
occurrences.get(occurrence).map(|new| *occurrence = *new).is_some()
&& names.iter_mut().all(|n| n.is_empty() || face_name(n, features, bodies).map(|new| *n = new).is_some())
}
};
sizes && tip && profile
});
match (joints.get(&old.a), joints.get(&old.b)) {
(Some(aa), Some(bb)) if faces => {
@@ -44,8 +44,8 @@ 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, coupling: None },
MotionLink { a: 1, ia: 0, b: 3, ib: 0, ratio: 1.0, offset: 0.0, coupling: None },
MotionLink { a: 1, ia: 0, b: 2, ib: 0, ratio: -2.0, offset: 0.3, coupling: None, lift: Vec::new() },
MotionLink { a: 1, ia: 0, b: 3, ib: 0, ratio: 1.0, offset: 0.0, coupling: None, lift: Vec::new() },
];
source.assembly.contact_sets = vec![
ContactSet { id: 1, name: "Internal contact".into(), occurrences: vec![1, 2], suppressed: true },
@@ -106,8 +106,8 @@ fn copied_gears_take_their_picked_faces_along() {
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"))) },
MotionLink { a: 1, ia: 0, b: 2, ib: 0, ratio: -2.0, offset: 0.0, coupling: Some(gear(circle("F1:side"))), lift: Vec::new() },
MotionLink { a: 2, ia: 0, b: 1, ib: 0, ratio: -0.5, offset: 0.0, coupling: Some(gear(circle("F99:side"))), lift: Vec::new() },
];
let mut destination = source.clone();
destination.occurrences.extend([101, 102].map(occurrence));
+142 -13
View File
@@ -7,6 +7,7 @@ use std::collections::{BTreeMap, BTreeSet};
use serde_json::{Value, json};
use solidcraft_doc::expr::Kind;
use solidcraft_doc::joints::cam::{self, Cam, CamProfile, Follower};
use solidcraft_doc::joints::{self, Coupling, Joint, JointKind, JointOrigin, MotionLink, NamedOrigin, Size, Snap};
use solidcraft_geom::{Aabb3, Vec3};
@@ -60,6 +61,9 @@ pub static COMMANDS: &[CommandSpec] = &[
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.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",
),
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)"),
@@ -80,29 +84,30 @@ pub fn resolve(s: &mut Session) -> joints::Solution {
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.
/// Work out the ratios (and cam tables) of the coupled motion links again (a radius, a parameter
/// or a cam's profile may have changed). A coupling that can't be worked out (its face gone)
/// keeps its last ratio and table; `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
let changed: Vec<(usize, (f64, Vec<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)))
.filter_map(|(i, l)| joints::coupling_values(&s.doc, &st, l)?.ok().filter(|(r, t)| *r != l.ratio || *t != l.lift).map(|v| (i, v)))
.collect();
if changed.is_empty() {
return;
}
let doc = s.doc_mut();
for (i, r) in changed {
for (i, (r, t)) in changed {
if let Some(l) = doc.assembly.links.get_mut(i) {
l.ratio = r;
l.lift = t;
}
}
}
@@ -709,7 +714,7 @@ 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, coupling: None });
s.doc_mut().assembly.links.push(MotionLink { a, ia, b, ib, ratio, offset, coupling: None, lift: Vec::new() });
let sol = resolve(s);
Ok(sol_json(&sol, p))
}
@@ -766,7 +771,7 @@ fn gear(s: &mut Session, p: &Value) -> Result<Value> {
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) })
couple(s, p, cmd, MotionLink { a, ia, b, ib, ratio: 1.0, offset: 0.0, coupling: Some(c), lift: Vec::new() })
}
fn rack_pinion(s: &mut Session, p: &Value) -> Result<Value> {
@@ -774,7 +779,7 @@ fn rack_pinion(s: &mut Session, p: &Value) -> Result<Value> {
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) })
couple(s, p, cmd, MotionLink { a, ia, b, ib, ratio: 1.0, offset: 0.0, coupling: Some(c), lift: Vec::new() })
}
fn screw(s: &mut Session, p: &Value) -> Result<Value> {
@@ -794,7 +799,120 @@ fn screw(s: &mut Session, p: &Value) -> Result<Value> {
}
};
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) })
couple(s, p, cmd, MotionLink { a, ia, b, ib, ratio: 1.0, offset: 0.0, coupling: Some(c), lift: Vec::new() })
}
fn cam_cmd(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "joint.cam";
let (a, b) = joint_pair(s, p, cmd)?;
let kind = |id: u64| s.doc.assembly.joints.iter().find(|j| j.id == id).map(|j| j.kind);
if kind(a) != Some(JointKind::Revolute) {
return Err(bad(cmd, "the cam (a) turns on a revolute joint"));
}
let oscillating = match kind(b) {
Some(JointKind::Slider) => false,
Some(JointKind::Revolute) => true,
_ => return Err(bad(cmd, "the follower (b) is a slider or a revolute joint")),
};
let follower = match str_(p, "follower").unwrap_or("knife") {
"knife" => Follower::Knife,
"roller" => Follower::Roller { radius: size_param(s, p, "radius", cmd)? },
"flat" => Follower::Flat,
_ => return Err(bad(cmd, "`follower` is knife, roller or flat")),
};
if oscillating && p.get("tip").is_none() && !matches!(follower, Follower::Roller { radius: Size::Circle(_) }) {
return Err(bad(cmd, "an oscillating follower needs `tip`: its knife's point or its roller's centre ({circle: [x, y, z]} on the roller)"));
}
let tip = match p.get("tip") {
Some(v) => Some(origin_param(s, Some(v), cmd)?),
None => None,
};
let mut c = Cam { profile: cam_profile(s, p, cmd)?, follower, tip, reverse: false };
let probe = MotionLink { a, ia: 0, b, ib: 0, ratio: 1.0, offset: 0.0, coupling: None, lift: Vec::new() };
let st = s.model.state();
c.reverse = cam::default_reverse(&s.doc, &st, &probe, &c).map_err(|e| bad(cmd, e.to_string()))? != bool_(p, "flip").unwrap_or(false);
let offset = cam::follower_offset(&s.doc, &st, &probe, &c).map_err(|e| bad(cmd, e.to_string()))?;
let reverse = c.reverse;
let mut out = couple(s, p, cmd, MotionLink { coupling: Some(Coupling::Cam(c)), ..probe })?;
let index = out["link"].as_u64().and_then(|i| usize::try_from(i).ok()).unwrap_or(usize::MAX);
if let (Some(o), Some(l)) = (out.as_object_mut(), s.doc.assembly.links.get(index)) {
o.remove("ratio");
o.insert("lift".into(), lift_json(l, oscillating));
o.insert("reverse".into(), json!(reverse));
if !oscillating {
o.insert("follower_offset".into(), json!(offset));
}
}
Ok(out)
}
/// A cam's follower travel over a turn of the cam: b's lowest and highest values and their
/// difference, in degrees (`angle`) or mm.
fn lift_json(l: &MotionLink, angle: bool) -> Value {
// A table that is not whole (a hand-edited file) moves nothing, and says so.
if l.lift.len() != cam::CAM_SAMPLES {
return Value::Null;
}
let unit = |v: f64| if angle { v.to_degrees() } else { v };
let (lo, hi) = l.lift.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), v| (lo.min(*v), hi.max(*v)));
let (lo, hi) = (unit(lo + l.offset), unit(hi + l.offset));
json!({"min": lo, "max": hi, "rise": hi - lo, "unit": if angle { "deg" } else { "mm" }})
}
/// A cam's profile: a sketch's closed profile (kept by its outer loop's curve ids, so the sketch's
/// other profiles may change), or picked edges of the cam's bodies (kept in the component's
/// coordinates with the edges' names).
fn cam_profile(s: &Session, p: &Value, cmd: &str) -> Result<CamProfile> {
let v = p.get("profile").ok_or_else(|| bad(cmd, "`profile` is required: {sketch: id|name} or {edges: [[x, y, z], …]}"))?;
if let Some(sk) = v.get("sketch") {
let sketch = super::features::sketch_id(s, Some(sk), cmd, "profile.sketch")?;
let st = s.model.state();
let solved =
st.sketches.iter().find(|x| x.feature == sketch).ok_or_else(|| bad(cmd, "that sketch is not evaluated (it is after the marker?)"))?;
let picked = match v.get("index") {
None => cam::sketch_profile(solved, &[]),
Some(i) => i.as_u64().and_then(|i| usize::try_from(i).ok()).and_then(|i| solved.profiles.get(i)),
};
let profile = picked.ok_or_else(|| bad(cmd, "the sketch has no such closed profile (an open curve makes none)"))?;
return Ok(CamProfile::Sketch { sketch, curves: profile.outer_curves.clone() });
}
let Some(Value::Array(pts)) = v.get("edges") else {
return Err(bad(cmd, "`profile` is {sketch: id|name} or {edges: [[x, y, z], …]}"));
};
if pts.is_empty() || pts.len() > cam::MAX_PROFILE_EDGES {
return Err(bad(cmd, format!("pick 1 to {} edges", cam::MAX_PROFILE_EDGES)));
}
let world: Vec<Vec3> = pts.iter().map(vec3).collect::<Option<_>>().ok_or_else(|| bad(cmd, "`edges` are finite [x, y, z] points"))?;
let occurrence = match v.get("occurrence") {
Some(o) => occurrence_id(s, Some(o), cmd)?,
None => world.first().and_then(|w| occurrence_at(s, *w)).ok_or_else(|| bad(cmd, "no body there"))?,
};
let t = if occurrence == 0 { solidcraft_doc::IDENTITY } else { joints::occurrence_world(&s.doc, occurrence) };
let inv = solidcraft_doc::mat_inverse(&t).ok_or_else(|| bad(cmd, "occurrence transform"))?;
let picks: Vec<Vec3> = world.iter().map(|w| solidcraft_doc::apply_point(&inv, *w)).collect();
let comp = s.doc.occurrences.iter().find(|o| o.id == occurrence).map(|o| o.component).unwrap_or(0);
let st = s.model.state();
let bodies: Vec<&solidcraft_doc::ModelBody> = st.bodies.iter().filter(|b| s.doc.model_body_component(b) == comp).collect();
// Each pick's edge name on the body whose edges pass nearest it.
let names = picks
.iter()
.map(|q| {
let near = |b: &&solidcraft_doc::ModelBody| {
b.mesh()
.edges
.iter()
.flat_map(|e| e.windows(2))
.filter_map(|w| Some(q.dist_to_segment(*w.first()?, *w.get(1)?)))
.fold(f64::MAX, f64::min)
};
bodies
.iter()
.min_by(|x, y| near(x).total_cmp(&near(y)))
.and_then(|b| solidcraft_doc::naming::edge_names_at(b, &[*q]).into_iter().next())
.unwrap_or_default()
})
.collect();
Ok(CamProfile::Edges { occurrence, picks, names })
}
/// Add a coupling's motion link, or put it in place of link `link`: its ratio worked out from its
@@ -826,14 +944,18 @@ fn couple(s: &mut Session, p: &Value, cmd: &str, mut l: MotionLink) -> Result<Va
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()))?;
(l.ratio, l.lift) =
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) - l.ratio * value(l.a, l.ia),
None => 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"));
}
let shown = l.ratio / ratio_scale(s, l.a, l.ia, l.b, l.ib);
let doc = s.doc_mut();
let index = match replace {
@@ -994,10 +1116,17 @@ fn list(s: &mut Session, p: &Value) -> Result<Value> {
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) {
if let Some(Err(e)) = joints::coupling_values(&s.doc, &st, l) {
v["error"] = json!(e.to_string());
}
}
// A cam's follower follows its table, not a ratio: its travel instead.
if matches!(l.coupling, Some(Coupling::Cam(_)))
&& let Some(o) = v.as_object_mut()
{
o.remove("ratio");
o.insert("lift".into(), lift_json(l, angle));
}
v
})
.collect();
+435
View File
@@ -0,0 +1,435 @@
//! Cams: a revolute joint drives a follower that rides the cam's profile. Expected values are
//! analytic: an eccentric circle of radius R whose centre is e from its axis puts a knife-edge
//! follower on a line h from the axis at e·cos θ + √(R² − (h − e·sin θ)²), a roller of radius r
//! where a knife on the circle of radius R + r would be, a flat face at e·cos θ + R; an
//! oscillating follower's roller sits where its arm's circle meets the circle of radius R + r.
use super::*;
use solidcraft_doc::joints::Coupling;
use solidcraft_doc::joints::cam::CamProfile;
/// The cam's eccentricity and radius (mm).
const E: f64 = 10.0;
const R: f64 = 30.0;
fn joint_id(s: &Session, name: &str) -> u64 {
s.doc.assembly.joints.iter().find(|j| j.name == name).map(|j| j.id).unwrap_or(0)
}
/// 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)
}
/// Where a knife edge on a line `h` from the axis stands on the circle of radius `r` about the
/// cam's centre turned by `deg`.
fn knife(deg: f64, h: f64, r: f64) -> f64 {
let t = deg.to_radians();
E * t.cos() + (r * r - (h - E * t.sin()).powi(2)).sqrt()
}
/// A grounded base, and a cam: a disc of radius `rc` (a parameter, 30 mm) about (10, 0), 5 mm thick,
/// drawn in sketch `CamSketch` of its own component, on a revolute joint `Cam` about Z through the
/// origin (the cam is its b side).
fn cam_on_base() -> Session {
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name": "rc", "expression": "30 mm"}));
run(&mut s, "parameters.add", json!({"name": "th", "expression": "5 mm"}));
part(&mut s, "Base", [-90.0, -90.0, -20.0]);
let g = occ(&s, "Base");
run(&mut s, "occurrence.ground", json!({"occurrence": g, "grounded": true}));
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "component.create", json!({"name": "Cam"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "CamSketch"}));
run(&mut s, "sketch.circle.center", json!({"center": [E, 0], "radius": R, "id": "c1"}));
run(&mut s, "sketch.dimension", json!({"entities": ["c1"], "type": "radius", "value": "rc"}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": "th"}));
pin(&mut s, "revolute", "Cam", "Base", "Cam", Vec3::ZERO, Vec3::Z);
s
}
/// A tappet (a 20 mm bar, 4 wide) sliding along +X on the line y = `h`, its end at x = `tip`, on a
/// slider `Tappet`.
fn tappet(s: &mut Session, h: f64, tip: f64) {
run(s, "component.activate", json!({"component": "root"}));
run(s, "component.create", json!({"name": "Tappet"}));
run(s, "solid.box", json!({"length": 20, "width": 4, "height": 5, "corner": [tip, h - 2.0, 0]}));
pin(s, "slider", "Tappet", "Base", "Tappet", Vec3::new(0.0, h, 2.5), Vec3::X);
}
fn close(got: f64, want: f64, tol: f64) -> bool {
(got - want).abs() < tol
}
/// A knife-edge tappet on the eccentric circle, its line through the axis and 6 mm past it, rises
/// and falls as e·cos θ + √(R² − (h − e·sin θ)²); the tappet's body goes with it.
#[test]
fn a_knife_edge_tappet_rides_an_eccentric_circle() {
for h in [0.0, 6.0] {
let mut s = cam_on_base();
let tip = knife(0.0, h, R);
tappet(&mut s, h, tip);
let r = run(&mut s, "joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}}));
assert_eq!((r["link"].clone(), r["reverse"].clone()), (json!(0), json!(false)), "{r}");
assert!(close(r["follower_offset"].as_f64().unwrap_or(f64::NAN), h, 1e-9), "{r}");
// It stays where it is, and its travel over a turn is the cam's.
assert!(close(shown(&mut s, "Tappet"), 0.0, 1e-9), "{r}");
let over_a_turn: Vec<f64> = (0..3600).map(|k| knife(f64::from(k) / 10.0, h, R)).collect();
let rise = over_a_turn.iter().fold(f64::MIN, |a, b| a.max(*b)) - over_a_turn.iter().fold(f64::MAX, |a, b| a.min(*b));
assert!(close(r["lift"]["rise"].as_f64().unwrap_or(f64::NAN), rise, 2e-3), "{r} vs {rise}");
for deg in [30.0f64, 90.0, 135.0, 200.0, 300.0, -45.0, 400.0] {
let d = run(&mut s, "joint.drive", json!({"joint": "Cam", "value": deg}));
assert_eq!(d["conflicts"].as_array().map(Vec::len), Some(0), "{d}");
let want = knife(deg, h, R) - tip;
assert!(close(shown(&mut s, "Tappet"), want, 1e-3), "h {h} θ {deg}: {} vs {want}", shown(&mut s, "Tappet"));
let end = place(&s, "Tappet", Vec3::new(tip, h, 2.5));
assert!(close(end.x, tip + want, 1e-3) && close(end.y, h, 1e-9), "{end:?}");
}
}
}
/// The follower's line is through its tip, wherever its slider's origin is across the line; a
/// guide of its own (grounded, the slider's b side) makes the tappet the slider's a side, and it
/// rides the same way.
#[test]
fn a_tappet_on_a_guide_of_its_own_rides_its_tip_s_line() {
let h = 6.0;
let tip = knife(0.0, h, R);
let mut s = cam_on_base();
part(&mut s, "Guide", [70.0, 20.0, 0.0]);
let g = occ(&s, "Guide");
run(&mut s, "occurrence.ground", json!({"occurrence": g, "grounded": true}));
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "component.create", json!({"name": "Tappet"}));
run(&mut s, "solid.box", json!({"length": 20, "width": 4, "height": 5, "corner": [tip, h - 2.0, 0]}));
// The slider's origins 9 mm across from the tappet's line, a the tappet, b the guide.
pin(&mut s, "slider", "Tappet", "Tappet", "Guide", Vec3::new(0.0, h + 9.0, 2.5), Vec3::X);
let r = run(
&mut s,
"joint.cam",
json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}, "tip": {"occurrence": "Tappet", "point": [tip, h, 2.5]}}),
);
assert!(close(r["follower_offset"].as_f64().unwrap_or(f64::NAN), h, 1e-9), "{r}");
for deg in [70.0f64, 160.0, 290.0] {
run(&mut s, "joint.drive", json!({"joint": "Cam", "value": deg}));
let end = place(&s, "Tappet", Vec3::new(tip, h, 2.5));
assert!(close(end.x, knife(deg, h, R), 1e-3), "θ {deg}: {end:?} vs {}", knife(deg, h, R));
}
}
/// A roller of radius 5 rides the circle of radius R + 5; a flat face e·cos θ + R, and when the
/// cam's radius parameter grows by 2 the flat face stands 2 further out at every angle.
#[test]
fn rollers_and_flat_faces_ride_their_offset_circles() {
let mut s = cam_on_base();
let tip = knife(0.0, 0.0, R + 5.0);
tappet(&mut s, 0.0, tip);
run(&mut s, "joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}, "follower": "roller", "radius": "5 mm"}));
for deg in [60.0f64, 150.0, 250.0] {
run(&mut s, "joint.drive", json!({"joint": "Cam", "value": deg}));
let want = knife(deg, 0.0, R + 5.0) - tip;
assert!(close(shown(&mut s, "Tappet"), want, 1e-3), "roller θ {deg}: {} vs {want}", shown(&mut s, "Tappet"));
}
let mut s = cam_on_base();
tappet(&mut s, 3.0, E + R);
run(&mut s, "joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}, "follower": "flat"}));
for deg in [60.0f64, 150.0, 250.0] {
run(&mut s, "joint.drive", json!({"joint": "Cam", "value": deg}));
let want = E * deg.to_radians().cos() - E;
assert!(close(shown(&mut s, "Tappet"), want, 1e-3), "flat θ {deg}: {} vs {want}", shown(&mut s, "Tappet"));
}
// Editing the profile: the table is worked out again.
run(&mut s, "parameters.change", json!({"name": "rc", "expression": "32 mm"}));
let want = E * 250f64.to_radians().cos() - E + 2.0;
assert!(close(shown(&mut s, "Tappet"), want, 1e-3), "after the edit: {} vs {want}", shown(&mut s, "Tappet"));
}
/// The cam's top edge picked on its body gives the table its sketch gives, and follows the
/// design the same way.
#[test]
fn picked_edges_give_the_sketch_s_table() {
let table = |profile: Value| {
let mut s = cam_on_base();
tappet(&mut s, 0.0, E + R);
run(&mut s, "joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": profile}));
s.doc.assembly.links.first().map(|l| (l.lift.clone(), l.offset)).unwrap_or_default()
};
let (sketch, a) = table(json!({"sketch": "CamSketch"}));
let (edges, b) = table(json!({"edges": [[E + R, 0, 5]]}));
assert_eq!((sketch.len(), edges.len()), (360, 360));
assert!(sketch.iter().zip(&edges).all(|(x, y)| close(*x, *y, 1e-3)) && close(a, b, 1e-3));
// A second disc of the cam's component touching it in the same plane is not part of its loop.
let mut s = cam_on_base();
run(&mut s, "component.activate", json!({"component": "Cam"}));
run(&mut s, "solid.cylinder", json!({"base": [E + R + 20.0, 0, 0], "axis": [0, 0, 1], "radius": 20, "height": 5}));
tappet(&mut s, 0.0, E + R);
run(&mut s, "joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {"edges": [[E - R, 0, 5]]}}));
let touching = s.doc.assembly.links.first().map(|l| l.lift.clone()).unwrap_or_default();
assert!(touching.len() == 360 && touching.iter().zip(&sketch).all(|(x, y)| close(*x, *y, 1e-3)), "{touching:?}");
let mut s = cam_on_base();
tappet(&mut s, 0.0, E + R);
run(&mut s, "joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {"edges": [[E + R, 0, 5]]}}));
assert!(matches!(
s.doc.assembly.links.first().and_then(|l| l.coupling.clone()),
Some(Coupling::Cam(c)) if matches!(&c.profile, CamProfile::Edges { names, .. } if names.first().is_some_and(|n| !n.is_empty()))
));
run(&mut s, "parameters.change", json!({"name": "rc", "expression": "32 mm"}));
// A thicker cam: the picked edge is found at its new height, and its loop with it.
run(&mut s, "parameters.change", json!({"name": "th", "expression": "7 mm"}));
let l = run(&mut s, "joint.list", json!({"quiet": true}));
assert!(l["links"][0].get("error").is_none(), "{l}");
run(&mut s, "joint.drive", json!({"joint": "Cam", "value": 90}));
let want = knife(90.0, 0.0, 32.0) - (E + R);
assert!(close(shown(&mut s, "Tappet"), want, 1e-3), "{} vs {want}", shown(&mut s, "Tappet"));
}
/// An arm of 50 about (−50, 40) with a roller of 5: its roller's centre is where the arm's circle
/// meets the circle of radius R + 5 about the cam's centre, the meeting it turns into first from
/// pointing away from the cam's axis.
#[test]
fn an_oscillating_roller_follower_swings_to_the_offset_circle() {
let (pivot, arm, roller) = (Vec3::new(-50.0, 40.0, 2.5), 50.0, 5.0);
let away = pivot.y.atan2(pivot.x);
let wrap = |a: f64| (a + std::f64::consts::PI).rem_euclid(std::f64::consts::TAU) - std::f64::consts::PI;
let angle = |p: Vec3| (p.y - pivot.y).atan2(p.x - pivot.x);
// Where the roller's centre is at cam angle `deg`, turning the way `turn` says.
let centre = |deg: f64, turn: f64| {
let c = Vec3::new(E * deg.to_radians().cos(), E * deg.to_radians().sin(), 2.5);
let [p, q] = meet(pivot, arm, c, R + roller);
let swept = |x: Vec3| (turn * (angle(x) - away)).rem_euclid(std::f64::consts::TAU);
if swept(p) < swept(q) { p } else { q }
};
let [p, q] = meet(pivot, arm, Vec3::new(E, 0.0, 2.5), R + roller);
let start = if wrap(angle(p) - away).abs() < wrap(angle(q) - away).abs() { p } else { q };
let turn = wrap(angle(start) - away).signum();
let mut s = cam_on_base();
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "component.create", json!({"name": "Arm"}));
run(&mut s, "solid.box", json!({"length": 4, "width": 4, "height": 5, "corner": [start.x - 2.0, start.y - 2.0, 0]}));
pin(&mut s, "revolute", "Arm", "Base", "Arm", pivot, Vec3::Z);
// An oscillating follower needs its tip.
assert!(s.execute("joint.cam", &json!({"a": "Cam", "b": "Arm", "profile": {"sketch": "CamSketch"}, "follower": "roller", "radius": 5})).is_err());
let r = run(
&mut s,
"joint.cam",
json!({"a": "Cam", "b": "Arm", "profile": {"sketch": "CamSketch"}, "follower": "roller", "radius": 5,
"tip": {"occurrence": "Arm", "point": [start.x, start.y, 2.5]}}),
);
assert_eq!((r["reverse"].clone(), r["lift"]["unit"].clone()), (json!(turn < 0.0), json!("deg")), "{r}");
for deg in [40.0f64, 120.0, 210.0, 330.0] {
run(&mut s, "joint.drive", json!({"joint": "Cam", "value": deg}));
let want = wrap(angle(centre(deg, turn)) - angle(start)).to_degrees();
assert!(close(shown(&mut s, "Arm"), want, 1e-3), "θ {deg}: {} vs {want}", shown(&mut s, "Arm"));
let at = place(&s, "Arm", start);
assert!(at.dist(centre(deg, turn)) < 1e-3, "θ {deg}: {at:?}");
}
// The roller's round face picked on its arm is where it touches, and its radius: the same table.
let table = s.doc.assembly.links.first().map(|l| l.lift.clone()).unwrap_or_default();
let mut t = cam_on_base();
run(&mut t, "component.activate", json!({"component": "root"}));
run(&mut t, "component.create", json!({"name": "Arm"}));
run(&mut t, "solid.cylinder", json!({"base": [start.x, start.y, 0], "axis": [0, 0, 1], "radius": roller, "height": 5}));
pin(&mut t, "revolute", "Arm", "Base", "Arm", pivot, Vec3::Z);
let face = [start.x + roller, start.y, 2.5];
run(
&mut t,
"joint.cam",
json!({"a": "Cam", "b": "Arm", "profile": {"sketch": "CamSketch"}, "follower": "roller", "radius": {"occurrence": "Arm", "circle": face}}),
);
let picked = t.doc.assembly.links.first().map(|l| l.lift.clone()).unwrap_or_default();
assert!(picked.len() == 360 && picked.iter().zip(&table).all(|(x, y)| close(*x, *y, 1e-6)));
}
/// A stationary cam: the profile on the grounded part, a rotor turning about it with the tappet's
/// guide on it. Turned by +θ against the cam, the tappet stands where it would on a cam turned by
/// −θ. A guide on the cam's own (grounded) side would turn with the profile: refused.
#[test]
fn a_stationary_cam_drives_a_follower_on_a_turning_rotor() {
let h = 6.0;
let tip = knife(0.0, h, R);
let mut s = cam_on_base();
// The cam is the fixed part: ground it, and take the base off its joint.
let (cam, base) = (occ(&s, "Cam"), occ(&s, "Base"));
run(&mut s, "joint.delete", json!({"joint": "Cam"}));
run(&mut s, "occurrence.ground", json!({"occurrence": cam, "grounded": true}));
run(&mut s, "occurrence.ground", json!({"occurrence": base, "grounded": false}));
pin(&mut s, "revolute", "Turret", "Cam", "Base", Vec3::ZERO, Vec3::Z);
tappet(&mut s, h, tip);
run(&mut s, "joint.cam", json!({"a": "Turret", "b": "Tappet", "profile": {"sketch": "CamSketch"}}));
for deg in [50.0f64, 140.0, 260.0] {
let d = run(&mut s, "joint.drive", json!({"joint": "Turret", "value": deg}));
assert_eq!(d["conflicts"].as_array().map(Vec::len), Some(0), "{d}");
let want = knife(-deg, h, R) - tip;
assert!(close(shown(&mut s, "Tappet"), want, 1e-3), "θ {deg}: {} vs {want}", shown(&mut s, "Tappet"));
}
let mut s = cam_on_base();
let (cam, base) = (occ(&s, "Cam"), occ(&s, "Base"));
run(&mut s, "joint.delete", json!({"joint": "Cam"}));
run(&mut s, "occurrence.ground", json!({"occurrence": cam, "grounded": true}));
run(&mut s, "occurrence.ground", json!({"occurrence": base, "grounded": false}));
part(&mut s, "Rotor", [-90.0, 60.0, -20.0]);
pin(&mut s, "revolute", "Turret", "Cam", "Rotor", Vec3::ZERO, Vec3::Z);
run(&mut s, "occurrence.ground", json!({"occurrence": base, "grounded": true}));
tappet(&mut s, h, tip);
let e = s.execute("joint.cam", &json!({"a": "Turret", "b": "Tappet", "profile": {"sketch": "CamSketch"}})).map_err(|e| e.to_string()).err();
assert!(e.as_deref().is_some_and(|e| e.contains("turns with the cam")), "{e:?}");
}
/// A motion study turning the cam plays the tappet through its analytic travel.
#[test]
fn a_cam_plays_in_a_motion_study() {
let mut s = cam_on_base();
let tip = knife(0.0, 0.0, R);
tappet(&mut s, 0.0, tip);
run(&mut s, "joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}}));
run(&mut s, "motion.study", json!({"name": "Turn", "steps": 10, "keys": [{"joint": "Cam", "points": [[0, 0], [10, 360]]}]}));
for step in [0, 2, 5, 7, 10] {
run(&mut s, "motion.play", json!({"study": "Turn", "step": step}));
let want = knife(36.0 * f64::from(step), 0.0, R) - tip;
assert!(close(shown(&mut s, "Tappet"), want, 1e-3), "step {step}: {} vs {want}", shown(&mut s, "Tappet"));
}
}
/// The tappet is the piston of a slider-crank: a 40 mm rod from its end to a 20 mm crank about
/// (95, 0). Turning the cam moves the tappet, and the loop turns the crank to where the rod's
/// circle meets the crank's, on the branch it started on. A stop in a contact set with the cam and
/// the tappet halts the cam where the tappet reaches it; the cam and the flat tappet only touch.
#[test]
fn a_cam_drives_a_closed_loop_and_stops_at_a_contact() {
let mut s = cam_on_base();
let tip = knife(0.0, 0.0, R);
tappet(&mut s, 0.0, tip);
let (q, end) = (Vec3::new(95.0, 0.0, 2.5), Vec3::new(tip + 20.0, 0.0, 2.5));
let crank_pin = |x: f64| meet(q, 20.0, Vec3::new(x, 0.0, 2.5), 40.0).into_iter().find(|p| p.y > 0.0).unwrap_or(Vec3::ZERO);
let pin0 = crank_pin(end.x);
for (name, at) in [("Rod", end), ("Crank", pin0)] {
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "component.create", json!({"name": name}));
run(&mut s, "solid.box", json!({"length": 2, "width": 2, "height": 2, "corner": [at.x - 1.0, at.y - 1.0, 10]}));
}
pin(&mut s, "revolute", "Crank", "Base", "Crank", q, Vec3::Z);
pin(&mut s, "revolute", "Wrist", "Tappet", "Rod", end, Vec3::Z);
pin(&mut s, "revolute", "Big end", "Rod", "Crank", pin0, Vec3::Z);
run(&mut s, "joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}}));
for deg in [45.0f64, 120.0, 180.0] {
let d = run(&mut s, "joint.drive", json!({"joint": "Cam", "value": deg}));
assert_eq!(d["conflicts"].as_array().map(Vec::len), Some(0), "{d}");
let x = end.x + knife(deg, 0.0, R) - tip;
let at = place(&s, "Crank", pin0);
assert!(at.dist(crank_pin(x)) < 1e-3, "θ {deg}: {at:?} vs {:?}", crank_pin(x));
}
// A flat tappet, a stop 5 mm past where its end is at the top, and contact on.
let mut s = cam_on_base();
tappet(&mut s, 0.0, E + R);
run(&mut s, "joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}, "follower": "flat"}));
run(&mut s, "joint.drive", json!({"joint": "Cam", "value": 180}));
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "component.create", json!({"name": "Stop"}));
run(&mut s, "solid.box", json!({"length": 3, "width": 4, "height": 5, "corner": [E + R + 15.0, -2, 0]}));
let st = occ(&s, "Stop");
run(&mut s, "occurrence.ground", json!({"occurrence": st, "grounded": true}));
run(&mut s, "contact.create", json!({"occurrences": ["Cam", "Tappet", "Stop"]}));
// Touching the cam stops nothing: half a turn moves the tappet 20 mm back up, as far as the stop.
let d = run(&mut s, "joint.drive", json!({"joint": "Cam", "value": 90}));
assert!(d["stopped_by_contact"].is_null(), "{d}");
let d = run(&mut s, "joint.drive", json!({"joint": "Cam", "value": 0}));
assert!(d["stopped_by_contact"].is_object(), "{d}");
let reached = shown(&mut s, "Tappet");
assert!(reached > -5.5 && reached < -4.5, "{reached}");
}
/// What a cam can't be made from is refused, and a table edited by hand never puts a NaN into a
/// joint's value.
#[test]
fn hostile_cams_are_refused() {
let mut s = cam_on_base();
tappet(&mut s, 0.0, E + R);
let refused = |s: &mut Session, p: Value| s.execute("joint.cam", &p).map_err(|e| e.to_string()).err().unwrap_or_else(|| panic!("accepted {p}"));
// An open curve makes no closed profile.
run(&mut s, "component.activate", json!({"component": "Cam"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Open"}));
run(&mut s, "sketch.line", json!({"points": [[0, 0], [20, 5], [10, 20]]}));
run(&mut s, "sketch.finish", json!({}));
let e = refused(&mut s, json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "Open"}}));
assert!(e.contains("closed profile"), "{e}");
// Two closed edges are not one loop; a line past the cam misses it.
let e = refused(&mut s, json!({"a": "Cam", "b": "Tappet", "profile": {"edges": [[E + R, 0, 5], [E + R, 0, 0]]}}));
assert!(e.contains("closed loop"), "{e}");
let mut far = cam_on_base();
tappet(&mut far, 50.0, 0.0);
let e = refused(&mut far, json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}}));
assert!(e.contains("misses"), "{e}");
for p in [
json!({"a": "Tappet", "b": "Cam", "profile": {"sketch": "CamSketch"}}),
json!({"a": "Cam", "b": "Cam", "profile": {"sketch": "CamSketch"}}),
json!({"a": "Cam", "b": "Tappet"}),
json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch", "index": 7}}),
json!({"a": "Cam", "b": "Tappet", "profile": {"edges": []}}),
json!({"a": "Cam", "b": "Tappet", "profile": {"edges": [[1, "x", 0]]}}),
json!({"a": "Cam", "b": "Tappet", "profile": {"edges": vec![[E + R, 0.0, 5.0]; 65]}}),
json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}, "follower": "roller"}),
json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}, "follower": "roller", "radius": -1}),
json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}, "follower": "roller", "radius": "nope"}),
json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}, "follower": "roller", "radius": 1e300}),
json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}, "follower": "spring"}),
json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}, "link": 3}),
] {
refused(&mut s, p);
}
assert!(s.doc.assembly.links.is_empty());
// A table edited by hand: NaN leaves the follower where it is, any length reads.
run(&mut s, "joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}}));
let before = solidcraft_doc::joints::joint_values(&s.doc.assembly);
for lift in [vec![f64::NAN; 360], vec![1.0, 2.0, 3.0], vec![f64::INFINITY]] {
let mut d = (*s.doc).clone();
if let Some(l) = d.assembly.links.first_mut() {
l.lift = lift;
}
let vals = solidcraft_doc::joints::joint_values(&d.assembly);
assert!(vals.values().flatten().all(|v| v.is_finite()), "{vals:?}");
// The follower keeps where it is.
assert_eq!(vals.get(&joint_id(&s, "Tappet")), d.assembly.joints.iter().find(|j| j.name == "Tappet").map(|j| &j.values), "{before:?}");
}
// A table on a plain link is not read.
let mut d = (*s.doc).clone();
if let Some(l) = d.assembly.links.first_mut() {
(l.coupling, l.ratio, l.offset) = (None, 0.0, 3.0);
}
assert_eq!(solidcraft_doc::joints::joint_values(&d.assembly).get(&joint_id(&s, "Tappet")).and_then(|q| q.first()).copied(), Some(3.0));
// A roller's picked face must turn about an axis parallel to the cam's, a tip given or not.
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "component.create", json!({"name": "Pin"}));
run(&mut s, "solid.cylinder", json!({"base": [60, 30, 0], "axis": [1, 0, 0], "radius": 3, "height": 10}));
run(&mut s, "joint.delete", json!({"link": 0}));
let tilted = json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}, "follower": "roller",
"radius": {"occurrence": "Pin", "circle": [65, 33, 0]}, "tip": {"occurrence": "Tappet", "point": [E + R, 0, 2.5]}});
let e = refused(&mut s, tilted);
assert!(e.contains("roller's axis"), "{e}");
}
/// A cam saves and opens with its definition and table; a link saved before cams reads as a
/// plain link.
#[test]
fn cams_save_and_open_with_their_table() {
let mut s = cam_on_base();
tappet(&mut s, 0.0, E + R);
run(&mut s, "joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}, "follower": "roller", "radius": 2}));
run(&mut s, "joint.drive", json!({"joint": "Cam", "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_eq!(back.assembly.links.first().map(|l| l.lift.len()), Some(360));
let l = run(&mut s, "joint.list", json!({}));
let link = &l["links"][0];
assert!(link.get("ratio").is_none() && link["lift"]["unit"] == "mm" && link["coupling"]["type"] == "cam", "{link}");
assert!(link["coupling"].get("lift").is_none(), "{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"), o.remove("lift")));
}
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() && l.lift.is_empty()));
}
+3
View File
@@ -1445,3 +1445,6 @@ fn hostile_trace_params_are_refused() {
#[path = "joints_coupling_tests.rs"]
mod coupling;
#[path = "joints_cam_tests.rs"]
mod cam;
+12 -3
View File
@@ -1,5 +1,5 @@
//! Assembly dialogs: New Component, Joint, As-built Joint, Joint Origin, Rigid Group, Drive
//! Joints, Motion Link, Gear, Rack and Pinion, Screw (`dialogs_coupling`) and Interference. Joint origins are picked by hovering geometry: a face
//! Joints, Motion Link, Gear, Rack and Pinion, Screw, Cam (`dialogs_coupling`) and Interference. Joint origins are picked by hovering geometry: a face
//! snaps to its centre (or a round face to the centre of its nearer end), an edge to its middle
//! (a round edge to its centre), a vertex to itself; the snap shows as a glyph with its z axis
//! before the click. Joints and drives preview on the placed (world) model: the moving
@@ -31,7 +31,7 @@ const TYPE_LABELS: [&str; 7] = ["Rigid", "Revolute", "Slider", "Cylindrical", "P
/// What a joint origin input accepts (snaps on faces, edges and vertices).
const SNAPS: u16 = FACES | EDGES | VERTICES;
/// Commands whose effect is where occurrences sit (previewed on the placed model).
const MOVES: [&str; 11] = [
const MOVES: [&str; 12] = [
"joint.create",
"joint.as_built",
"joint.drive",
@@ -41,6 +41,7 @@ const MOVES: [&str; 11] = [
"joint.gear",
"joint.rack_pinion",
"joint.screw",
"joint.cam",
"parts.insert",
"parts.fastener",
];
@@ -72,6 +73,7 @@ pub enum Asm {
offset: String,
},
Coupling(Box<crate::dialogs_coupling::CouplingForm>),
Cam(Box<crate::dialogs_coupling::cam::CamForm>),
Interference {
result: Option<Value>,
overlaps: Vec<Mesh>,
@@ -151,13 +153,14 @@ impl Asm {
"joint.rack_pinion" => "RACK AND PINION",
_ => "SCREW",
},
Asm::Cam(_) => "CAM",
Asm::Interference { .. } => "INTERFERENCE",
Asm::Trace(_) => "MOTION TRACE",
}
}
pub fn previews(&self) -> bool {
matches!(self, Asm::Joint(_) | Asm::Drive { .. } | Asm::RigidGroup | Asm::Coupling(_))
matches!(self, Asm::Joint(_) | Asm::Drive { .. } | Asm::RigidGroup | Asm::Coupling(_) | Asm::Cam(_))
}
}
@@ -185,6 +188,7 @@ pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
(Asm::MotionLink { a: 0, b, ia: 0, ib: 0, ratio: "1".into(), offset: "0".into() }, vec![])
}
"joint.gear" | "joint.rack_pinion" | "joint.screw" => crate::dialogs_coupling::start(app, id)?,
"joint.cam" => crate::dialogs_coupling::cam::start(app),
"inspect.interference" => {
(Asm::Interference { result: None, overlaps: Vec::new(), of: Vec::new() }, vec![SelInput::new("Bodies (all if none)", BODIES, true)])
}
@@ -686,6 +690,7 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Asm, inputs: &mut [Se
ui.end_row();
}
Asm::Coupling(f) => enter |= crate::dialogs_coupling::rows(app, ui, f, inputs),
Asm::Cam(f) => enter |= crate::dialogs_coupling::cam::rows(app, ui, f, inputs),
Asm::Interference { result, overlaps, of } => {
let picked: Vec<Sel> = inputs.first().map(|i| i.items.clone()).unwrap_or_default();
row_label(ui, "");
@@ -914,6 +919,7 @@ pub fn commands(app: &SolveApp, k: &Asm, inputs: &[SelInput]) -> Result<Vec<(Str
cmd("joint.motion_link", json!({"a": ja.id, "b": jb.id, "ia": ia, "ib": ib, "ratio": ratio, "offset": offset.trim()}))
}
Asm::Coupling(f) => return crate::dialogs_coupling::commands(app, f, inputs),
Asm::Cam(f) => return crate::dialogs_coupling::cam::commands(app, f, inputs),
// The check runs from its Compute button; OK only closes.
Asm::Interference { .. } => Vec::new(),
Asm::Trace(f) => cmd("motion.trace.keep", trace::params(s, f, inputs)?),
@@ -925,6 +931,9 @@ pub fn animate(app: &SolveApp, k: &Asm, cmds: &mut Vec<(String, Value)>) {
if let Asm::Coupling(f) = k {
crate::dialogs_coupling::animate(&app.session, f, cmds);
}
if let Asm::Cam(f) = k {
crate::dialogs_coupling::cam::animate(&app.session, f, cmds);
}
let Asm::Joint(f) = k else { return };
if !f.animate {
return;
+197
View File
@@ -0,0 +1,197 @@
//! The Cam dialog (`joint.cam`): a cam turning on a revolute joint drives a follower, a slider or
//! a revolute, along the cam's profile (a closed sketch profile, or edges of the cam's body). The
//! follower is a knife edge, a roller (its radius typed, or its round face picked, which is also
//! where it touches) or a flat face. Picking the profile on a part chooses the joint that part
//! turns on. Play turns the cam a full turn so the follower shows its motion before OK.
use egui::RichText;
use serde_json::{Value, json};
use solidcraft_engine::Sel;
use solidcraft_engine::Session;
use solidcraft_engine::doc::joints::JointKind;
use super::{ROUND, joint_row, occurrence, pick, picked_radius, trim, turn};
use crate::SolveApp;
use crate::dialogs::{check, combo, field, row_label};
use crate::dialogs_assembly::Asm;
use crate::selection::{EDGES, FACES, PROFILES, SelInput, VERTICES};
use crate::theme::Tokens;
/// The follower types, as `joint.cam` takes them, and their labels.
const FOLLOWERS: [&str; 3] = ["knife", "roller", "flat"];
const FOLLOWER_LABELS: [&str; 3] = ["Knife edge", "Roller", "Flat face"];
/// The inputs: the cam's profile, the roller's round face, the follower's tip.
const PROFILE: usize = 0;
const ROLLER: usize = 1;
const TIP: usize = 2;
#[derive(Clone, Debug)]
pub struct CamForm {
/// The cam's joint and the follower's (ids).
pub a: u64,
pub b: u64,
/// Index into `FOLLOWERS`.
pub follower: usize,
/// The roller's radius, used where no round face is picked.
pub radius: String,
/// Ride the cam from the other side.
pub flip: bool,
/// The body of the profile pick the cam's joint was last chosen from, so a pick chooses once.
pub picked: Option<String>,
pub animate: bool,
/// Animation phase, 0…1.
pub phase: f64,
}
/// Joints of a kind: (id, label).
fn joints_of(s: &Session, kinds: &[JointKind]) -> Vec<(u64, String)> {
s.doc.assembly.joints.iter().filter(|j| kinds.contains(&j.kind)).map(|j| (j.id, format!("{} ({:?})", j.name, j.kind))).collect()
}
pub fn start(app: &SolveApp) -> (Asm, Vec<SelInput>) {
let s = &app.session;
let cams = joints_of(s, &[JointKind::Revolute]);
let followers = joints_of(s, &[JointKind::Slider, JointKind::Revolute]);
let a = cams.first().map(|j| j.0).unwrap_or(0);
// A slider first, else another revolute than the cam's.
let b = followers.iter().find(|j| j.0 != a).map(|j| j.0).unwrap_or(0);
let f = CamForm { a, b, follower: 0, radius: "5 mm".into(), flip: false, picked: None, animate: false, phase: 0.0 };
let inputs = vec![
SelInput::new("Profile", PROFILES | EDGES, true),
SelInput::new("Roller (optional)", ROUND, false),
SelInput::new("Tip (optional)", FACES | EDGES | VERTICES, false),
];
(Asm::Cam(Box::new(f)), inputs)
}
/// The occurrence the profile picked in `inputs` is on: an edge's body's, or a sketch's component's
/// first.
fn profile_occurrence(s: &Session, inputs: &[SelInput]) -> Option<(String, u64)> {
match inputs.get(PROFILE)?.items.first()? {
Sel::Edge { body, .. } => Some((body.clone(), occurrence(s, body)?)),
Sel::Profile { sketch, .. } => {
let comp = s.doc.feature(*sketch).map(|f| f.component)?;
let occ = if comp == 0 { 0 } else { s.doc.occurrence_of(comp)?.id };
Some((format!("sketch {sketch}"), occ))
}
_ => None,
}
}
/// The dialog's rows; true when Enter was pressed in a value.
pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, f: &mut CamForm, inputs: &[SelInput]) -> bool {
let t = Tokens::get();
let s = &app.session;
let (cams, followers) = (joints_of(s, &[JointKind::Revolute]), joints_of(s, &[JointKind::Slider, JointKind::Revolute]));
if cams.is_empty() || followers.len() < 2 && followers.iter().all(|j| cams.iter().any(|c| c.0 == j.0)) {
row_label(ui, "");
ui.label(RichText::new(crate::i18n::tr_label("needs a joint that turns and one that slides or swings")).color(t.text_dim));
ui.end_row();
return false;
}
// A profile picked on a part chooses the joint that part turns on, once.
let at = profile_occurrence(s, inputs);
if at.as_ref().map(|x| &x.0) != f.picked.as_ref() {
if let Some(j) = at.as_ref().and_then(|(_, o)| {
let turning = s.doc.assembly.joints.iter().filter(|j| j.kind == JointKind::Revolute);
turning.clone().find(|j| j.b.occurrence == *o).or(turning.clone().find(|j| j.a.occurrence == *o))
}) {
f.a = j.id;
if f.b == f.a {
f.b = followers.iter().find(|x| x.0 != f.a).map(|x| x.0).unwrap_or(0);
}
}
f.picked = at.map(|x| x.0);
}
let mut enter = false;
joint_row(ui, "Cam", "cam_a", &cams, &mut f.a);
joint_row(ui, "Follower", "cam_b", &followers, &mut f.b);
row_label(ui, "Type");
let labels: Vec<&str> = FOLLOWER_LABELS.iter().map(|l| crate::i18n::tr_label(l)).collect();
combo(ui, "cam_follower", &labels, &mut f.follower);
ui.end_row();
if FOLLOWERS.get(f.follower) == Some(&"roller") {
row_label(ui, "Radius");
match (pick(inputs, ROLLER), picked_radius(s, inputs, ROLLER)) {
(_, Some(r)) => {
ui.label(format!("{} mm ({})", trim(r), crate::i18n::tr_label("picked")));
}
(Some(_), None) => {
ui.label(RichText::new(crate::i18n::tr_label("not a round face")).color(t.warning));
}
(None, None) => enter |= field(ui, &mut f.radius),
}
ui.end_row();
}
row_label(ui, "Other side");
check(ui, &mut f.flip);
ui.end_row();
row_label(ui, "Animate");
let label = if f.animate { "■ Stop" } else { "▶ Play" };
let r = ui.button(label).on_hover_text(crate::i18n::tr_label("Play the joint's motion in the view"));
crate::scenario::publish_handle("dialog:Animate", r.rect.center());
if r.clicked() {
f.animate = !f.animate;
}
ui.end_row();
if f.animate {
let now = ui.input(|i| i.time);
// 72 steps over 6 s: each one a preview.
f.phase = ((now / 6.0).fract() * 72.0).floor() / 72.0;
ui.ctx().request_repaint_after(std::time::Duration::from_millis(30));
}
enter
}
/// The command OK runs.
pub fn commands(app: &SolveApp, f: &CamForm, inputs: &[SelInput]) -> Result<Vec<(String, Value)>, String> {
let s = &app.session;
let joint = |id: u64| {
s.doc.assembly.joints.iter().find(|j| j.id == id).map(|j| j.id).ok_or_else(|| "choose the cam's and the follower's joints".to_string())
};
let (a, b) = (joint(f.a)?, joint(f.b)?);
let items = inputs.get(PROFILE).map(|i| i.items.as_slice()).unwrap_or_default();
let profile = match items.first() {
Some(Sel::Profile { sketch, index }) => json!({"sketch": sketch, "index": index}),
Some(Sel::Edge { body, .. }) => {
let pts: Vec<Value> =
items.iter().filter_map(|x| if let Sel::Edge { point, .. } = x { Some(json!([point.x, point.y, point.z])) } else { None }).collect();
let mut p = json!({ "edges": pts });
if let Some(o) = occurrence(s, body) {
p["occurrence"] = json!(o);
}
p
}
_ => return Err("select the cam's profile (a sketch profile or the cam's edges)".into()),
};
let follower = FOLLOWERS.get(f.follower).copied().unwrap_or("knife");
let mut p = json!({"a": a, "b": b, "profile": profile, "follower": follower, "flip": f.flip});
if follower == "roller" {
p["radius"] = match pick(inputs, ROLLER) {
Some((body, at)) => {
let mut v = json!({"circle": [at.x, at.y, at.z]});
if let Some(o) = occurrence(s, body) {
v["occurrence"] = json!(o);
}
v
}
None => json!(f.radius.trim()),
};
}
if let Some(snap) = inputs.get(TIP).and_then(|i| i.items.first()).and_then(|x| crate::dialogs_assembly::snap_of(s, x)) {
p["tip"] = snap.param;
}
Ok(vec![("joint.cam".to_string(), p)])
}
/// While animating, the preview turns the cam a full turn.
pub fn animate(s: &Session, f: &CamForm, cmds: &mut Vec<(String, Value)>) {
if f.animate {
turn(s, f.a, f.phase, cmds);
}
}
#[cfg(test)]
#[path = "dialogs_cam_tests.rs"]
mod tests;
+112
View File
@@ -0,0 +1,112 @@
use serde_json::json;
use solidcraft_engine::Session;
use solidcraft_engine::geom::Vec3;
use super::*;
use crate::Services;
use crate::dialogs::{Kind, apply_commands, preview_commands};
/// A grounded base, a cam (a disc of radius 30 about (10, 0)) turning on a revolute joint about +Z
/// through the origin, a tappet on a slider along +X, its end on the cam, and a spare part on a
/// revolute joint of its own.
fn valve() -> SolveApp {
let mut s = Session::default();
let mut run = |id: &str, p: Value| s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}"));
run("component.create", json!({"name": "Base"}));
run("solid.box", json!({"length": 4, "width": 4, "height": 4, "corner": [-80, -80, -20]}));
run("component.activate", json!({"component": "root"}));
run("component.create", json!({"name": "Cam"}));
run("solid.cylinder", json!({"base": [10, 0, 0], "axis": [0, 0, 1], "radius": 30, "height": 5}));
run("component.activate", json!({"component": "root"}));
run("component.create", json!({"name": "Tappet"}));
run("solid.box", json!({"length": 20, "width": 4, "height": 5, "corner": [40, -2, 0]}));
run("component.activate", json!({"component": "root"}));
run("component.create", json!({"name": "Spare"}));
run("solid.box", json!({"length": 4, "width": 4, "height": 4, "corner": [-80, 60, 0]}));
run("component.activate", json!({"component": "root"}));
run("occurrence.ground", json!({"occurrence": "Base:1", "grounded": true}));
for (kind, name, z) in [("revolute", "Cam", [0, 0, 1]), ("slider", "Tappet", [1, 0, 0]), ("revolute", "Spare", [0, 0, 1])] {
let at = |o: &str| json!({"occurrence": o, "point": [0, 0, 2.5], "z": z});
run("joint.create", json!({"type": kind, "a": at("Base"), "b": at(name), "flip": true, "name": name}));
}
SolveApp::new(s, Services::default())
}
/// A pick of the mesh edge nearest a point.
fn edge(app: &SolveApp, p: Vec3) -> Sel {
let st = app.session.world_state();
let mut best: Option<(f64, Sel)> = None;
for b in &st.bodies {
for (index, e) in b.mesh().edges.iter().enumerate() {
let d = e.windows(2).filter_map(|w| Some(p.dist_to_segment(*w.first()?, *w.get(1)?))).fold(f64::MAX, f64::min);
if best.as_ref().is_none_or(|(bd, _)| d < *bd) {
best = Some((d, Sel::Edge { body: b.name.clone(), index, point: p }));
}
}
}
best.map(|x| x.1).unwrap()
}
fn joint(app: &SolveApp, name: &str) -> u64 {
app.session.doc.assembly.joints.iter().find(|j| j.name == name).map(|j| j.id).unwrap_or(0)
}
/// Picking the cam's edge chooses the joint it turns on; OK makes the cam, and Play turns the cam
/// in the preview.
#[test]
fn a_picked_cam_edge_makes_the_cam() {
let mut app = valve();
app.start("joint.cam");
let mut d = app.dialog.take().unwrap();
// A second revolute joint for the cam to start on: the pick puts the cam right.
if let Kind::Assembly(Asm::Cam(f)) = &mut d.kind {
(f.a, f.b) = (joint(&app, "Spare"), joint(&app, "Tappet"));
}
d.active = 0;
// The far side of the cam's top edge (its near side touches the tappet).
let pick = edge(&app, Vec3::new(-20.0, 0.0, 5.0));
d.pick(&app.session, pick);
app.dialog = Some(d);
let ctx = egui::Context::default();
for _ in 0..3 {
let input =
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 1000.0))), ..Default::default() };
let mut out = ctx.run_ui(input, |ui| crate::dialogs::show(&mut app, ui.ctx()));
out.textures_delta.clear();
}
let mut d = app.dialog.clone().expect("the dialog stays open");
if let Kind::Assembly(Asm::Cam(f)) = &mut d.kind {
// The joint the picked part turns on, and a follower that is another joint.
assert_eq!((f.a, f.b), (joint(&app, "Cam"), joint(&app, "Tappet")));
(f.follower, f.radius) = (1, "4 mm".into());
}
let c = apply_commands(&app, &d).unwrap();
let (id, p) = &c[0];
assert_eq!((id.as_str(), p["follower"].clone(), p["radius"].clone()), ("joint.cam", json!("roller"), json!("4 mm")), "{p}");
assert!(p["profile"]["edges"][0].is_array() && p["profile"]["occurrence"].is_u64(), "{p}");
if let Kind::Assembly(Asm::Cam(f)) = &mut d.kind {
(f.follower, f.animate, f.phase) = (0, true, 0.25);
}
let cmds = preview_commands(&app, &d).unwrap();
assert_eq!(cmds.get(1).map(|c| (c.0.as_str(), c.1["values"].clone())), Some(("joint.drive", json!(["90 deg"]))), "{cmds:?}");
let c = apply_commands(&app, &d).unwrap();
for (id, p) in c {
app.run(&id, p).unwrap();
}
let l = app.session.doc.assembly.links.first().cloned().unwrap();
assert_eq!(l.lift.len(), 360);
// A quarter turn lowers the knife tappet from 40 to √(30² − 10²).
app.run("joint.drive", json!({"joint": "Cam", "value": 90})).unwrap();
let v = solidcraft_engine::doc::joints::joint_values(&app.session.doc.assembly).get(&joint(&app, "Tappet")).cloned().unwrap_or_default();
assert!((v.first().copied().unwrap_or(f64::NAN) - (800f64.sqrt() - 40.0)).abs() < 1e-3, "{v:?}");
}
/// The dialog's rows pass their English text, which the catalogs translate by its key.
#[test]
fn the_cam_dialog_labels_translate() {
let de = crate::i18n::Locale::for_language("de");
for (english, german) in [("Cam", "Nocken"), ("Follower", "Stößel"), ("Roller", "Rolle"), ("Tip (optional)", "Spitze (optional)")] {
assert_eq!(de.label(english), german);
}
assert_eq!(crate::i18n::Locale::for_language("zh").label("Knife edge"), "尖顶");
}
+16 -6
View File
@@ -1,5 +1,5 @@
//! The Gear, Rack and Pinion and Screw dialogs: two joints coupled by radii or a pitch
//! (`joint.gear`, `joint.rack_pinion`, `joint.screw`). A radius is typed, or comes from the round
//! (`joint.gear`, `joint.rack_pinion`, `joint.screw`); the Cam dialog is `dialogs_cam.rs`. A radius is typed, or comes from the round
//! face picked on the part, and then follows that face when the design changes. Picking a gear's
//! face also picks the joint it turns on. Play turns the driving joint so the coupling shows its
//! motion before OK.
@@ -259,21 +259,31 @@ pub fn commands(app: &SolveApp, f: &CouplingForm, inputs: &[SelInput]) -> Result
Ok(vec![(f.command.to_string(), p)])
}
/// While animating, the preview turns the driving joint a full turn: its first value that turns
/// (a planar joint's is its third), the values before it as they are.
/// While animating, the preview turns the driving joint a full turn.
pub fn animate(s: &Session, f: &CouplingForm, cmds: &mut Vec<(String, Value)>) {
let Some(j) = s.doc.assembly.joints.iter().find(|j| j.id == f.a).filter(|_| f.animate) else { return };
if f.animate {
turn(s, f.a, f.phase, cmds);
}
}
/// Drive `joint` to `phase` of a full turn: its first value that turns (a planar joint's is its
/// third), the values before it as they are.
fn turn(s: &Session, joint: u64, phase: f64, cmds: &mut Vec<(String, Value)>) {
let Some(j) = s.doc.assembly.joints.iter().find(|j| j.id == joint) else { return };
let Some(turn) = (0..j.kind.dofs().len()).find(|i| j.kind.is_angle(*i)) else { return };
let values: Vec<String> = (0..=turn)
.map(|i| match j.values.get(i).copied().unwrap_or(0.0) {
_ if i == turn => format!("{} deg", f.phase * 360.0),
_ if i == turn => format!("{} deg", phase * 360.0),
v if j.kind.is_angle(i) => format!("{} deg", v.to_degrees()),
v => format!("{v} mm"),
})
.collect();
cmds.push(("joint.drive".into(), json!({"joint": f.a, "values": values})));
cmds.push(("joint.drive".into(), json!({"joint": joint, "values": values})));
}
#[path = "dialogs_cam.rs"]
pub mod cam;
#[cfg(test)]
#[path = "dialogs_coupling_tests.rs"]
mod tests;
+10
View File
@@ -207,6 +207,7 @@ fn motion_key(cmds: &[(String, Value)], revision: u64, colors: Colors) -> Option
| "joint.gear"
| "joint.rack_pinion"
| "joint.screw"
| "joint.cam"
)
})
{
@@ -856,6 +857,15 @@ mod tests {
};
assert!(motion_key(&gear("0 deg"), 7, colors()).is_some());
assert_eq!(motion_key(&gear("0 deg"), 7, colors()), motion_key(&gear("90 deg"), 7, colors()));
// So does a cam dialog turning its cam.
let cam = |angle: &str| {
vec![
("joint.cam".to_string(), json!({"a": 1, "b": 2, "profile": {"sketch": 3}})),
("joint.drive".to_string(), json!({"joint": 1, "values": [angle]})),
]
};
assert!(motion_key(&cam("0 deg"), 7, colors()).is_some());
assert_eq!(motion_key(&cam("0 deg"), 7, colors()), motion_key(&cam("90 deg"), 7, colors()));
}
/// Combine previews its result (cut, join or intersect between existing bodies) without an
+1
View File
@@ -19,6 +19,7 @@ const ASSEMBLE: &[&str] = &[
"joint.gear",
"joint.rack_pinion",
"joint.screw",
"joint.cam",
"motion.study",
"motion.trace",
"explode.create",