Drive Joints opens on the picked joint; ui.start takes params (#1297)

Drive Joints opens on the joint picked in the browser, else the first joint that moves, and won't start on a rigid joint. ui.start takes params: joint.drive opens on a joint (id or name) at given values, driving nothing until OK; a command or param it doesn't take is refused.
This commit is contained in:
Mateusz Sych authored and GitHub committed 2026-10-15 02:54:02 -04:00
1 parent 0ee5e97db5
commit 849426e866
5 files changed
+145 -3

No files matched your search

+62
View File
@@ -336,7 +336,25 @@ fn dispatch(app: &mut SolveApp, ctx: &egui::Context, method: &str, p: &Value) ->
"ui.camera" => wrap(camera(app, p)),
"ui.start" => {
let Some(id) = s("command") else { return err("missing `command`") };
// `params`: what the dialog opens on. Refused: nothing starts, or the dialog closes.
let params = p.get("params").filter(|v| !v.is_null());
if params.is_some() && id != "joint.drive" {
return err(format!("`{id}` takes no ui.start params yet (joint.drive takes joint, values)"));
}
app.start(id);
if let Some(params) = params {
if app.tree.capture_prompt.is_some() {
return err("Drive Joints waits for the Capture Position question (ui.capture)");
}
let Some(crate::dialogs::Kind::Assembly(k)) = app.dialog.as_mut().map(|d| &mut d.kind) else {
let why = app.status.as_ref().filter(|s| s.2).map(|s| s.0.clone());
return err(why.unwrap_or_else(|| "Drive Joints did not open".into()));
};
if let Err(e) = crate::dialogs_assembly::preset(&app.session, k, params) {
crate::dialogs::cancel(app);
return err(e);
}
}
ok(json!({"tool": app.tool.as_ref().map(|t| t.cmd), "dialog": app.dialog.as_ref().map(|d| format!("{d:?}"))}))
}
"ui.move" | "ui.click" => {
@@ -1251,4 +1269,48 @@ mod tests {
let r = empty.call("ui.camera", json!({"yaw": 1, "fit": true}));
assert!(r["ok"] == false && empty.app.cam == before, "{r}");
}
/// `ui.start` params set what a dialog opens on: Drive Joints on a joint (by name or id) at
/// the given values, driving nothing yet. A param it doesn't take is refused and closes it.
#[test]
fn ui_start_params_open_drive_joints_where_asked() {
let mut s = Session::default();
for (name, x) in [("A", 0), ("B", 30), ("C", 60)] {
s.execute("component.activate", &json!({"component": "root"})).unwrap();
s.execute("component.create", &json!({ "name": name })).unwrap();
s.execute("solid.box", &json!({"length": 20, "width": 20, "height": 10, "corner": [x, 0, 0]})).unwrap();
}
s.execute("component.activate", &json!({"component": "root"})).unwrap();
s.execute("occurrence.ground", &json!({"occurrence": "A:1", "grounded": true})).unwrap();
let at = |o: &str, x: i32| json!({"occurrence": o, "point": [x, 10, 5], "z": [1, 0, 0]});
s.execute("joint.create", &json!({"type": "revolute", "a": at("A", 25), "b": at("B", 25), "flip": true, "name": "Hinge"})).unwrap();
s.execute("joint.create", &json!({"type": "slider", "a": at("B", 55), "b": at("C", 55), "flip": true, "name": "Rail"})).unwrap();
let mut h = crate::scenario::Harness::new();
h.app = SolveApp::new(s, Services::default());
h.app.viewport.no_pixels = true;
let before = h.app.session.doc.clone();
let drive = |h: &crate::scenario::Harness| match h.app.dialog.as_ref().map(|d| &d.kind) {
Some(crate::dialogs::Kind::Assembly(crate::dialogs_assembly::Asm::Drive { joint, values, .. })) => {
(h.app.session.doc.assembly.joints[*joint].name.clone(), values.clone())
}
other => panic!("{other:?}"),
};
let r = h.call("ui.start", json!({"command": "joint.drive", "params": {"joint": "Rail", "values": [12.5]}}));
assert_eq!(r["ok"], true, "{r}");
assert_eq!(drive(&h), ("Rail".to_string(), vec!["12.5 mm".to_string()]));
let hinge = h.app.session.doc.assembly.joints[0].id;
h.call("ui.start", json!({"command": "joint.drive", "params": {"joint": hinge, "values": ["45 deg"]}}));
assert_eq!(drive(&h), ("Hinge".to_string(), vec!["45 deg".to_string()]));
assert_eq!(h.app.session.doc, before, "nothing is driven until OK");
for bad in [json!({"joint": "Nope"}), json!({"joint": "Rail", "value": 3}), json!({"values": [1, 2]}), json!({"values": [f64::MAX]})] {
let r = h.call("ui.start", json!({"command": "joint.drive", "params": bad}));
assert_eq!(r["ok"], false, "{bad}: {r}");
assert!(h.app.dialog.is_none(), "{bad}");
}
for command in ["solid.extrude", "sketch.create", "inspect.zebra"] {
let r = h.call("ui.start", json!({"command": command, "params": {"distance": 5}}));
assert!(r["ok"] == false && r["error"].as_str().unwrap().contains("takes no ui.start params"), "{r}");
assert!(h.app.dialog.is_none() && h.app.tool.is_none() && h.app.session.analysis.is_none(), "{command} started nothing");
}
}
}
+50 -1
View File
@@ -152,7 +152,14 @@ pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
"joint.origin" => (Asm::JointOrigin { name: String::new(), hover: None }, vec![SelInput::new("Snap", SNAPS, false)]),
"joint.rigid_group" => (Asm::RigidGroup, vec![SelInput::new("Components", BODIES, true)]),
"joint.drive" => {
let joint = movable(s).first().copied().unwrap_or(0);
// The joint picked in the browser, else the first that moves.
let list = movable(s);
let picked = app
.tree
.picked_joints
.iter()
.find_map(|id| list.iter().copied().find(|i| s.doc.assembly.joints.get(*i).is_some_and(|j| j.id == *id)));
let joint = picked.or(list.first().copied()).unwrap_or(0);
(Asm::Drive { joint, values: current_values(s, joint), focus: true }, vec![])
}
"joint.motion_link" => {
@@ -167,6 +174,48 @@ pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
Some((Kind::Assembly(asm), inputs))
}
/// What an open dialog starts on when the control channel's `ui.start` passes `params`. Drive
/// Joints takes `joint` (id or name) and `values` (numbers: degrees or millimetres; text: an
/// expression), as `joint.drive` does. Nothing is driven until OK.
pub fn preset(s: &Session, k: &mut Asm, p: &Value) -> Result<(), String> {
let Asm::Drive { joint, values, .. } = k else { return Err(format!("{} takes no params yet", k.title())) };
let o = p.as_object().ok_or("`params` must be an object")?;
if let Some(key) = o.keys().find(|k| !matches!(k.as_str(), "joint" | "values")) {
return Err(format!("unknown param `{key}` (Drive Joints takes joint, values)"));
}
if let Some(v) = o.get("joint") {
let key = match v {
Value::Number(n) => n.to_string(),
Value::String(x) => x.clone(),
_ => return Err("`joint` must be an id or a name".into()),
};
let i = s.doc.assembly.joints.iter().position(|j| j.id.to_string() == key || j.name == key).ok_or_else(|| format!("no joint `{key}`"))?;
if !movable(s).contains(&i) {
return Err(format!("joint `{key}` is rigid: it has nothing to drive"));
}
*joint = i;
*values = current_values(s, i);
}
if let Some(v) = o.get("values") {
let j = s.doc.assembly.joints.get(*joint).ok_or("no joint that moves yet")?;
let list = v.as_array().ok_or("`values` must be a list")?;
if list.len() > values.len() {
return Err(format!("`{}` takes {} value(s)", j.name, values.len()));
}
for (i, (slot, v)) in values.iter_mut().zip(list).enumerate() {
*slot = match v {
Value::Number(n) => {
let x = n.as_f64().filter(|x| x.is_finite() && x.abs() < 1e9).ok_or("values must be finite numbers")?;
num(x, if j.kind.is_angle(i) { "deg" } else { "mm" })
}
Value::String(t) if t.len() <= 1000 => t.clone(),
_ => return Err("values must be numbers or expressions".into()),
};
}
}
Ok(())
}
/// Indices (into the design's joints) of joints that move (not rigid).
fn movable(s: &Session) -> Vec<usize> {
s.doc.assembly.joints.iter().enumerate().filter(|(_, j)| j.kind != JointKind::Rigid).map(|(i, _)| i).collect()
@@ -460,3 +460,33 @@ fn drive_says_when_its_joint_is_joined_to_nothing_grounded() {
crate::dialogs::run_dialog(&mut app, &d).unwrap();
assert_eq!(app.status.clone().map(|(status, _, error)| (status, error)), Some((why, false)));
}
/// Drive Joints opens on the joint picked in the browser; with none picked, on the first joint
/// that moves.
#[test]
fn drive_opens_on_the_picked_joint() {
let mut app = four_bar();
let drive_on = |app: &mut SolveApp| {
app.dialog = None;
app.start("joint.drive");
match &app.dialog.as_ref().unwrap().kind {
Kind::Assembly(Asm::Drive { joint, .. }) => app.session.doc.assembly.joints[*joint].name.clone(),
k => panic!("{k:?}"),
}
};
assert_eq!(drive_on(&mut app), "Crank");
let rocker = app.session.doc.assembly.joints.iter().find(|j| j.name == "Rocker").unwrap().id;
pick_joint(&mut app, rocker, false);
assert_eq!(drive_on(&mut app), "Rocker");
}
/// A rigid joint has nothing to drive: Drive Joints won't start on it.
#[test]
fn drive_preset_refuses_a_rigid_joint() {
let mut app = two_boxes();
let at = |o: &str| json!({"occurrence": o, "point": [25, 10, 5], "z": [1, 0, 0]});
app.run("joint.create", json!({"type": "rigid", "a": at("A"), "b": at("B"), "name": "Glue"})).unwrap();
let mut k = Asm::Drive { joint: 0, values: Vec::new(), focus: true };
let e = preset(&app.session, &mut k, &json!({"joint": "Glue"})).unwrap_err();
assert!(e.contains("rigid"), "{e}");
}