//! 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 = (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())); }