diff --git a/crates/engine/src/cmd/results.rs b/crates/engine/src/cmd/results.rs index bfd385a..1f2b409 100644 --- a/crates/engine/src/cmd/results.rs +++ b/crates/engine/src/cmd/results.rs @@ -44,7 +44,7 @@ pub const MODES: CommandSpec = CommandSpec { pub const ANIMATE: CommandSpec = CommandSpec { id: "results.animate", label: "Animate", - params: r#"{"on": true} — animates the deformed shape: a mode swings through its positive and negative shape, a static step grows from undeformed to deformed"#, + params: r#"{"on": true} — animates the result over time: a mode swings through its positive and negative shape, a static step grows from undeformed to deformed, an explicit or fluid step plays its frames in order"#, form: Some(super::forms::ANIMATE), kind: CommandKind::Session, requires: &[Requirement::Result], @@ -341,12 +341,12 @@ fn animate(s: &mut Session, p: &Value) -> Result { return Ok(s.results_report()); } let step = step_for(s, &json!({}), Want::Any)?; - if s.latest_result(&step).is_some_and(|(r, _)| matches!(r.kind(), Kind::Fluid(_))) { - return Err(EngineError::Params(format!("step `{step}` is a fluid step: nothing deforms; show its frames with results.frame"))); - } + let fluid = s.latest_result(&step).is_some_and(|(r, _)| matches!(r.kind(), Kind::Fluid(_))); let v = view_of(s, &step)?; v.animate = true; - v.deformed = true; + // A fluid step has nothing to deform; animating plays its frames (as results.frame does) in + // order. A structural step animates its deformation (or an explicit step its frames). + v.deformed = !fluid; Ok(s.results_report()) } @@ -1484,7 +1484,10 @@ mod tests { assert_eq!((r["traced"]["seeds"].as_u64(), r["traced"]["points"].as_u64()), (Some(5), Some(20)), "{r}"); assert!(s.execute("results.streamlines", &json!({"show": false})).unwrap()["streamlines"].is_null()); assert_eq!(s.execute("results.surface", &json!({"show": false})).unwrap()["surface"], false); - assert!(s.execute("results.animate", &json!({})).is_err(), "nothing deforms"); + // A fluid step animates its frames (the start-up transient), not a deformation. + let r = s.execute("results.animate", &json!({})).unwrap(); + assert_eq!((r["view"]["animate"].as_bool(), r["view"]["deformed"].as_bool()), (Some(true), Some(false)), "{r}"); + assert!(s.execute("results.animate", &json!({"on": false})).unwrap()["view"]["animate"] == json!(false)); assert_eq!((s.journal.len(), s.revision()), (journal, revision), "display commands are not edits"); // Structural steps have none of it. let mut t = solved(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5e04adc..49c6bb5 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1115,7 +1115,7 @@ impl ModelApp { /// frame; `None` when no results are shown. fn plot_key(&self) -> Option { let (view, result, current) = results::shown(&self.session)?; - let frame = results::frame(view, self.now); + let frame = results::frame(view, result, self.now); let pick = results::pick(view, result, frame); Some(PlotKey { revision: self.revision, result: Arc::as_ptr(result) as usize, current, view: view.clone(), pick, frame }) } diff --git a/crates/ui-egui/src/results.rs b/crates/ui-egui/src/results.rs index 232e582..48b13d4 100644 --- a/crates/ui-egui/src/results.rs +++ b/crates/ui-egui/src/results.rs @@ -143,9 +143,12 @@ pub fn shown(session: &Session) -> Option<(&ResultsView, &Arc, bool) Some((view, result, current)) } -/// The animation frame shown at time `now` (seconds), when animating. -pub fn frame(view: &ResultsView, now: f64) -> Option { - (view.animate && view.deformed && now.is_finite()).then(|| ((now.max(0.0) / PERIOD * FRAMES as f64).floor() as u64) % FRAMES) +/// The animation frame shown at time `now` (seconds), when animating. A deformed step swings its +/// shape; a fluid step plays its frames though it does not deform; a step with nothing to animate +/// (Animate on but Deformed off, and not fluid) stays still. +pub fn frame(view: &ResultsView, result: &StepResult, now: f64) -> Option { + let plays = view.deformed || matches!(result.kind(), Kind::Fluid(_)); + (view.animate && plays && now.is_finite()).then(|| ((now.max(0.0) / PERIOD * FRAMES as f64).floor() as u64) % FRAMES) } /// The deformation multiplier of an animation frame: a mode swings through ±1, a static step @@ -168,6 +171,7 @@ pub fn pick(view: &ResultsView, result: &StepResult, frame: Option) -> Pick let mut p = view.pick(); let frames = match result.kind() { Kind::Explicit(e) => e.frames.len(), + Kind::Fluid(f) => f.frames.len(), Kind::Static(r) => r.nonlinear.as_ref().map_or(0, |h| h.frames.len()), _ => 0, }; @@ -481,12 +485,34 @@ pub(crate) mod tests { let scene = scene(&p, Style::ShadedEdges, crate::scene::Show::DEFAULT, &t); assert!(scene.tris.len() > base.surface_tris.len(), "the slice is drawn"); assert!(!scene.lines.is_empty(), "streamlines and the part's edges are drawn"); - // Pressure on the slice; the first frame (at rest at the inlet speed everywhere). + // Pressure on the slice; the first frame. This plate_in_a_tank fixture sets no initial + // velocity, so it starts at rest (unlike the flow-past sample's uniform inlet field). s.execute("results.slice", &json!({"field": "pressure", "frame": 0})).unwrap(); let p = plot_at(&s, &base, None).unwrap(); assert_eq!((p.contour.as_ref().map(|c| c.field), p.pick.frame), (Some(Field::Pressure), Some(0))); } + /// Animating a fluid step plays its frames: the clock maps across the frames once per cycle, + /// and results.animate turns it on without deforming the part (the flow start-up transient). + #[test] + fn a_fluid_step_animates_its_frames() { + let mut s = plate_in_a_tank(false); + let (base, _) = fieldcraft_engine::view::display(&s); + s.execute("results.animate", &json!({})).unwrap(); + let view = s.results_view.as_ref().unwrap(); + let result = s.latest_result(&view.step).unwrap().0; + assert!(view.animate && !view.deformed, "fluid animates its frames, not a deformation"); + // The animation clock runs even though the fluid step does not deform. + assert_eq!(frame(view, result, 0.0), Some(0)); + assert_eq!(frame(view, result, PERIOD * 0.5), Some(FRAMES / 2)); + // The clock maps across the three frames: the plate_in_a_tank field, which starts at rest + // (the fixture sets no initial velocity, unlike the flow-past sample), then the settled flow. + let n = plot_at(&s, &base, None).unwrap().frame().unwrap().2; + assert_eq!(n, 3); + assert_eq!(plot_at(&s, &base, Some(0)).unwrap().pick.frame, Some(0)); + assert_eq!(plot_at(&s, &base, Some(FRAMES - 1)).unwrap().pick.frame, Some(n - 1)); + } + #[test] fn heated_flow_fields_have_units_colours_and_face_markers() { let mut s = plate_in_a_tank(true); @@ -531,10 +557,14 @@ pub(crate) mod tests { s.execute("results.animate", &json!({})).unwrap(); let base = DisplayMesh::from_model(&s.model); let view = s.results_view.clone().unwrap(); - assert_eq!(frame(&view, 0.0), Some(0)); - assert_eq!(frame(&view, PERIOD * 0.25), Some(FRAMES / 4)); - assert_eq!(frame(&view, PERIOD * 1.25), Some(FRAMES / 4)); - let (_, result, _) = shown(&s).unwrap(); + let result = s.latest_result(&view.step).unwrap().0; + assert_eq!(frame(&view, result, 0.0), Some(0)); + assert_eq!(frame(&view, result, PERIOD * 0.25), Some(FRAMES / 4)); + assert_eq!(frame(&view, result, PERIOD * 1.25), Some(FRAMES / 4)); + // Animate on but Deformed off, and not a fluid step: nothing to animate, so it stays still. + let mut still = view.clone(); + still.deformed = false; + assert_eq!(frame(&still, result, PERIOD * 0.25), None); assert!((swing(result, Some(FRAMES / 4)) - 1.0).abs() < 1e-12); assert!((swing(result, Some(3 * FRAMES / 4)) + 1.0).abs() < 1e-12); // At frame 0 a mode is undeformed; a quarter cycle later fully deformed. diff --git a/crates/ui-egui/src/results_panel.rs b/crates/ui-egui/src/results_panel.rs index 9a725c2..2f39cbc 100644 --- a/crates/ui-egui/src/results_panel.rs +++ b/crates/ui-egui/src/results_panel.rs @@ -364,6 +364,10 @@ pub fn controls(app: &mut ModelApp, plot: &Plot, ui: &mut egui::Ui, t: &Tokens) if ui.add(egui::Slider::new(&mut frame, 0..=n.saturating_sub(1))).changed() { run.push(("results.frame", json!({"frame": frame}))); } + let mut animate = plot.view.animate; + if ui.checkbox(&mut animate, "Animate").on_hover_text("Play the frames from the start-up transient to the settled flow").changed() { + run.push(("results.animate", json!({"on": animate}))); + } }); ui.label(RichText::new(format!("t = {}", with_unit(number(at), Quantity::Time.unit(plot.units).as_deref()))).color(t.text_dim)); } else if let Some((k, factor, n)) = plot.frame().filter(|_| matches!(plot.result.kind(), Kind::Static(_))) {