Fix live hinge animation and Drive Joint slider previews (#835)

Joint previews keep showing finished poses while the slider moves, and the latest value is still computed.
This commit is contained in:
bherbruck authored and GitHub committed 2026-10-13 16:34:09 -04:00
1 parent 152418b624
commit b702f0efde
3 files changed
+194 -7

No files matched your search

+1
View File
@@ -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") };
}
@@ -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]
+130 -7
View File
@@ -55,6 +55,8 @@ pub struct PreviewState {
/// Key of the commands whose result (or error) is shown.
shown_key: Option<u64>,
running: Option<(u64, Receiver<Job>)>,
/// Inputs of an in-flight joint preview, excluding only its driven values.
running_motion_key: Option<u64>,
/// Bodies the preview stands in for (hidden in the model scene).
pub replaced: Vec<String>,
/// 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<u64>) {
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<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"))
{
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