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}");
}
+2 -1
View File
@@ -39,7 +39,8 @@ part still has with its loops closed: the moving parts of a four-bar have one be
loop locked by a rigid joint has none. The Drive Joints dialog says when a drive stops short and
why. While it previews, a row under the values gives the value the joint reached and the reason
(the loop that can't close further, or the two parts a contact set keeps apart), and after OK the
status line repeats it. The value box keeps what you typed.
status line repeats it. The value box keeps what you typed. The dialog opens on the joint
picked in the browser, or on the first joint that moves when none is picked.
The solver places what it reaches from the root and from grounded components. A chain of joints
that is joined to nothing grounded is never placed, so driving one of its joints moves nothing.
+1 -1
View File
@@ -105,7 +105,7 @@ unit regardless of the design, pass an expression with one: `"value": "50 mm"` o
| `ui.set` | any `UiState` field (`tab`, `perspective`, `showGrid`, `dark`, …), and `hiddenBodies`, `hiddenOrigin`, `hiddenSketches`, `shownSketches`, `hiddenJoints` (whole lists; `browser.visibility` changes single items) | `dark: false` switches to the light theme |
| `ui.view` | `view`: `front`, `back`, `top`, `bottom`, `left`, `right`, `iso`, `home`, `fit`; `animate` (bool, default false: snap) | |
| `ui.camera` | `target?`: `[x, y, z]` (mm), `yaw?`, `pitch?`, `roll?` (radians), `distance?` (mm), `fov?`, `up?`; `fit?`: `true`, `false` or `[occurrence id or name…]` (`root`: the bodies outside every component) | the viewport's orbit camera: it looks at `target` from `distance` away; `yaw` 0 looks along +Y (from the front), `pitch` is the elevation (±π/2: straight down or up), `roll` turns the image. Fields given are set first (the others keep their values), then `fit` frames the listed occurrences (or everything), keeping the direction. The reply, passed back, sets the same view again, so a framing can be stored and replayed. `fov` and `up` follow the perspective setting (`ui.set`) and the up axis preference (`ui.prefs`) and can't change here; a refused field changes nothing |
| `ui.start` | `command` | like a toolbar click: starts the sketch tool or dialog for the command |
| `ui.start` | `command`, `params?` | like a toolbar click: starts the sketch tool or dialog for the command. `params` sets what the dialog opens on, and nothing runs until OK: `joint.drive` takes `joint` (id or name; without it, the joint picked in the browser, else the first joint that moves) and `values` (numbers in degrees or millimetres, or expressions). A command that takes no params is refused before it starts; a param the dialog doesn't take is refused and the dialog closes |
| `ui.click` | `x`, `y` (screen points), `button?`: left/right/middle, `shift?`, `ctrl?`, `double?` | real pointer input |
| `ui.move` | `x`, `y`, `shift?`, `alt?`, `ctrl?`, `cmd?` | pointer movement without a button; modifiers apply until the next synthetic move/click; a move with omitted modifiers releases them. With `nav: "touchpad"`, Shift pans and Alt/Option orbits. |
| `ui.drag` | `x0`, `y0`, `x1`, `y1`, `button?`, `shift?`, `ctrl?`, `steps?`, `hold?`, `hold_end?` | press, move, release; a left drag on the model is a box selection (left to right: window, right to left: crossing); with OpenSCAD navigation, plain left-drag on empty space orbits, Shift+left-drag box-selects, and drags starting on sketch geometry edit it; `hold` keeps the button down that many frames first (a right press-and-hold opens the marking menu, releasing over a direction picks it), `hold_end` that many frames at the end (a sketch drag shows its snap) |