Joints: gears, rack and pinion, and screws (#1524)
Motion links can carry a coupling (gear pair, rack and pinion, screw) whose ratio follows radii, picked round faces or pitch. Design format 23. Co-authored-by: SychPL <SychPL@users.noreply.github.com> Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
29 files changed
+1476
-54
No files matched your search
@@ -0,0 +1,279 @@
|
||||
//! 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
|
||||
//! 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.
|
||||
|
||||
use egui::RichText;
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_engine::Sel;
|
||||
use solidcraft_engine::Session;
|
||||
use solidcraft_engine::doc::expr::Kind as ValueKind;
|
||||
|
||||
use crate::SolveApp;
|
||||
use crate::dialogs::{check, combo, field, row_label};
|
||||
use crate::dialogs_assembly::Asm;
|
||||
use crate::selection::{EDGES, FACES, SelInput};
|
||||
use crate::theme::Tokens;
|
||||
|
||||
/// What a round-face input takes: a face, or an edge of one.
|
||||
const ROUND: u16 = FACES | EDGES;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CouplingForm {
|
||||
/// `joint.gear`, `joint.rack_pinion` or `joint.screw`.
|
||||
pub command: &'static str,
|
||||
/// The driving joint and the driven one (ids; the same cylindrical joint for a screw on one).
|
||||
pub a: u64,
|
||||
pub b: u64,
|
||||
/// Typed sizes, used where no round face is picked: gear 1's and gear 2's radii, the pinion's
|
||||
/// radius, or the screw's pitch.
|
||||
pub sizes: [String; 2],
|
||||
pub internal: bool,
|
||||
pub reverse: bool,
|
||||
/// The bodies of the picks the joints were last chosen from, so a pick chooses once.
|
||||
pub picked: [Option<String>; 2],
|
||||
pub animate: bool,
|
||||
/// Animation phase, 0…1.
|
||||
pub phase: f64,
|
||||
}
|
||||
|
||||
/// Joints with a value that turns (`turn`) or slides: (id, label).
|
||||
fn joints_that(s: &Session, turn: bool) -> Vec<(u64, String)> {
|
||||
s.doc
|
||||
.assembly
|
||||
.joints
|
||||
.iter()
|
||||
.filter(|j| (0..j.kind.dofs().len()).any(|i| j.kind.is_angle(i) == turn))
|
||||
.map(|j| (j.id, format!("{} ({:?})", j.name, j.kind)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The joint roles and whether the driven one turns (a gear) or slides.
|
||||
fn roles(command: &str) -> ([&'static str; 2], bool) {
|
||||
match command {
|
||||
"joint.gear" => (["Gear 1", "Gear 2"], true),
|
||||
"joint.rack_pinion" => (["Pinion", "Rack"], false),
|
||||
_ => (["Screw", "Nut"], false),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start(app: &SolveApp, id: &str) -> Option<(Asm, Vec<SelInput>)> {
|
||||
let s = &app.session;
|
||||
let (_, turn_b) = roles(id);
|
||||
let (turns, b_list) = (joints_that(s, true), joints_that(s, turn_b));
|
||||
let first = |l: &[(u64, String)]| l.first().map(|j| j.0).unwrap_or(0);
|
||||
let (command, a, b, sizes, inputs) = match id {
|
||||
"joint.gear" => (
|
||||
"joint.gear",
|
||||
first(&turns),
|
||||
turns.get(1).map(|j| j.0).unwrap_or(0),
|
||||
["10 mm".to_string(), "20 mm".to_string()],
|
||||
vec![SelInput::new("Gear 1 (optional)", ROUND, false), SelInput::new("Gear 2 (optional)", ROUND, false)],
|
||||
),
|
||||
"joint.rack_pinion" => (
|
||||
"joint.rack_pinion",
|
||||
first(&turns),
|
||||
first(&b_list),
|
||||
["10 mm".to_string(), String::new()],
|
||||
vec![SelInput::new("Pinion (optional)", ROUND, false)],
|
||||
),
|
||||
"joint.screw" => {
|
||||
// A joint that turns and slides is a screw on its own.
|
||||
let both = turns.iter().find(|t| b_list.iter().any(|l| l.0 == t.0)).map(|t| t.0);
|
||||
let a = both.unwrap_or(first(&turns));
|
||||
("joint.screw", a, both.unwrap_or(first(&b_list)), ["1.5 mm".to_string(), String::new()], vec![])
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
let f = CouplingForm { command, a, b, sizes, internal: false, reverse: false, picked: [None, None], animate: false, phase: 0.0 };
|
||||
Some((Asm::Coupling(Box::new(f)), inputs))
|
||||
}
|
||||
|
||||
/// The occurrence placing a picked body (0: the root), by the body's placement: each instance of
|
||||
/// a copied gear is an occurrence of its own.
|
||||
fn occurrence(s: &Session, body: &str) -> Option<u64> {
|
||||
s.doc.body_frames(&s.model.state()).into_iter().find(|f| f.0 == body).map(|(_, _, path, _)| path.last().copied().unwrap_or(0))
|
||||
}
|
||||
|
||||
fn pick(inputs: &[SelInput], i: usize) -> Option<(&str, solidcraft_engine::geom::Vec3)> {
|
||||
match inputs.get(i)?.items.first()? {
|
||||
Sel::Face { body, point, .. } | Sel::Edge { body, point, .. } => Some((body.as_str(), *point)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The radius of the round face picked in input `i`.
|
||||
fn picked_radius(s: &Session, inputs: &[SelInput], i: usize) -> Option<f64> {
|
||||
let (body, point) = pick(inputs, i)?;
|
||||
let st = s.world_state();
|
||||
solidcraft_engine::kernel::cylinder_face_at(&st.body(body)?.body, point).map(|c| c.radius)
|
||||
}
|
||||
|
||||
/// Size `i` in mm: the picked face's radius, or the typed one.
|
||||
fn size(s: &Session, f: &CouplingForm, inputs: &[SelInput], i: usize) -> Option<f64> {
|
||||
picked_radius(s, inputs, i).or_else(|| s.doc.eval(f.sizes.get(i)?, ValueKind::Length).ok()).filter(|v| v.is_finite() && *v > 0.0)
|
||||
}
|
||||
|
||||
/// A joint row: the joints `list` offers, `sel` the chosen id.
|
||||
fn joint_row(ui: &mut egui::Ui, label: &str, salt: &str, list: &[(u64, String)], sel: &mut u64) {
|
||||
let labels: Vec<&str> = list.iter().map(|j| j.1.as_str()).collect();
|
||||
let mut pos = list.iter().position(|j| j.0 == *sel).unwrap_or(0);
|
||||
row_label(ui, label);
|
||||
combo(ui, salt, &labels, &mut pos);
|
||||
ui.end_row();
|
||||
*sel = list.get(pos).map(|j| j.0).unwrap_or(0);
|
||||
}
|
||||
|
||||
/// The dialog's rows; true when Enter was pressed in a value.
|
||||
pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, f: &mut CouplingForm, inputs: &[SelInput]) -> bool {
|
||||
let t = Tokens::get();
|
||||
let s = &app.session;
|
||||
let ([role_a, role_b], turn_b) = roles(f.command);
|
||||
let (la, lb) = (joints_that(s, true), joints_that(s, turn_b));
|
||||
if la.is_empty() || lb.is_empty() {
|
||||
row_label(ui, "");
|
||||
let need = if turn_b { "needs two joints that turn" } else { "needs a joint that turns and one that slides" };
|
||||
ui.label(RichText::new(crate::i18n::tr_label(need)).color(t.text_dim));
|
||||
ui.end_row();
|
||||
return false;
|
||||
}
|
||||
// A face picked on a part chooses the joint that part turns on, once.
|
||||
for (i, slot) in [&mut f.a, &mut f.b].into_iter().enumerate() {
|
||||
let body = pick(inputs, i).map(|(b, _)| b.to_string());
|
||||
if body.is_some() && body != f.picked.get(i).cloned().flatten() {
|
||||
let turning = s.doc.assembly.joints.iter().filter(|j| la.iter().any(|l| l.0 == j.id));
|
||||
if let Some(j) = body
|
||||
.as_deref()
|
||||
.and_then(|b| occurrence(s, b))
|
||||
.and_then(|o| turning.clone().find(|j| j.b.occurrence == o).or(turning.clone().find(|j| j.a.occurrence == o)))
|
||||
{
|
||||
*slot = j.id;
|
||||
}
|
||||
}
|
||||
if let Some(p) = f.picked.get_mut(i) {
|
||||
*p = body;
|
||||
}
|
||||
}
|
||||
let mut enter = false;
|
||||
let size_row = |ui: &mut egui::Ui, label: &str, i: usize, text: &mut String, enter: &mut bool| {
|
||||
row_label(ui, label);
|
||||
match (pick(inputs, i), picked_radius(s, inputs, i)) {
|
||||
(_, Some(r)) => {
|
||||
ui.label(format!("{} mm ({})", trim(r), crate::i18n::tr_label("picked")));
|
||||
}
|
||||
// A pick on a flat face or a straight edge has no radius: say so rather than fail on OK.
|
||||
(Some(_), None) => {
|
||||
ui.label(RichText::new(crate::i18n::tr_label("not a round face")).color(t.warning));
|
||||
}
|
||||
(None, None) => *enter |= field(ui, text),
|
||||
}
|
||||
ui.end_row();
|
||||
};
|
||||
joint_row(ui, role_a, "cp_a", &la, &mut f.a);
|
||||
let [s0, s1] = &mut f.sizes;
|
||||
match f.command {
|
||||
"joint.gear" => {
|
||||
size_row(ui, "Radius 1", 0, s0, &mut enter);
|
||||
joint_row(ui, role_b, "cp_b", &lb, &mut f.b);
|
||||
size_row(ui, "Radius 2", 1, s1, &mut enter);
|
||||
row_label(ui, "Internal gear");
|
||||
check(ui, &mut f.internal);
|
||||
ui.end_row();
|
||||
}
|
||||
"joint.rack_pinion" => {
|
||||
size_row(ui, "Radius", 0, s0, &mut enter);
|
||||
joint_row(ui, role_b, "cp_b", &lb, &mut f.b);
|
||||
}
|
||||
_ => {
|
||||
joint_row(ui, role_b, "cp_b", &lb, &mut f.b);
|
||||
row_label(ui, "Pitch");
|
||||
enter |= field(ui, s0);
|
||||
ui.end_row();
|
||||
}
|
||||
}
|
||||
row_label(ui, "Reverse");
|
||||
check(ui, &mut f.reverse);
|
||||
ui.end_row();
|
||||
// What the coupling does, as joint.list gives a link's ratio.
|
||||
let sign = |neg: bool| if neg { -1.0 } else { 1.0 };
|
||||
let shown = match f.command {
|
||||
"joint.gear" => size(s, f, inputs, 0)
|
||||
.zip(size(s, f, inputs, 1))
|
||||
.map(|(ra, rb)| format!("{} deg / deg", trim(ra / rb * sign(!f.internal) * sign(f.reverse)))),
|
||||
"joint.rack_pinion" => size(s, f, inputs, 0).map(|r| format!("{} mm / deg", trim(r.to_radians() * sign(f.reverse)))),
|
||||
_ => size(s, f, inputs, 0).map(|p| format!("{} mm / deg", trim(p / 360.0 * sign(f.reverse)))),
|
||||
};
|
||||
row_label(ui, "Ratio");
|
||||
ui.label(RichText::new(shown.unwrap_or_else(|| "-".into())).color(t.text_dim));
|
||||
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);
|
||||
// 48 steps over 4 s: each one a preview.
|
||||
f.phase = ((now / 4.0).fract() * 48.0).floor() / 48.0;
|
||||
ui.ctx().request_repaint_after(std::time::Duration::from_millis(30));
|
||||
}
|
||||
enter
|
||||
}
|
||||
|
||||
/// A number without trailing zeros.
|
||||
fn trim(v: f64) -> String {
|
||||
let s = format!("{v:.4}");
|
||||
let s = s.trim_end_matches('0').trim_end_matches('.');
|
||||
if s == "-0" { "0".into() } else { s.into() }
|
||||
}
|
||||
|
||||
/// The command OK runs.
|
||||
pub fn commands(app: &SolveApp, f: &CouplingForm, 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 joints to couple".to_string());
|
||||
let (a, b) = (joint(f.a)?, joint(f.b)?);
|
||||
// A picked face, or the typed size.
|
||||
let size = |i: usize| -> Value {
|
||||
match pick(inputs, i) {
|
||||
Some((body, p)) => {
|
||||
let mut v = json!({"circle": [p.x, p.y, p.z]});
|
||||
if let Some(o) = occurrence(s, body) {
|
||||
v["occurrence"] = json!(o);
|
||||
}
|
||||
v
|
||||
}
|
||||
None => json!(f.sizes.get(i).map(|t| t.trim()).unwrap_or("")),
|
||||
}
|
||||
};
|
||||
let mut p = match f.command {
|
||||
"joint.gear" => json!({"a": a, "b": b, "ra": size(0), "rb": size(1), "internal": f.internal}),
|
||||
"joint.rack_pinion" => json!({"a": a, "b": b, "radius": size(0)}),
|
||||
_ if a == b => json!({"joint": a, "pitch": f.sizes[0].trim()}),
|
||||
_ => json!({"a": a, "b": b, "pitch": f.sizes[0].trim()}),
|
||||
};
|
||||
p["reverse"] = json!(f.reverse);
|
||||
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.
|
||||
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 };
|
||||
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),
|
||||
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})));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "dialogs_coupling_tests.rs"]
|
||||
mod tests;
|
||||
Reference in new issue
Block a user