From 849426e8665cc79748e2598eb5e91850f62a101d Mon Sep 17 00:00:00 2001 From: Mateusz Sych <151755914+SychPL@users.noreply.github.com> Date: Thu, 15 Oct 2026 08:54:02 +0200 Subject: [PATCH] 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. --- crates/ui-egui/src/control.rs | 62 ++++++++++++++++++++ crates/ui-egui/src/dialogs_assembly.rs | 51 +++++++++++++++- crates/ui-egui/src/dialogs_assembly_tests.rs | 30 ++++++++++ docs/book/src/guide/assemblies.md | 3 +- docs/control-protocol.md | 2 +- 5 files changed, 145 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 6d269103..04eaae59 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -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"); + } + } } diff --git a/crates/ui-egui/src/dialogs_assembly.rs b/crates/ui-egui/src/dialogs_assembly.rs index f63327ea..738975dc 100644 --- a/crates/ui-egui/src/dialogs_assembly.rs +++ b/crates/ui-egui/src/dialogs_assembly.rs @@ -152,7 +152,14 @@ pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec)> { "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)> { 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 { s.doc.assembly.joints.iter().enumerate().filter(|(_, j)| j.kind != JointKind::Rigid).map(|(i, _)| i).collect() diff --git a/crates/ui-egui/src/dialogs_assembly_tests.rs b/crates/ui-egui/src/dialogs_assembly_tests.rs index e61981ba..b5b7fe7e 100644 --- a/crates/ui-egui/src/dialogs_assembly_tests.rs +++ b/crates/ui-egui/src/dialogs_assembly_tests.rs @@ -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}"); +} diff --git a/docs/book/src/guide/assemblies.md b/docs/book/src/guide/assemblies.md index 2c7d93a9..407eaf8d 100644 --- a/docs/book/src/guide/assemblies.md +++ b/docs/book/src/guide/assemblies.md @@ -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. diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 58fd63dd..289d6418 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -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) |