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
@@ -1,5 +1,5 @@
|
||||
//! Assembly dialogs: New Component, Joint, As-built Joint, Joint Origin, Rigid Group, Drive
|
||||
//! Joints, Motion Link and Interference. Joint origins are picked by hovering geometry: a face
|
||||
//! Joints, Motion Link, Gear, Rack and Pinion, Screw (`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
|
||||
@@ -27,8 +27,19 @@ 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; 8] =
|
||||
["joint.create", "joint.as_built", "joint.drive", "joint.edit", "joint.limits", "joint.rigid_group", "parts.insert", "parts.fastener"];
|
||||
const MOVES: [&str; 11] = [
|
||||
"joint.create",
|
||||
"joint.as_built",
|
||||
"joint.drive",
|
||||
"joint.edit",
|
||||
"joint.limits",
|
||||
"joint.rigid_group",
|
||||
"joint.gear",
|
||||
"joint.rack_pinion",
|
||||
"joint.screw",
|
||||
"parts.insert",
|
||||
"parts.fastener",
|
||||
];
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum Asm {
|
||||
@@ -56,6 +67,7 @@ pub enum Asm {
|
||||
ratio: String,
|
||||
offset: String,
|
||||
},
|
||||
Coupling(Box<crate::dialogs_coupling::CouplingForm>),
|
||||
Interference {
|
||||
result: Option<Value>,
|
||||
overlaps: Vec<Mesh>,
|
||||
@@ -128,12 +140,17 @@ impl Asm {
|
||||
Asm::RigidGroup => "RIGID GROUP",
|
||||
Asm::Drive { .. } => "DRIVE JOINTS",
|
||||
Asm::MotionLink { .. } => "MOTION LINK",
|
||||
Asm::Coupling(f) => match f.command {
|
||||
"joint.gear" => "GEAR",
|
||||
"joint.rack_pinion" => "RACK AND PINION",
|
||||
_ => "SCREW",
|
||||
},
|
||||
Asm::Interference { .. } => "INTERFERENCE",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn previews(&self) -> bool {
|
||||
matches!(self, Asm::Joint(_) | Asm::Drive { .. } | Asm::RigidGroup)
|
||||
matches!(self, Asm::Joint(_) | Asm::Drive { .. } | Asm::RigidGroup | Asm::Coupling(_))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,6 +183,7 @@ pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
|
||||
let b = usize::from(movable(s).len() > 1);
|
||||
(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)?,
|
||||
"inspect.interference" => {
|
||||
(Asm::Interference { result: None, overlaps: Vec::new(), of: Vec::new() }, vec![SelInput::new("Bodies (all if none)", BODIES, true)])
|
||||
}
|
||||
@@ -657,6 +675,7 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Asm, inputs: &mut [Se
|
||||
enter |= field(ui, offset);
|
||||
ui.end_row();
|
||||
}
|
||||
Asm::Coupling(f) => enter |= crate::dialogs_coupling::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, "");
|
||||
@@ -883,6 +902,7 @@ pub fn commands(app: &SolveApp, k: &Asm, inputs: &[SelInput]) -> Result<Vec<(Str
|
||||
let ratio = eval(ratio, ValueKind::Unitless)?;
|
||||
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),
|
||||
// The check runs from its Compute button; OK only closes.
|
||||
Asm::Interference { .. } => Vec::new(),
|
||||
})
|
||||
@@ -890,6 +910,9 @@ pub fn commands(app: &SolveApp, k: &Asm, inputs: &[SelInput]) -> Result<Vec<(Str
|
||||
|
||||
/// The preview's commands: OK's, with the joint's values playing its motion while animating.
|
||||
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);
|
||||
}
|
||||
let Asm::Joint(f) = k else { return };
|
||||
if !f.animate {
|
||||
return;
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,198 @@
|
||||
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 with discs G1 (radius 10 at x = 0) and G2 (radius 20 at x = 30) turning on
|
||||
/// revolute joints about +Z, and a nut on a cylindrical joint.
|
||||
fn discs() -> SolveApp {
|
||||
let mut s = Session::default();
|
||||
let mut run = |id: &str, p: Value| s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}"));
|
||||
for (name, base, r) in [("Base", [0, -60, -20], 2), ("G1", [0, 0, 0], 10), ("G2", [30, 0, 0], 20), ("Nut", [0, 60, 0], 3)] {
|
||||
run("component.activate", json!({"component": "root"}));
|
||||
run("component.create", json!({ "name": name }));
|
||||
run("solid.cylinder", json!({"base": base, "axis": [0, 0, 1], "radius": r, "height": 5}));
|
||||
}
|
||||
run("component.activate", json!({"component": "root"}));
|
||||
run("occurrence.ground", json!({"occurrence": "Base:1", "grounded": true}));
|
||||
for (kind, name, x, y) in [("revolute", "G1", 0, 0), ("revolute", "G2", 30, 0), ("cylindrical", "Nut", 0, 60)] {
|
||||
let at = |o: &str| json!({"occurrence": o, "point": [x, y, 0], "z": [0, 0, 1]});
|
||||
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 round wall a point of the exact geometry lies on (the facets of the wall's mesh
|
||||
/// cut inside it, so the nearest face of any body).
|
||||
fn wall(app: &SolveApp, p: Vec3) -> Sel {
|
||||
let st = app.session.world_state();
|
||||
let mut best: Option<(f64, Sel)> = None;
|
||||
for b in &st.bodies {
|
||||
let m = b.mesh();
|
||||
for (t, f) in m.triangles.iter().zip(&m.tri_face) {
|
||||
let Some([a, bb, c]) = m.tri(t) else { continue };
|
||||
let d = crate::preview::point_triangle_dist(p, a, bb, c);
|
||||
if best.as_ref().is_none_or(|(bd, _)| d < *bd) {
|
||||
best = Some((d, Sel::Face { body: b.name.clone(), index: *f as usize, 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)
|
||||
}
|
||||
|
||||
/// The text of every text shape in a frame's output.
|
||||
fn texts(out: &egui::FullOutput) -> Vec<String> {
|
||||
out.shapes
|
||||
.iter()
|
||||
.filter_map(|c| match &c.shape {
|
||||
egui::Shape::Text(t) => Some(t.galley.text().to_string()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Picking a gear's round face chooses the joint it turns on and takes its radius; the ratio
|
||||
/// row says what the pair does, and OK makes the coupling.
|
||||
#[test]
|
||||
fn picked_gear_faces_choose_their_joints_and_radii() {
|
||||
let mut app = discs();
|
||||
app.start("joint.gear");
|
||||
let mut d = app.dialog.take().unwrap();
|
||||
// G2 first, G1 second: the joints follow the picks, whatever the dialog started on.
|
||||
for (i, p) in [Vec3::new(50.0, 0.0, 2.5), Vec3::new(10.0, 0.0, 2.5)].into_iter().enumerate() {
|
||||
d.active = i;
|
||||
let pick = wall(&app, p);
|
||||
d.pick(&app.session, pick);
|
||||
}
|
||||
app.dialog = Some(d);
|
||||
let ctx = egui::Context::default();
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 1000.0));
|
||||
let frame = |app: &mut SolveApp| {
|
||||
let input = egui::RawInput { screen_rect: Some(screen), ..Default::default() };
|
||||
let mut out = ctx.run_ui(input, |ui| crate::dialogs::show(app, ui.ctx()));
|
||||
// Nothing is painted: drop the texture changes (an unapplied delta panics on drop).
|
||||
out.textures_delta.clear();
|
||||
out
|
||||
};
|
||||
frame(&mut app);
|
||||
let out = frame(&mut app);
|
||||
let shown = texts(&out);
|
||||
assert!(shown.iter().any(|t| t == "-2 deg / deg"), "{shown:?}");
|
||||
assert!(shown.iter().any(|t| t.starts_with("20 mm")), "{shown:?}");
|
||||
let d = app.dialog.as_ref().expect("the dialog stays open");
|
||||
let c = apply_commands(&app, d).unwrap();
|
||||
let (id, p) = &c[0];
|
||||
assert_eq!((id.as_str(), p["a"].clone(), p["b"].clone()), ("joint.gear", json!(joint(&app, "G2")), json!(joint(&app, "G1"))), "{p}");
|
||||
assert!(p["ra"]["circle"].is_array() && p["rb"]["occurrence"].is_u64(), "{p}");
|
||||
// Play turns the driving gear in the preview.
|
||||
let mut d = d.clone();
|
||||
if let Kind::Assembly(Asm::Coupling(f)) = &mut d.kind {
|
||||
f.animate = true;
|
||||
f.phase = 0.25;
|
||||
}
|
||||
let cmds = preview_commands(&app, &d).unwrap();
|
||||
assert_eq!(cmds.get(1).map(|c| c.1["values"].clone()), Some(json!(["90 deg"])), "{cmds:?}");
|
||||
for (id, p) in c {
|
||||
app.run(&id, p).unwrap();
|
||||
}
|
||||
let l = app.session.doc.assembly.links.first().cloned().unwrap();
|
||||
assert!((l.ratio + 2.0).abs() < 1e-9, "{l:?}");
|
||||
}
|
||||
|
||||
/// Two instances of one gear (a copied component) are two occurrences, each on its own joint:
|
||||
/// picks on each choose each one's joint, and the engine finds each pick on its own instance.
|
||||
#[test]
|
||||
fn instances_of_one_gear_are_coupled_as_two() {
|
||||
let mut s = Session::default();
|
||||
let mut run = |id: &str, p: Value| s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}"));
|
||||
for (name, r) in [("Base", 2), ("Twin", 15)] {
|
||||
run("component.activate", json!({"component": "root"}));
|
||||
run("component.create", json!({ "name": name }));
|
||||
run("solid.cylinder", json!({"base": [0, 0, if name == "Base" { -20 } else { 0 }], "axis": [0, 0, 1], "radius": r, "height": 5}));
|
||||
}
|
||||
run("component.activate", json!({"component": "root"}));
|
||||
run("occurrence.copy", json!({"component": "Twin", "translate": [30, 0, 0]}));
|
||||
run("occurrence.ground", json!({"occurrence": "Base:1", "grounded": true}));
|
||||
for (name, x) in [("Twin:1", 0), ("Twin:2", 30)] {
|
||||
let at = |o: &str| json!({"occurrence": o, "point": [x, 0, 0], "z": [0, 0, 1]});
|
||||
run("joint.create", json!({"type": "revolute", "a": at("Base:1"), "b": at(name), "flip": true, "name": name}));
|
||||
}
|
||||
let mut app = SolveApp::new(s, Services::default());
|
||||
let occ = |app: &SolveApp, name: &str| app.session.doc.occurrences.iter().find(|o| o.name == name).map(|o| o.id).unwrap_or(0);
|
||||
app.start("joint.gear");
|
||||
let mut d = app.dialog.take().unwrap();
|
||||
for (i, p) in [Vec3::new(-15.0, 0.0, 2.5), Vec3::new(45.0, 0.0, 2.5)].into_iter().enumerate() {
|
||||
d.active = i;
|
||||
let pick = wall(&app, p);
|
||||
d.pick(&app.session, pick);
|
||||
}
|
||||
app.dialog = Some(d);
|
||||
let ctx = egui::Context::default();
|
||||
for _ in 0..2 {
|
||||
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 c = apply_commands(&app, app.dialog.as_ref().unwrap()).unwrap();
|
||||
let p = &c[0].1;
|
||||
assert_eq!((p["a"].clone(), p["b"].clone()), (json!(joint(&app, "Twin:1")), json!(joint(&app, "Twin:2"))), "{p}");
|
||||
assert_eq!((p["ra"]["occurrence"].clone(), p["rb"]["occurrence"].clone()), (json!(occ(&app, "Twin:1")), json!(occ(&app, "Twin:2"))), "{p}");
|
||||
app.run(&c[0].0, p.clone()).unwrap();
|
||||
assert!((app.session.doc.assembly.links[0].ratio + 1.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
/// A cylindrical joint is a screw on its own: the dialog sends it as `joint` with the pitch.
|
||||
#[test]
|
||||
fn a_cylindrical_joint_is_a_screw_on_its_own() {
|
||||
let mut app = discs();
|
||||
app.start("joint.screw");
|
||||
let d = app.dialog.take().unwrap();
|
||||
let c = apply_commands(&app, &d).unwrap();
|
||||
assert_eq!(c[0], ("joint.screw".to_string(), json!({"joint": joint(&app, "Nut"), "pitch": "1.5 mm", "reverse": false})));
|
||||
app.run(&c[0].0, c[0].1.clone()).unwrap();
|
||||
app.run("joint.drive", json!({"joint": "Nut", "values": [360]})).unwrap();
|
||||
let nut = solidcraft_engine::doc::joints::joint_values(&app.session.doc.assembly).get(&joint(&app, "Nut")).cloned().unwrap_or_default();
|
||||
assert!((nut.get(1).copied().unwrap_or(f64::NAN) - 1.5).abs() < 1e-9, "{nut:?}");
|
||||
}
|
||||
|
||||
/// Play turns the pinion's angle, wherever it is among its joint's values: a planar joint's is
|
||||
/// the third, after its x and y, which keep theirs.
|
||||
#[test]
|
||||
fn play_turns_the_angle_of_a_planar_pinion() {
|
||||
let mut app = discs();
|
||||
let at = |o: &str| json!({"occurrence": o, "point": [0, 0, 0], "z": [0, 0, 1]});
|
||||
app.run("joint.edit", json!({"joint": "G1", "type": "planar"})).unwrap();
|
||||
app.run("joint.drive", json!({"joint": "G1", "values": [2, 0, 0]})).unwrap();
|
||||
app.run("joint.create", json!({"type": "slider", "a": at("Base"), "b": at("G2"), "flip": true, "name": "Rail"})).unwrap();
|
||||
app.start("joint.rack_pinion");
|
||||
let mut d = app.dialog.take().unwrap();
|
||||
if let Kind::Assembly(Asm::Coupling(f)) = &mut d.kind {
|
||||
(f.a, f.animate, f.phase) = (joint(&app, "G1"), true, 0.5);
|
||||
}
|
||||
let cmds = preview_commands(&app, &d).unwrap();
|
||||
assert_eq!(cmds.last().map(|c| c.1["values"].clone()), Some(json!(["2 mm", "0 mm", "180 deg"])), "{cmds:?}");
|
||||
}
|
||||
|
||||
/// The dialog's rows pass their English text, which the catalogs translate by its key.
|
||||
#[test]
|
||||
fn the_dialog_labels_translate() {
|
||||
let de = crate::i18n::Locale::for_language("de");
|
||||
for (english, german) in [
|
||||
("Gear 1", "Zahnrad 1"),
|
||||
("Radius 2", "Radius 2"),
|
||||
("Internal gear", "Innenzahnrad"),
|
||||
("Pitch", "Steigung"),
|
||||
("Rack and Pinion", "Zahnstange und Ritzel"),
|
||||
] {
|
||||
assert_eq!(de.label(english), german);
|
||||
}
|
||||
assert_eq!(crate::i18n::Locale::for_language("ja").label("not a round face"), "円筒面ではありません");
|
||||
}
|
||||
@@ -25,6 +25,7 @@ pub mod design_properties;
|
||||
pub mod dialogs;
|
||||
pub mod dialogs_appearance;
|
||||
pub mod dialogs_assembly;
|
||||
pub mod dialogs_coupling;
|
||||
pub mod dialogs_library;
|
||||
pub mod dialogs_motion;
|
||||
pub mod dialogs_move;
|
||||
|
||||
@@ -187,7 +187,19 @@ impl PreviewState {
|
||||
/// limits, feature dialog or document cannot reuse an obsolete pose.
|
||||
fn motion_key(cmds: &[(String, Value)], revision: u64, colors: Colors) -> Option<u64> {
|
||||
if !cmds.iter().any(|(c, p)| c == "joint.drive" || c == "joint.create" && p.get("values").is_some())
|
||||
|| !cmds.iter().all(|(c, _)| matches!(c.as_str(), "joint.drive" | "joint.create" | "joint.as_built" | "joint.edit" | "joint.limits"))
|
||||
|| !cmds.iter().all(|(c, _)| {
|
||||
matches!(
|
||||
c.as_str(),
|
||||
"joint.drive"
|
||||
| "joint.create"
|
||||
| "joint.as_built"
|
||||
| "joint.edit"
|
||||
| "joint.limits"
|
||||
| "joint.gear"
|
||||
| "joint.rack_pinion"
|
||||
| "joint.screw"
|
||||
)
|
||||
})
|
||||
{
|
||||
return None;
|
||||
}
|
||||
@@ -826,6 +838,15 @@ mod tests {
|
||||
motion_key(&[(command.into(), json!({"joint": 1, "values": [90]}))], 7, colors()),
|
||||
);
|
||||
}
|
||||
// A gear dialog playing its driving joint streams its poses too.
|
||||
let gear = |angle: &str| {
|
||||
vec![
|
||||
("joint.gear".to_string(), json!({"a": 1, "b": 2, "ra": 10, "rb": 20})),
|
||||
("joint.drive".to_string(), json!({"joint": 1, "values": [angle]})),
|
||||
]
|
||||
};
|
||||
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()));
|
||||
}
|
||||
|
||||
/// Combine previews its result (cut, join or intersect between existing bodies) without an
|
||||
|
||||
@@ -16,6 +16,9 @@ const ASSEMBLE: &[&str] = &[
|
||||
"joint.rigid_group",
|
||||
"joint.drive",
|
||||
"joint.motion_link",
|
||||
"joint.gear",
|
||||
"joint.rack_pinion",
|
||||
"joint.screw",
|
||||
"motion.study",
|
||||
"explode.create",
|
||||
"contact.enable_sets",
|
||||
|
||||
Reference in new issue
Block a user