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

+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",