diff --git a/crates/ui-egui/src/dialogs_assembly.rs b/crates/ui-egui/src/dialogs_assembly.rs index 55e8582d..732b58be 100644 --- a/crates/ui-egui/src/dialogs_assembly.rs +++ b/crates/ui-egui/src/dialogs_assembly.rs @@ -529,6 +529,7 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Asm, inputs: &mut [Se row_label(ui, ""); ui.spacing_mut().slider_width = 160.0; let r = ui.add(egui::Slider::new(&mut shown, lo * scale..=hi * scale).show_value(false)); + crate::scenario::publish_handle(&format!("drive_slider:{i}"), r.rect.center()); if r.changed() { *v = if angle { format!("{shown:.1} deg") } else { format!("{shown:.2} mm") }; } diff --git a/crates/ui-egui/src/dialogs_assembly_tests.rs b/crates/ui-egui/src/dialogs_assembly_tests.rs index 5803bcd2..95b45b86 100644 --- a/crates/ui-egui/src/dialogs_assembly_tests.rs +++ b/crates/ui-egui/src/dialogs_assembly_tests.rs @@ -264,6 +264,69 @@ fn drive_value_takes_the_keyboard() { assert!(app.dialog.is_none()); } +/// A real slider drag updates the requested pose while the pointer is still down. +/// Previewing and cancelling must leave the saved joint and occurrence positions alone. +#[test] +fn drive_slider_previews_intermediate_positions_before_release() { + let mut h = crate::scenario::Harness::new(); + h.app = two_boxes(); + h.app.viewport.no_pixels = true; + let (a, b) = (occ(&h.app, "A"), occ(&h.app, "B")); + h.app + .run( + "joint.create", + json!({"type": "revolute", "a": {"occurrence": a, "face": [10, 10, 10]}, + "b": {"occurrence": b, "face": [40, 10, 0]}, "limits": [[-90, 90]]}), + ) + .unwrap(); + let before = h.app.session.doc.clone(); + h.app.start("joint.drive"); + h.frames(4); + let bounds = h + .output + .accesskit_update + .as_ref() + .unwrap() + .nodes + .iter() + .find_map(|(_, n)| (n.role() == egui::accesskit::Role::Slider).then(|| n.bounds()).flatten()) + .expect("drive slider"); + let at = |fraction: f64| egui::pos2((bounds.x0 + fraction * (bounds.x1 - bounds.x0)) as f32, ((bounds.y0 + bounds.y1) / 2.0) as f32); + let start = at(0.25); + h.app.synthetic.extend([ + egui::Event::PointerMoved(start), + egui::Event::PointerButton { pos: start, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }, + ]); + h.frames(3); + let first = preview_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap(); + h.app.synthetic.push(egui::Event::PointerMoved(at(0.75))); + h.frames(3); + let second = preview_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap(); + assert_ne!(first, second, "the held slider requests a new pose before release"); + assert!(h.ctx.input(|i| i.pointer.primary_down())); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while h.app.preview.busy { + assert!(std::time::Instant::now() < deadline, "held-slider preview did not finish"); + std::thread::sleep(std::time::Duration::from_millis(1)); + h.frame(); + } + assert!(h.app.preview.active, "the viewport shows the pose before release"); + assert!(h.app.preview.error.is_none()); + assert_eq!(h.app.preview.replaced, ["Body2"]); + assert_eq!(h.app.session.doc, before); + h.app.synthetic.push(egui::Event::PointerButton { + pos: at(0.75), + button: egui::PointerButton::Primary, + pressed: false, + modifiers: Default::default(), + }); + h.frames(3); + h.step(&json!({"key": "Escape"})).unwrap(); + assert!(h.app.dialog.is_none()); + assert!(!h.app.preview.active); + assert_eq!(h.app.session.doc, before, "cancel leaves the joint at its original angle"); +} + /// Named joint origins are drawn where their component is: a drive of the occurrence, or a free /// move not captured yet, carries them along. #[test] diff --git a/crates/ui-egui/src/preview.rs b/crates/ui-egui/src/preview.rs index 6d79dfe2..d7dda222 100644 --- a/crates/ui-egui/src/preview.rs +++ b/crates/ui-egui/src/preview.rs @@ -55,6 +55,8 @@ pub struct PreviewState { /// Key of the commands whose result (or error) is shown. shown_key: Option, running: Option<(u64, Receiver)>, + /// Inputs of an in-flight joint preview, excluding only its driven values. + running_motion_key: Option, /// Bodies the preview stands in for (hidden in the model scene). pub replaced: Vec, /// The preview scene for the GPU and its key (changes whenever the scene does). @@ -80,6 +82,7 @@ pub struct PreviewState { impl PreviewState { fn clear(&mut self) { self.running = None; + self.running_motion_key = None; self.shown_key = None; self.error = None; self.applied = None; @@ -123,7 +126,7 @@ impl PreviewState { /// Errors validate only the current inputs. Keep the last good geometry while /// computing new inputs, but never let an obsolete result block their Apply. - fn poll(&mut self, key: u64) { + fn poll(&mut self, key: u64, motion_key: Option) { if self.shown_key != Some(key) { self.error = None; } @@ -136,7 +139,12 @@ impl PreviewState { }; if let Some(job) = job { self.running = None; - if k == key { + let intermediate = motion_key.is_some() && self.running_motion_key == motion_key && matches!(&job, Ok(b) if b.error.is_none()); + self.running_motion_key = None; + // A held slider or animation may advance faster than the worker. Show + // completed poses from the same joint setup, then compute the latest. + // Errors and results from different setups still require an exact key. + if k == key || intermediate { self.apply(k, job); } } @@ -144,6 +152,33 @@ impl PreviewState { } } +/// Joint motion can stream successful intermediate poses while values change. Include +/// every other input, the document revision and colours so a different joint, changed +/// limits, feature dialog or document cannot reuse an obsolete pose. +fn motion_key(cmds: &[(String, Value)], revision: u64, colors: Colors) -> Option { + 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")) + { + return None; + } + let mut setup = cmds.to_vec(); + for (command, params) in &mut setup { + if matches!(command.as_str(), "joint.drive" | "joint.create") + && let Some(p) = params.as_object_mut() + { + p.remove("values"); + if command == "joint.drive" { + p.remove("value"); + } + } + } + let mut h = std::collections::hash_map::DefaultHasher::new(); + serde_json::to_string(&setup).ok()?.hash(&mut h); + revision.hash(&mut h); + format!("{colors:?}").hash(&mut h); + Some(h.finish()) +} + /// Keep the preview in step with the open dialog: start a computation when its inputs changed, /// take finished results. Called once per frame. pub fn update(app: &mut SolveApp, ctx: &egui::Context) { @@ -166,10 +201,12 @@ pub fn update(app: &mut SolveApp, ctx: &egui::Context) { app.session.revision.hash(&mut h); format!("{colors:?}").hash(&mut h); let key = h.finish(); + let motion_key = motion_key(&cmds, app.session.revision, colors); let pv = &mut app.preview; - pv.poll(key); + pv.poll(key, motion_key); if pv.running.is_none() && pv.shown_key != Some(key) { pv.started = crate::now_ms(); + pv.running_motion_key = motion_key; let scratch = app.session.scratch(); spawn(&mut app.preview, key, scratch, cmds, colors); } @@ -528,14 +565,14 @@ mod tests { }; let (tx, rx) = std::sync::mpsc::channel(); pv.running = Some((1, rx)); - pv.poll(2); + pv.poll(2, None); assert!(pv.error.is_none(), "the corrected inputs can Apply while their preview computes"); assert!(pv.running.is_some()); assert!(pv.active); assert_eq!(pv.replaced, ["Turn"]); assert_eq!(pv.key, 9, "the last good geometry stays visible"); tx.send(Err("unknown_angle".into())).unwrap(); - pv.poll(2); + pv.poll(2, None); assert!(pv.error.is_none(), "a late obsolete result cannot disable Apply again"); assert!(pv.running.is_none()); assert_eq!(pv.shown_key, Some(1)); @@ -544,13 +581,99 @@ mod tests { let (tx, rx) = std::sync::mpsc::channel(); pv.running = Some((2, rx)); tx.send(Err("current invalid expression".into())).unwrap(); - pv.poll(2); + pv.poll(2, None); assert_eq!(pv.error.as_deref(), Some("current invalid expression")); assert_eq!(pv.shown_key, Some(2)); - pv.poll(2); + pv.poll(2, None); assert_eq!(pv.error.as_deref(), Some("current invalid expression"), "current errors still prevent invalid Apply"); } + fn pose() -> Built { + Built { scene: GpuScene::default(), replaced: vec!["Lid".into()], error: None, bounds: Aabb3::EMPTY, applied: None, tapered: Vec::new() } + } + + /// The worker completes while the slider is still held or Play keeps advancing. + /// Intermediate successful poses must reach the viewport, and the final value + /// must still be computed rather than treating that intermediate pose as current. + #[test] + fn joint_motion_shows_completed_poses_while_inputs_keep_advancing() { + let mut pv = PreviewState::default(); + for (running, wanted) in [(1, 2), (2, 3), (3, 4)] { + let (tx, rx) = std::sync::mpsc::channel(); + pv.running = Some((running, rx)); + pv.running_motion_key = Some(42); + pv.poll(wanted, Some(42)); + assert!(pv.running.is_some()); + tx.send(Ok(pose())).unwrap(); + pv.poll(wanted, Some(42)); + assert!(pv.active, "motion should be visible before input stops"); + assert_eq!(pv.replaced, ["Lid"]); + assert_eq!(pv.key, running, "every completed pose reaches the scene"); + assert_eq!(pv.shown_key, Some(running)); + assert_ne!(pv.shown_key, Some(wanted), "the latest pose still needs a job"); + } + let (tx, rx) = std::sync::mpsc::channel(); + pv.running = Some((4, rx)); + pv.running_motion_key = Some(42); + tx.send(Ok(pose())).unwrap(); + pv.poll(4, Some(42)); + assert_eq!(pv.shown_key, Some(4), "release catches up to the exact final pose"); + assert!(pv.running.is_none()); + } + + #[test] + fn intermediate_motion_rejects_other_setups_and_obsolete_errors() { + for (context, job) in [ + (None, Ok(pose())), + (Some(43), Ok(pose())), + (Some(42), Err("old invalid value".into())), + (Some(42), Ok(Built { error: Some("old tool preview error".into()), ..pose() })), + ] { + let mut pv = PreviewState { running_motion_key: Some(42), ..PreviewState::default() }; + let (tx, rx) = std::sync::mpsc::channel(); + pv.running = Some((1, rx)); + tx.send(job).unwrap(); + pv.poll(2, context); + assert!(!pv.active); + assert!(pv.error.is_none(), "obsolete errors must not block Apply"); + assert!(pv.running.is_none()); + } + } + + #[test] + fn motion_context_changes_only_when_the_joint_setup_changes() { + let commands = |angle| { + vec![ + ("joint.edit".into(), json!({"joint": 1, "flip": true})), + ("joint.limits".into(), json!({"joint": 1, "min": "-135 deg", "max": "0 deg"})), + ("joint.drive".into(), json!({"joint": 1, "values": [angle]})), + ] + }; + let key = motion_key(&commands("-30 deg"), 7, colors()); + assert!(key.is_some()); + assert_eq!(key, motion_key(&commands("-60 deg"), 7, colors())); + assert_ne!(key, motion_key(&commands("-60 deg"), 8, colors())); + let mut different = commands("-60 deg"); + different[2].1["joint"] = json!(2); + assert_ne!(key, motion_key(&different, 7, colors())); + different = commands("-60 deg"); + different[1].1["min"] = json!("-90 deg"); + assert_ne!(key, motion_key(&different, 7, colors())); + different = commands("-60 deg"); + different[0].1["flip"] = json!(false); + assert_ne!(key, motion_key(&different, 7, colors())); + let mut other_colors = colors(); + other_colors.body = [9, 9, 9, 255]; + assert_ne!(key, motion_key(&commands("-60 deg"), 7, other_colors)); + assert!(motion_key(&[("solid.extrude".into(), json!({"distance": 10}))], 7, colors()).is_none()); + for command in ["joint.create", "joint.drive"] { + assert_eq!( + motion_key(&[(command.into(), json!({"joint": 1, "values": [0]}))], 7, colors()), + motion_key(&[(command.into(), json!({"joint": 1, "values": [90]}))], 7, colors()), + ); + } + } + /// Combine previews its result (cut, join or intersect between existing bodies) without an /// error, so its dialog's OK is enabled. Before, the preview also tried the command as a new /// body, which a combine has no form of, and always reported "operation must be join, cut or