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:
1 parent
152418b624
commit
b702f0efde
3 files changed
+194
-7
No files matched your search
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user