diff --git a/crates/engine/src/catalogue.rs b/crates/engine/src/catalogue.rs index 3b2184b..49cd223 100644 --- a/crates/engine/src/catalogue.rs +++ b/crates/engine/src/catalogue.rs @@ -677,7 +677,14 @@ pub static CATALOGUE: &[Capability] = &[ soon("results.path", "Path Plot", Results, "Plots", "M9", "Plot a quantity along a path"), soon("results.cut", "Cut", Results, "Plots", "M9", "Section cuts through the model"), soon("results.display_group", "Display Group", Results, "Plots", "M9", "Show and hide parts of the model"), - soon("results.streamlines", "Streamlines", Results, "Plots", "M12", "Streamlines and flow fields"), + ready_more( + "results.streamlines", + "Streamlines", + Results, + "Plots", + "A fluid step's streamlines coloured by speed, its velocity or pressure on a slice, and the obstacles coloured by the flow", + &["results.slice", "results.surface"], + ), ready("results.export.vtu", "Export VTU", Results, "Files", "Write a solved step as VTK unstructured grids for ParaView"), soon("results.read", "Read Results", Results, "Files", "M9", "Read results in public formats"), soon("results.convergence", "Convergence", Results, "Files", "M9", "Mesh convergence studies"), diff --git a/crates/engine/src/cmd/forms.rs b/crates/engine/src/cmd/forms.rs index 64daef6..aec9213 100644 --- a/crates/engine/src/cmd/forms.rs +++ b/crates/engine/src/cmd/forms.rs @@ -1992,6 +1992,8 @@ pub const CONTOUR: &[Field] = &[ ("displacement_x", "Displacement X"), ("displacement_y", "Displacement Y"), ("displacement_z", "Displacement Z"), + ("velocity", "Velocity magnitude"), + ("pressure", "Pressure (fluid)"), ("max_principal", "Max principal"), ("mid_principal", "Mid principal"), ("min_principal", "Min principal"), @@ -2030,6 +2032,35 @@ pub const FRAME: &[Field] = &[ int("frame", "Frame", 0, 1_000_000_000).help("The frame shown."), positive("time", "Time", D::Time).help("Show the frame nearest this time; the last when empty.").hint("the last frame"), ]; +const FLUID_FRAME: Field = int("frame", "Frame", 0, 1_000_000_000).help("The frame shown, from 0.").hint("the frame shown"); +pub const SLICE: &[Field] = &[ + SOLVED, + choice("field", "Field", &[("velocity", "Velocity magnitude"), ("pressure", "Pressure")]) + .help("The field coloured on the slice and the obstacles.") + .hint("the field shown"), + choice("axis", "Normal", &[("x", "X"), ("y", "Y"), ("z", "Z"), ("none", "None (hide)")]) + .help("The axis the slice is normal to.") + .hint("the slice shown"), + number_or_auto("position", "Position", D::Length, None) + .help("Where the slice crosses its axis; auto: the domain's middle.") + .hint("the position shown"), + FLUID_FRAME, +]; +pub const FLOW_SURFACE: &[Field] = &[SOLVED, flag("show", "Show").help("Colour the obstacles by the field in the fluid beside them.").def("true")]; +pub const STREAMLINES: &[Field] = &[ + SOLVED, + flag("show", "Show").help("Draw the streamlines.").def("true"), + int("count", "Count", 1, 2000).help("Seeds on the inlet face, or points along the seed line.").def("64"), + int("max_steps", "Max steps", 1, 100_000).help("Most steps of half a cell each streamline takes.").def("4000"), + points("seeds", "Seeds", D::Length, 2000).help("The points streamlines start from; the inlet face when empty."), + group( + "line", + "Seed line", + &[xyz("from", "From", D::Length).help("The seed line's first end."), xyz("to", "To", D::Length).help("The seed line's last end.")], + ) + .help("Start `count` streamlines evenly along a line instead of the inlet face."), + FLUID_FRAME, +]; pub const EXPORT_CSV: &[Field] = &[ choice("what", "Export", &[("reactions", "Reaction sums"), ("probe", "Probed nodes")]).help("What to write.").req().def("\"reactions\""), path("path", "File", true).help("The CSV file to write.").req(), diff --git a/crates/engine/src/cmd/mesh.rs b/crates/engine/src/cmd/mesh.rs index b8d2036..82db91e 100644 --- a/crates/engine/src/cmd/mesh.rs +++ b/crates/engine/src/cmd/mesh.rs @@ -151,7 +151,7 @@ pub(super) fn optional_count(p: &Value, key: &str, default: u64, range: std::ops } /// A whole number in `range`, as a problem's kind. -fn whole_in(range: &std::ops::RangeInclusive) -> ProblemKind { +pub(super) fn whole_in(range: &std::ops::RangeInclusive) -> ProblemKind { let int = |n: u64| i64::try_from(n).unwrap_or(i64::MAX); ProblemKind::NotAWholeNumber { min: int(*range.start()), max: int(*range.end()) } } diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index ced07dc..89de5d5 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -202,6 +202,9 @@ pub static ALL: &[CommandSpec] = &[ results::MODES, results::ANIMATE, results::FRAME, + results::SLICE, + results::SURFACE, + results::STREAMLINES, results::REACTIONS, results::EXPORT_CSV, results::EXPORT_VTU, diff --git a/crates/engine/src/cmd/results.rs b/crates/engine/src/cmd/results.rs index 627b692..f5c36d0 100644 --- a/crates/engine/src/cmd/results.rs +++ b/crates/engine/src/cmd/results.rs @@ -1,11 +1,12 @@ //! `results.*`: what the results display shows (deformed shape, contours, mode shapes, -//! animation) and reaction sums. The display is session state ([`ResultsView`]): these +//! animation; a fluid step's slice, coloured obstacles and streamlines) and reaction sums. The display is session state ([`ResultsView`]): these //! commands are not edits, so they are not journalled or undone. use crate::schema::ProblemKind; use serde_json::{Value, json}; use super::{opt, xyz}; +use crate::flow::{Axis, FlowView, MAX_STREAMLINE_STEPS, MAX_STREAMLINES, SliceView}; use crate::requirement::Requirement; use crate::results::{Field, Kind, MAX_BANDS, MIN_BANDS, ResultsView}; use crate::{CommandKind, CommandSpec, EngineError, Result, Session}; @@ -23,7 +24,7 @@ pub const DEFORMED: CommandSpec = CommandSpec { pub const CONTOUR: CommandSpec = CommandSpec { id: "results.contour", label: "Contours", - params: r#"{"step": "Load", "field": "von_mises", "bands": 12, "range"?: {"min"?: 0, "max"?: 80} | "auto"} — contours of a nodal field: displacement, displacement_x/y/z, von_mises, max/mid/min_principal, stress_xx/yy/zz/xy/xz/yz (stresses for static steps), contact_pressure, contact_gap, contact_status (0 open, 1 closed or stick, 2 slip), contact_shear, contact_slip (friction; nonlinear static steps with contact, at the contacts' secondary surfaces); "none" turns them off; 2 to 24 bands; `range` clamps the legend (a bound left out, null or "auto" follows the field; values above `max` are drawn in the over colour), kept until the field changes"#, + params: r#"{"step": "Load", "field": "von_mises", "bands": 12, "range"?: {"min"?: 0, "max"?: 80} | "auto"} — contours of a nodal field: displacement, displacement_x/y/z, velocity (explicit and fluid steps), pressure (fluid steps), von_mises, max/mid/min_principal, stress_xx/yy/zz/xy/xz/yz (stresses for static steps), contact_pressure, contact_gap, contact_status (0 open, 1 closed or stick, 2 slip), contact_shear, contact_slip (friction; nonlinear static steps with contact, at the contacts' secondary surfaces); "none" turns them off; 2 to 24 bands; `range` clamps the legend (a bound left out, null or "auto" follows the field; values above `max` are drawn in the over colour), kept until the field changes"#, form: Some(super::forms::CONTOUR), kind: CommandKind::Session, requires: &[Requirement::Result], @@ -53,7 +54,7 @@ pub const ANIMATE: CommandSpec = CommandSpec { pub const FRAME: CommandSpec = CommandSpec { id: "results.frame", label: "Field Frame", - params: r#"{"step": "Impact", "frame": 3, "time": 0.002} — shows a field frame of a solved explicit step, by index from 0 or the frame nearest a time (neither: the last), or of a nonlinear static step (one per converged increment, `time` its load factor; the deformed shape follows it, its contours show the final state); the deformed shape and contours follow it"#, + params: r#"{"step": "Impact", "frame": 3, "time": 0.002} — shows a field frame of a solved explicit or fluid step, by index from 0 or the frame nearest a time (neither: the last), or of a nonlinear static step (one per converged increment, `time` its load factor; the deformed shape follows it, its contours show the final state); the deformed shape and contours follow it"#, form: Some(super::forms::FRAME), kind: CommandKind::Session, requires: &[Requirement::Result], @@ -90,6 +91,36 @@ pub const EXPORT_CSV: CommandSpec = CommandSpec { run: export_csv, }; +pub const SLICE: CommandSpec = CommandSpec { + id: "results.slice", + label: "Flow Slice", + params: r#"{"step": "Flow", "field": "velocity" | "pressure", "axis": "x" | "y" | "z" | "none", "position": 20 | "auto", "frame": 10} — shows a fluid step's field (velocity magnitude or gauge pressure, banded with the legend as results.contour sets it) on a slice through the domain normal to `axis`, at `position` along it (inside the domain; "auto" or left out with a new axis: the domain's middle); "none" hides the slice; `frame` from 0 (left out: the one shown, at first the last); what is left out stays as shown. A fluid step is first shown with velocity on a slice through the middle, normal to the horizontal axis across the flow"#, + form: Some(super::forms::SLICE), + kind: CommandKind::Session, + requires: &[Requirement::Result], + run: slice, +}; + +pub const SURFACE: CommandSpec = CommandSpec { + id: "results.surface", + label: "Flow on Surfaces", + params: r#"{"step": "Flow", "show": true} — colours a fluid step's obstacles (the part drawn) by the field shown, read in the fluid cells beside them; `show: false` draws the part in its own colours"#, + form: Some(super::forms::FLOW_SURFACE), + kind: CommandKind::Session, + requires: &[Requirement::Result], + run: surface, +}; + +pub const STREAMLINES: CommandSpec = CommandSpec { + id: "results.streamlines", + label: "Streamlines", + params: r#"{"step": "Flow", "show": true, "count": 64, "max_steps": 4000, "seeds": [[x, y, z], …], "line": {"from": [x, y, z], "to": [x, y, z]}, "frame": 10} — streamlines of a fluid step, traced downstream through its velocity field (trilinear between the cells, midpoint rule, half-cell steps) and coloured by speed when the legend shows velocity: from `seeds` (at most 2000 points), from `count` points evenly along `line`, or else from `count` seeds (1 to 2000, default 64) on the inlet face upstream of the obstacles; each at most `max_steps` steps (1 to 100000, default 4000), 2 000 000 points in all; `show: false` hides them; `frame` as for results.slice. Replies with the view and `traced`: the streamlines, their points, the seeds and how many started none (outside the fluid)"#, + form: Some(super::forms::STREAMLINES), + kind: CommandKind::Session, + requires: &[Requirement::Result], + run: streamlines, +}; + pub const CLEAR: CommandSpec = CommandSpec { id: "results.clear", label: "Hide Results", @@ -115,23 +146,27 @@ fn solved_list(s: &Session) -> String { pub(super) enum Want { Any, Frequency, - /// Steps with frames: explicit dynamics and nonlinear static (one per converged increment). + /// Steps with frames: explicit dynamics, fluid and nonlinear static (one per converged + /// increment). Frames, + Fluid, } impl Want { fn fits(self, s: &Session, step: &str) -> bool { s.latest_result(step).is_some_and(|(r, _)| match (self, r.kind()) { - (Want::Any, _) | (Want::Frequency, Kind::Frequency(_)) | (Want::Frames, Kind::Explicit(_)) => true, + (Want::Any, _) | (Want::Frequency, Kind::Frequency(_)) | (Want::Frames | Want::Fluid, Kind::Fluid(_)) => true, + (Want::Frames, Kind::Explicit(_)) => true, (Want::Frames, Kind::Static(r)) => r.nonlinear.is_some(), - (Want::Frequency | Want::Frames, _) => false, + (Want::Frequency | Want::Frames | Want::Fluid, _) => false, }) } fn name(self) -> &'static str { match self { Want::Any => "solved", Want::Frequency => "frequency", - Want::Frames => "explicit or nonlinear static", + Want::Frames => "explicit, fluid or nonlinear static", + Want::Fluid => "fluid", } } } @@ -291,6 +326,9 @@ 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 v = view_of(s, &step)?; v.animate = true; v.deformed = true; @@ -302,6 +340,7 @@ fn frame(s: &mut Session, p: &Value) -> Result { let times: Vec = match s.latest_result(&step).map(|(r, _)| r.kind()) { Some(Kind::Explicit(e)) => e.frames.iter().map(|f| f.time).collect(), Some(Kind::Static(r)) => r.nonlinear.as_ref().map(|h| h.load_factors()).unwrap_or_default(), + Some(Kind::Fluid(f)) => f.frames.iter().map(|f| f.time).collect(), _ => Vec::new(), }; let last = times.len().checked_sub(1).ok_or_else(|| EngineError::Params(format!("step `{step}` has no field frames")))?; @@ -322,6 +361,182 @@ fn frame(s: &mut Session, p: &Value) -> Result { Ok(s.results_report()) } +/// The fluid step a fluid display command works on (`step`, or the one shown) and its result. +fn fluid_step(s: &Session, p: &Value) -> Result<(String, std::sync::Arc)> { + let step = step_for(s, p, Want::Fluid)?; + let r = s.latest_result(&step).map(|(r, _)| r.clone()).ok_or_else(|| EngineError::Params(solved_list(s)))?; + Ok((step, r)) +} + +/// A whole number in `range`, when given. +fn whole(p: &Value, key: &str, range: std::ops::RangeInclusive) -> Result> { + match p.get(key).filter(|v| !v.is_null()) { + None => Ok(None), + Some(v) => v.as_u64().filter(|n| range.contains(n)).map(Some).ok_or_else(|| EngineError::param(key, super::mesh::whole_in(&range))), + } +} + +/// A fluid command's `frame`: from 0 to the last; `None` when left out. +fn fluid_frame(f: &fieldcraft_lbm::FluidResult, p: &Value) -> Result> { + let last = f.frames.len().saturating_sub(1) as u64; + Ok(whole(p, "frame", 0..=last)?.and_then(|k| usize::try_from(k).ok())) +} + +/// A whole number from 1 to `max`, when given. +fn count_param(p: &Value, key: &str, max: u32) -> Result> { + Ok(whole(p, key, 1..=u64::from(max))?.and_then(|n| u32::try_from(n).ok())) +} + +/// Largest coordinate a seed may have. +const MAX_COORDINATE: f64 = 1e12; + +/// A point `[x, y, z]` of finite coordinates no larger than [`MAX_COORDINATE`]. +fn seed_point(v: &Value) -> Option<[f64; 3]> { + let a = v.as_array().filter(|a| a.len() == 3)?; + let c: Vec = a.iter().filter_map(Value::as_f64).filter(|x| x.is_finite() && x.abs() <= MAX_COORDINATE).collect(); + match c[..] { + [x, y, z] => Some([x, y, z]), + _ => None, + } +} + +fn slice(s: &mut Session, p: &Value) -> Result { + let (step, r) = fluid_step(s, p)?; + let Kind::Fluid(f) = r.kind() else { return Err(EngineError::Params(format!("step `{step}` is not a fluid step"))) }; + let field = match opt::(p, "field")?.as_deref() { + None => None, + Some("velocity") => Some(Field::Velocity), + Some("pressure") => Some(Field::Pressure), + Some(_) => return Err(EngineError::invalid("field", "must be \"velocity\" or \"pressure\"")), + }; + // Some(None): hide the slice. + let axis = match opt::(p, "axis")?.as_deref() { + None => None, + Some("none") => Some(None), + Some(a) => Some(Some(Axis::parse(a).ok_or_else(|| EngineError::invalid("axis", "must be x, y, z or none"))?)), + }; + // Some(None): the domain's middle. + let position = match p.get("position").filter(|v| !v.is_null()) { + None => None, + Some(Value::String(a)) if a == "auto" => Some(None), + Some(v) => Some(Some(v.as_f64().filter(|x| x.is_finite()).ok_or_else(|| EngineError::param("position", ProblemKind::NotANumber))?)), + }; + let frame = fluid_frame(f, p)?; + let first = FlowView::for_result(f); + let shown = s.results_view.as_ref().filter(|v| v.step == step).and_then(|v| v.flow.clone()).unwrap_or_else(|| first.clone()); + let slice = match axis { + Some(None) => None, + Some(Some(a)) => { + // A new axis starts at the middle; the same one keeps its position. + let kept = shown.slice.as_ref().filter(|s| s.axis == a).and_then(|s| s.position); + Some(SliceView { axis: a, position: position.unwrap_or(kept) }) + } + None => { + let current = shown.slice.clone().or(first.slice); + current.map(|s| SliceView { position: position.unwrap_or(s.position), ..s }) + } + }; + if let Some(sv) = &slice + && let Some(at) = sv.position + { + let g = crate::flow::grid(f); + let (lo, hi) = (g.start(sv.axis.index()), g.end(sv.axis.index())); + if !(at >= lo && at <= hi) { + return Err(EngineError::invalid("position", format!("must be inside the domain: {lo} to {hi} along {}", sv.axis.id()))); + } + } + let v = view_of(s, &step)?; + if let Some(field) = field { + if v.contour != Some(field) { + (v.range_min, v.range_max) = (None, None); + } + v.contour = Some(field); + } + if frame.is_some() { + v.frame = frame; + } + v.flow = Some(FlowView { slice, ..shown }); + Ok(s.results_report()) +} + +fn surface(s: &mut Session, p: &Value) -> Result { + let (step, r) = fluid_step(s, p)?; + let Kind::Fluid(f) = r.kind() else { return Err(EngineError::Params(format!("step `{step}` is not a fluid step"))) }; + let show = opt::(p, "show")?.unwrap_or(true); + let v = view_of(s, &step)?; + let mut flow = FlowView::of(v, f).into_owned(); + flow.surface = show; + v.flow = Some(flow); + Ok(s.results_report()) +} + +fn streamlines(s: &mut Session, p: &Value) -> Result { + let (step, r) = fluid_step(s, p)?; + let Kind::Fluid(f) = r.kind() else { return Err(EngineError::Params(format!("step `{step}` is not a fluid step"))) }; + let show = opt::(p, "show")?.unwrap_or(true); + let count = count_param(p, "count", MAX_STREAMLINES)?; + let max_steps = count_param(p, "max_steps", MAX_STREAMLINE_STEPS)?; + let max = MAX_STREAMLINES as usize; + let seeds = match p.get("seeds").filter(|v| !v.is_null()) { + None => None, + Some(_) => { + let list = super::points(p, "seeds", max)?; + if list.is_empty() || list.iter().flatten().any(|c| !(c.is_finite() && c.abs() <= MAX_COORDINATE)) { + return Err(EngineError::invalid("seeds", format!("must be 1 to {max} points [x, y, z] of finite coordinates"))); + } + Some(list) + } + }; + let line = match p.get("line").filter(|v| !v.is_null()) { + None => None, + Some(v) => { + let end = |k: &str| v.get(k).and_then(seed_point); + match (end("from"), end("to")) { + (Some(a), Some(b)) => Some((a, b)), + _ => return Err(EngineError::invalid("line", "must be {\"from\": [x, y, z], \"to\": [x, y, z]} of finite coordinates")), + } + } + }; + if seeds.is_some() && line.is_some() { + return Err(EngineError::invalid("line", "give `seeds` or `line`, not both")); + } + let frame = fluid_frame(f, p)?; + let v = view_of(s, &step)?; + let mut flow = FlowView::of(v, f).into_owned(); + flow.streamlines = if show { + let mut sl = flow.streamlines.take().unwrap_or_default(); + sl.count = count.unwrap_or(sl.count); + sl.max_steps = max_steps.unwrap_or(sl.max_steps); + sl.seeds = match (seeds, line) { + (Some(points), _) => Some(points), + (None, Some((a, b))) => { + let n = sl.count.max(1); + let at = |i: u32| if n == 1 { 0.5 } else { f64::from(i) / f64::from(n - 1) }; + Some((0..n).map(|i| std::array::from_fn(|k| a[k] + (b[k] - a[k]) * at(i))).collect()) + } + (None, None) => None, + }; + Some(sl) + } else { + None + }; + if frame.is_some() { + v.frame = frame; + } + let pick = v.pick(); + let traced = flow.streamlines.clone(); + v.flow = Some(flow); + let mut out = s.results_report(); + if let Some(sl) = traced { + // Traced once: the plot drawn after this reply takes the same lines from the session. + let k = crate::flow::frame_index(f, pick)?; + let (seeds, lines) = s.traced.trace(&r, k, &sl)?; + let points: usize = lines.iter().map(|l| l.points.len()).sum(); + out["traced"] = json!({"streamlines": lines.len(), "points": points, "seeds": seeds, "no_start": seeds.saturating_sub(lines.len())}); + } + Ok(out) +} + fn clear(s: &mut Session, _: &Value) -> Result { s.results_view = None; Ok(json!({"view": null})) @@ -1161,6 +1376,124 @@ mod tests { assert!(s.execute("results.reactions", &json!({})).is_err()); } + /// A session showing a synthetic fluid result ([`crate::flow::tests::channel`]). + fn fluid() -> Session { + let mut s = Session::default(); + s.store_result(crate::StepResult::Fluid(crate::flow::tests::channel())); + s + } + + /// A fluid step is shown with velocity on a slice across the flow, its obstacles coloured + /// and streamlines from the inlet (#193); the commands change what is shown, not the model. + #[test] + fn fluid_steps_show_a_slice_the_obstacles_and_streamlines() { + let mut s = fluid(); + let (journal, revision) = (s.journal.len(), s.revision()); + let r = s.results_report(); + assert_eq!(r["view"]["contour"], "velocity", "{r}"); + assert_eq!(r["contour"]["range"]["max"]["value"], 10.0, "{r}"); + assert_eq!(r["contour"]["range"]["max"]["at"], json!([9.5, 0.5, 0.5]), "{r}"); + assert_eq!(r["slice"], json!({"axis": "y", "position": 2.0})); + assert_eq!((r["surface"].as_bool(), r["streamlines"]["inlet"].as_str()), (Some(true), Some("x_min")), "{r}"); + // A slice normal to z at 1.5 coloured by pressure; "auto" goes back to the middle, a new + // axis starts there; positions outside the domain are refused; "none" hides it. + let r = s.execute("results.slice", &json!({"axis": "z", "position": 1.5, "field": "pressure"})).unwrap(); + assert_eq!((r["slice"].clone(), r["contour"]["field"].as_str()), (json!({"axis": "z", "position": 1.5}), Some("pressure")), "{r}"); + assert_eq!(s.execute("results.slice", &json!({"position": "auto"})).unwrap()["slice"]["position"], 2.0); + assert_eq!(s.execute("results.slice", &json!({"axis": "x"})).unwrap()["slice"], json!({"axis": "x", "position": 5.0})); + let e = s.execute("results.slice", &json!({"position": 11})).unwrap_err().to_string(); + assert!(e.contains("0 to 10"), "{e}"); + assert!(s.execute("results.slice", &json!({"axis": "none"})).unwrap()["slice"].is_null()); + // results.contour reaches the same fields; nodal ones are refused. + s.execute("results.contour", &json!({"field": "velocity", "bands": 6})).unwrap(); + assert!(s.execute("results.contour", &json!({"field": "von_mises"})).is_err()); + // Frames, by results.frame or with the slice. + assert_eq!(s.execute("results.frame", &json!({"frame": 0})).unwrap()["time"], 0.0); + assert_eq!(s.execute("results.slice", &json!({"frame": 1, "axis": "y"})).unwrap()["time"], 1.0); + // Streamlines from the inlet, from seeds, along a line; hidden. + let r = s.execute("results.streamlines", &json!({"count": 4})).unwrap(); + assert_eq!((r["traced"]["seeds"].as_u64(), r["traced"]["streamlines"].as_u64()), (Some(4), Some(4)), "{r}"); + // The reply's trace is the one the plot draws. + let (view, result) = (s.results_view.as_ref().unwrap(), s.latest_result("Flow").unwrap().0); + let p = crate::flow::plot(view, result, &s.traced).unwrap(); + let sl = view.flow.as_ref().and_then(|f| f.streamlines.as_ref()).unwrap(); + assert!(std::sync::Arc::ptr_eq(&p.streamlines, &s.traced.trace(result, 1, sl).unwrap().1)); + let r = s.execute("results.streamlines", &json!({"seeds": [[0.5, 0.5, 0.5], [5.5, 1.5, 1.5], [50, 0, 0]]})).unwrap(); + assert_eq!((r["traced"]["streamlines"].as_u64(), r["traced"]["no_start"].as_u64()), (Some(1), Some(2)), "{r}"); + assert_eq!(r["streamlines"]["seeds"], 3); + let r = + s.execute("results.streamlines", &json!({"line": {"from": [0.5, 0.5, 0.5], "to": [0.5, 3.5, 3.5]}, "count": 5, "max_steps": 3})).unwrap(); + 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"); + assert_eq!((s.journal.len(), s.revision()), (journal, revision), "display commands are not edits"); + // Structural steps have none of it. + let mut t = solved(); + for id in ["results.slice", "results.surface", "results.streamlines"] { + assert!(t.execute(id, &json!({})).is_err(), "{id}"); + assert!(t.execute(id, &json!({"step": "Bend"})).is_err(), "{id}"); + } + } + + #[test] + fn fluid_display_hostile_params_never_panic() { + let s = fluid(); + let point = json!([0.5, 0.5, 0.5]); + let hostile = [ + json!(null), + json!([]), + json!({"step": 7}), + json!({"step": "Nope"}), + json!({"field": "nope"}), + json!({"field": 3}), + json!({"axis": "w"}), + json!({"axis": 1}), + json!({"axis": ""}), + json!({"position": 1e308}), + json!({"position": -1e308}), + json!({"position": "middle"}), + json!({"position": [1]}), + json!({"axis": "z", "position": -0.001}), + json!({"frame": -1}), + json!({"frame": 2}), + json!({"frame": 1e300}), + json!({"frame": "last"}), + json!({"show": "yes"}), + json!({"count": 0}), + json!({"count": 2001}), + json!({"count": -5}), + json!({"count": 1e300}), + json!({"count": "many"}), + json!({"max_steps": 0}), + json!({"max_steps": 100_001}), + json!({"max_steps": u64::MAX}), + json!({"seeds": []}), + json!({"seeds": [[1, 2]]}), + json!({"seeds": [[1e300, 0, 0]]}), + json!({"seeds": [[1, "x", 0]]}), + json!({"seeds": "here"}), + json!({"seeds": vec![point.clone(); 2001]}), + json!({"seeds": [point], "line": {"from": [0, 0, 0], "to": [1, 1, 1]}}), + json!({"line": {"from": [0, 0, 0]}}), + json!({"line": 5}), + json!({"line": {"from": [0, 0, 0], "to": [1e13, 0, 0]}}), + json!({"count": 2000, "max_steps": 100_000}), + json!({"seeds": vec![point; 2000], "max_steps": 100_000}), + ]; + for id in + ["results.slice", "results.surface", "results.streamlines", "results.frame", "results.contour", "results.deformed", "results.animate"] + { + for p in &hostile { + let mut t = s.clone(); + let _ = t.execute(id, p); + assert_eq!(t.model, s.model); + // Whatever was accepted still draws. + let _ = crate::view::scene(&t, &crate::view::Palette::LIGHT); + } + } + } + #[test] fn results_hostile_params_never_panic() { let s = solved(); diff --git a/crates/engine/src/flow.rs b/crates/engine/src/flow.rs new file mode 100644 index 0000000..a932d3a --- /dev/null +++ b/crates/engine/src/flow.rs @@ -0,0 +1,586 @@ +//! The display of a fluid step's result: its fields per cell drawn as a coloured slice through the +//! domain, on the obstacles' surfaces beside the fluid, and as streamlines coloured by speed +//! ([`fieldcraft_render::flow`]), as the results view's [`FlowView`] asks. The headless pictures +//! ([`crate::view`]) and the desktop viewport draw the same [`FlowPlot`]; the part stays drawn +//! (a fluid step does not deform it). +//! +//! A fluid step is first shown with velocity magnitude on a slice through the middle of the +//! domain, normal to the horizontal axis across the flow (the slice holds the flow and the +//! vertical: Z is up), on the obstacles, and with streamlines seeded on the inlet face upstream +//! of the obstacles. + +use std::sync::{Arc, Mutex, Weak}; + +use fieldcraft_lbm::{FluidResult, Frame}; +use fieldcraft_render::contour::{Bands, banded, bands_color}; +use fieldcraft_render::flow::{self, CellGrid, Slice, Streamline}; +use fieldcraft_render::{Aabb3, Rgb, Scene, SceneLine, Vec3}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use crate::results::{Field, Kind, Pick, ResultsView, magnitude}; +use crate::{EngineError, Result, StepResult}; + +/// Streamlines traced when no count is given, and the steps (half a cell each) each may take. +pub const DEFAULT_STREAMLINES: u32 = 64; +pub const DEFAULT_STREAMLINE_STEPS: u32 = 4_000; +/// Most streamlines and most steps of one, as commands accept them. +pub const MAX_STREAMLINES: u32 = flow::MAX_STREAMLINES as u32; +pub const MAX_STREAMLINE_STEPS: u32 = flow::MAX_STREAMLINE_STEPS as u32; +/// A streamline's width, in points. +const STREAMLINE_WIDTH: f64 = 1.5; + +/// A coordinate axis: the normal of a slice. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Axis { + X, + Y, + Z, +} + +impl Axis { + pub const ALL: [Axis; 3] = [Axis::X, Axis::Y, Axis::Z]; + + /// 0, 1, 2. + pub fn index(self) -> usize { + match self { + Axis::X => 0, + Axis::Y => 1, + Axis::Z => 2, + } + } + + pub fn id(self) -> &'static str { + match self { + Axis::X => "x", + Axis::Y => "y", + Axis::Z => "z", + } + } + + pub fn parse(id: &str) -> Option { + Axis::ALL.into_iter().find(|a| a.id().eq_ignore_ascii_case(id.trim())) + } +} + +/// A slice through the domain: normal to `axis` at `position` along it (`None`: the domain's +/// middle). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct SliceView { + pub axis: Axis, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub position: Option, +} + +/// Streamlines: from `seeds`, or (`None`) `count` seeds on the inlet face upstream of the +/// obstacles; each at most `max_steps` steps of half a cell. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct StreamlineView { + pub count: u32, + pub max_steps: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub seeds: Option>, +} + +impl Default for StreamlineView { + fn default() -> Self { + StreamlineView { count: DEFAULT_STREAMLINES, max_steps: DEFAULT_STREAMLINE_STEPS, seeds: None } + } +} + +/// What a fluid step's display shows besides the field's legend (the view's contour field: +/// velocity or pressure, `None` draws no colours). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct FlowView { + /// The slice, `None` for none. + pub slice: Option, + /// The obstacles' surfaces coloured by the field in the fluid beside them. + pub surface: bool, + pub streamlines: Option, +} + +impl FlowView { + /// A fluid result's first look: a slice through the middle across the flow, the surfaces + /// coloured, streamlines from the inlet. + pub fn for_result(r: &FluidResult) -> FlowView { + let along = r.frames.last().map_or(Axis::X, |f| direction(r, f).0); + // The slice holds the flow and the vertical (Z is up); for a flow along y, it is normal to x. + let axis = if along == Axis::Y { Axis::X } else { Axis::Y }; + FlowView { slice: Some(SliceView { axis, position: None }), surface: true, streamlines: Some(StreamlineView::default()) } + } + + /// What `view` shows of fluid result `r`: its own settings, or the result's first look. + pub fn of<'a>(view: &'a ResultsView, r: &FluidResult) -> std::borrow::Cow<'a, FlowView> { + match &view.flow { + Some(f) => std::borrow::Cow::Borrowed(f), + None => std::borrow::Cow::Owned(FlowView::for_result(r)), + } + } +} + +/// The result's grid of cells. +pub fn grid(r: &FluidResult) -> CellGrid { + CellGrid { origin: r.grid.origin, spacing: r.grid.spacing, cells: r.grid.cells } +} + +/// The index of the frame of a fluid result `pick` asks for (`None`: the last). +pub fn frame_index(r: &FluidResult, pick: Pick) -> Result { + let last = r.frames.len().checked_sub(1).ok_or_else(|| EngineError::Params(format!("step `{}` has no frames", r.step)))?; + let k = pick.frame.unwrap_or(last); + if k > last { + return Err(EngineError::Params(format!("step `{}` has frames 0 to {last}; there is no frame {k}", r.step))); + } + Ok(k) +} + +/// The frame of a fluid result `pick` asks for (`None`: the last). +pub fn frame(r: &FluidResult, pick: Pick) -> Result<&Frame> { + let k = frame_index(r, pick)?; + r.frames.get(k).ok_or_else(|| EngineError::Params(format!("step `{}` has no frame {k}", r.step))) +} + +/// The axis the flow mostly runs along in a frame (by its mean velocity over the fluid cells) and +/// whether it runs toward its high end; along +x when the fluid is at rest. +pub fn direction(r: &FluidResult, f: &Frame) -> (Axis, bool) { + let mut sum = [0.0f64; 3]; + for (k, v) in f.velocity.iter().enumerate() { + if !r.solid.get(k).copied().unwrap_or(false) { + for (s, c) in sum.iter_mut().zip(v) { + if c.is_finite() { + *s += f64::from(*c); + } + } + } + } + let axis = Axis::ALL.into_iter().fold(Axis::X, |best, a| if sum[a.index()].abs() > sum[best.index()].abs() { a } else { best }); + (axis, sum[axis.index()] >= 0.0) +} + +/// A cell field's value in every cell of a frame: velocity magnitude or pressure, NaN in the +/// solid cells. +pub fn cell_values(r: &FluidResult, field: Field, f: &Frame) -> Result> { + let solid = |k: usize| r.solid.get(k).copied().unwrap_or(false); + let values: Vec = match field { + Field::Velocity => f.velocity.iter().map(|v| magnitude(v.map(f64::from))).collect(), + Field::Pressure => f.pressure.iter().map(|p| f64::from(*p)).collect(), + _ => return Err(EngineError::Params(format!("{} is not available for fluid step `{}` (velocity or pressure)", field.label(), r.step))), + }; + Ok(values.into_iter().enumerate().map(|(k, v)| if solid(k) || !v.is_finite() { f64::NAN } else { v }).collect()) +} + +/// The smallest and largest value, with their cells. +pub fn cell_range(values: &[f64]) -> Option<((usize, f64), (usize, f64))> { + let mut out: Option<((usize, f64), (usize, f64))> = None; + for (k, &v) in values.iter().enumerate().filter(|(_, v)| v.is_finite()) { + out = Some(match out { + None => ((k, v), (k, v)), + Some((lo, hi)) => (if v < lo.1 { (k, v) } else { lo }, if v > hi.1 { (k, v) } else { hi }), + }); + } + out +} + +/// The centre of cell `k` of a result's grid. +pub fn cell_centre(r: &FluidResult, k: usize) -> [f64; 3] { + let g = grid(r); + let c = g.centre(g.coords(k)); + [c.x, c.y, c.z] +} + +/// Where a slice lies: its position, or the middle of the domain, held inside it. +pub fn slice_position(r: &FluidResult, s: &SliceView) -> f64 { + let g = grid(r); + let a = s.axis.index(); + let (lo, hi) = (g.start(a), g.end(a)); + s.position.filter(|p| p.is_finite()).unwrap_or(0.5 * (lo + hi)).clamp(lo.min(hi), hi.max(lo)) +} + +/// The seeds streamlines start from: the given ones, or `count` on the inlet face (where the +/// flow enters the domain) upstream of the obstacles. +pub fn seeds(r: &FluidResult, f: &Frame, s: &StreamlineView) -> Vec { + match &s.seeds { + Some(points) => points.iter().map(|p| Vec3::from(*p)).collect(), + None => { + let (axis, toward_high) = direction(r, f); + flow::face_seeds(&grid(r), &r.solid, axis.index(), !toward_high, s.count as usize) + } + } +} + +/// The streamlines a view asks for in a frame, and how many seeds they started from. +pub fn trace(r: &FluidResult, f: &Frame, s: &StreamlineView) -> (usize, Vec) { + let seeds = seeds(r, f, s); + (seeds.len(), flow::streamlines(&grid(r), &f.velocity, &r.solid, &seeds, s.max_steps as usize)) +} + +/// Streamlines traced once and shared: `results.streamlines` traces them for its reply, and the +/// plot drawn right after takes them from here instead of tracing them again (at the caps, a +/// trace is 2 000 000 points). One entry: the lines of one result, frame and settings, kept +/// while that result lives. +#[derive(Debug, Default)] +pub struct TraceCache(Mutex>); + +#[derive(Clone, Debug)] +struct Traced { + result: Weak, + frame: usize, + view: StreamlineView, + seeds: usize, + lines: Arc>, +} + +impl Clone for TraceCache { + fn clone(&self) -> Self { + TraceCache(Mutex::new(self.lock().clone())) + } +} + +impl TraceCache { + fn lock(&self) -> std::sync::MutexGuard<'_, Option> { + self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner) + } + + /// The streamlines `s` asks for in frame `k` of the fluid result `result`, and how many + /// seeds they started from: the kept ones when they are those, else traced now and kept. + pub fn trace(&self, result: &Arc, k: usize, s: &StreamlineView) -> Result<(usize, Arc>)> { + let Kind::Fluid(r) = result.kind() else { return Err(EngineError::Params("streamlines are traced in fluid steps".into())) }; + let f = r.frames.get(k).ok_or_else(|| EngineError::Params(format!("step `{}` has no frame {k}", r.step)))?; + if let Some(t) = self.lock().as_ref() + && t.frame == k + && t.view == *s + && t.result.upgrade().is_some_and(|a| Arc::ptr_eq(&a, result)) + { + return Ok((t.seeds, t.lines.clone())); + } + let (seeds, lines) = trace(r, f, s); + let lines = Arc::new(lines); + *self.lock() = Some(Traced { result: Arc::downgrade(result), frame: k, view: s.clone(), seeds, lines: lines.clone() }); + Ok((seeds, lines)) + } +} + +/// A cell field ready to colour: its values, bands and extremes (with their cells). +#[derive(Clone, Debug, PartialEq)] +pub struct CellContour { + pub field: Field, + pub values: Vec, + pub bands: Bands, + pub min: (usize, f64), + pub max: (usize, f64), +} + +/// A fluid step's display, ready to draw ([`draw`]). +#[derive(Clone, Debug, PartialEq)] +pub struct FlowPlot { + /// What it shows: the view's settings, or the result's first look. + pub view: FlowView, + pub grid: CellGrid, + /// The field coloured, when there is one. + pub contour: Option, + /// The slice (with its normal axis and position), when there is one and a field to colour it. + pub slice: Option<(Axis, f64, Slice)>, + pub surface: bool, + /// Shared with the [`TraceCache`] they came from. + pub streamlines: Arc>, + /// The bands streamlines are coloured by speed over: the legend's when it shows velocity; + /// `None` draws them in one colour (the legend shows another field). + pub speed: Option, +} + +impl FlowPlot { + /// The box around what is drawn of the flow: the domain, with a slice or streamlines. + pub fn bounds(&self) -> Aabb3 { + if self.slice.is_some() || !self.streamlines.is_empty() { self.grid.bounds() } else { Aabb3::EMPTY } + } +} + +/// The display of the fluid result `result` the view asks for, its streamlines from `traced`. +pub fn plot(view: &ResultsView, result: &Arc, traced: &TraceCache) -> Result { + let Kind::Fluid(r) = result.kind() else { return Err(EngineError::Params("the flow display shows fluid steps".into())) }; + let k = frame_index(r, view.pick())?; + let f = frame(r, view.pick())?; + let fv = FlowView::of(view, r).into_owned(); + let contour = match view.contour.filter(|c| matches!(c, Field::Velocity | Field::Pressure)) { + Some(field) => { + let values = cell_values(r, field, f)?; + cell_range(&values).map(|(min, max)| CellContour { field, bands: view.bands(min.1, max.1), min, max, values }) + } + None => None, + }; + let g = grid(r); + let slice = match (&fv.slice, &contour) { + (Some(s), Some(c)) => { + let at = slice_position(r, s); + flow::slice(&g, &c.values, s.axis.index(), at).map(|sl| (s.axis, at, sl)) + } + _ => None, + }; + let streamlines = match &fv.streamlines { + Some(s) => traced.trace(result, k, s)?.1, + None => Arc::default(), + }; + let speed = contour.as_ref().filter(|c| c.field == Field::Velocity).map(|c| c.bands); + Ok(FlowPlot { grid: g, contour, slice, surface: fv.surface, streamlines, speed, view: fv }) +} + +/// The colours a flow plot is drawn in. +#[derive(Clone, Copy, Debug)] +pub struct FlowColors<'a> { + /// The contour scale, low to high, and values above a clamped legend's maximum. + pub stops: &'a [Rgb], + pub over: Rgb, + /// Streamlines when the legend shows another field than velocity. + pub plain: Rgb, +} + +/// Draw `plot` into `scene`, which holds the model as drawn: its faces beside the fluid +/// coloured by the field (when the surface is shown), then the slice and the streamlines. +pub fn draw(plot: &FlowPlot, scene: &mut Scene, colors: &FlowColors) { + if let Some(c) = &plot.contour { + let color = |band: Option| band.map_or(colors.plain, |k| bands_color(colors.stops, colors.over, &c.bands, k)); + if plot.surface { + let model = std::mem::take(&mut scene.tris); + for t in model { + let v = t.p.map(|p| flow::sample(&plot.grid, &c.values, p)); + let (sum, known) = v.iter().filter(|x| !x.is_nan()).fold((0.0, 0u32), |(s, n), x| (s + x, n + 1)); + if known == 0 { + scene.tris.push(t); + continue; + } + let mean = sum / f64::from(known); + banded(t.p, v.map(|x| if x.is_nan() { mean } else { x }), t.normal, &c.bands, &color, &mut scene.tris); + } + } + if let Some((_, _, s)) = &plot.slice { + for t in &s.tris { + banded(t.p, t.v, s.normal, &c.bands, &color, &mut scene.tris); + } + } + } + for l in plot.streamlines.iter() { + for (k, w) in l.points.windows(2).enumerate() { + let (Some(&a), Some(&b)) = (w.first(), w.get(1)) else { continue }; + let speed = 0.5 * (l.speed.get(k).copied().unwrap_or(0.0) + l.speed.get(k + 1).copied().unwrap_or(0.0)); + let color = plot.speed.as_ref().and_then(|b| b.band(speed).map(|k| bands_color(colors.stops, colors.over, b, k))).unwrap_or(colors.plain); + scene.lines.push(SceneLine { a, b, color, width: STREAMLINE_WIDTH }); + } + } +} + +/// What a fluid step's display shows, for the `results.*` replies: the field's range with the +/// cells (and their centres) of its extremes, the slice and the streamlines' settings. +pub fn report(view: &ResultsView, r: &FluidResult, units: fieldcraft_model::UnitSystem) -> Value { + let mut out = json!({}); + let Ok(f) = frame(r, view.pick()) else { return out }; + out["time"] = json!(f.time); + if let Some(field) = view.contour + && let Ok(values) = cell_values(r, field, f) + { + let end = |(k, v): (usize, f64)| json!({"cell": k, "at": cell_centre(r, k), "value": v}); + let range = cell_range(&values).map(|(lo, hi)| json!({"min": end(lo), "max": end(hi)})); + let unit = field.quantity().unit(units); + out["contour"] = json!({"field": field, "label": field.label(), "unit": unit, "range": range}); + if view.range_min.is_some() || view.range_max.is_some() { + out["contour"]["legend"] = json!({"min": view.range_min, "max": view.range_max}); + } + } + let fv = FlowView::of(view, r); + out["slice"] = match &fv.slice { + Some(s) => json!({"axis": s.axis, "position": slice_position(r, s)}), + None => Value::Null, + }; + out["surface"] = json!(fv.surface); + out["streamlines"] = match &fv.streamlines { + Some(s) => { + let mut v = json!({"count": s.count, "max_steps": s.max_steps}); + match &s.seeds { + Some(points) => v["seeds"] = json!(points.len()), + None => { + let (axis, high) = direction(r, f); + v["inlet"] = json!(format!("{}_{}", axis.id(), if high { "min" } else { "max" })); + } + } + v + } + None => Value::Null, + }; + out +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + use fieldcraft_lbm::{FluidStats, Grid}; + + /// A synthetic fluid result: a 10 × 4 × 4 grid of unit cells, a solid 2 × 2 × 2 block at + /// x, y, z in [5, 7] × [1, 3] × [1, 3], the fluid moving along +x at the speed of its x and + /// the pressure falling along x. + pub(crate) fn channel() -> FluidResult { + let grid = Grid { origin: [0.0; 3], spacing: 1.0, cells: [10, 4, 4] }; + let n = grid.len(); + let solid: Vec = (0..n).map(|c| matches!(grid.coords(c), [5 | 6, 1 | 2, 1 | 2])).collect(); + let velocity: Vec<[f32; 3]> = (0..n).map(|c| if solid[c] { [0.0; 3] } else { [grid.coords(c)[0] as f32 + 1.0, 0.0, 0.0] }).collect(); + let pressure: Vec = (0..n).map(|c| if solid[c] { 0.0 } else { 10.0 - grid.coords(c)[0] as f32 }).collect(); + let at_rest = Frame { time: 0.0, step: 0, velocity: vec![[0.0; 3]; n], pressure: vec![0.0; n], mass: 1.0 }; + let last = Frame { time: 1.0, step: 10, velocity, pressure, mass: 1.0 }; + let stats = FluidStats { + cells: n, + fluid_cells: n - 8, + steps: 10, + time_step: 0.1, + end_time: 1.0, + tau_plus: 1.0, + tau_minus: 1.0, + magic: 0.25, + velocity: 1.0, + lattice_velocity: 0.1, + mach: 0.1, + cell_updates: 0, + }; + FluidResult { + step: "Flow".into(), + grid, + solid, + frames: vec![at_rest, last], + history_times: Vec::new(), + forces: Vec::new(), + stats, + warnings: Vec::new(), + } + } + + /// A 40 × 30 × 10 mm plate (the geometry sample) in a tank 20 mm larger on every side, of + /// 5 mm cells, a liquid flowing along x past it: solved, so its result is shown. + pub(crate) fn plate_in_a_tank() -> crate::Session { + use fieldcraft_geometry::sample::{Plate, plate_design}; + let dir = std::env::temp_dir().join(format!("fieldcraft-flow-view-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("plate.solidcraft"); + std::fs::write(&path, plate_design(&Plate::default()).unwrap()).unwrap(); + let mut s = crate::Session::default(); + s.execute("geometry.open", &json!({"path": path.to_str().unwrap()})).unwrap(); + let _ = std::fs::remove_dir_all(&dir); + let script = json!([ + {"command": "model.material.create", "params": {"name": "Water", "density": 1e-9, "fluid": {"type": "newtonian", "viscosity": 1e-7}}}, + {"command": "model.fluid_domain.create", "params": {"name": "Tank", "material": "Water", "min": [-20, -20, -20], "max": [60, 50, 30], "spacing": 5, "obstacles": ["Body1"], + "faces": {"x_min": {"type": "velocity", "velocity": [10, 0, 0]}, "x_max": {"type": "pressure", "pressure": 0}}}}, + {"command": "model.step.create", "params": {"name": "Flow", "procedure": {"type": "fluid", "domain": "Tank", "steps": 60, "frames": 2}}}, + ]); + s.run_script(&script).unwrap(); + s.execute("solve.fluid", &json!({})).unwrap(); + s + } + + #[test] + fn a_fluid_result_is_first_shown_across_the_flow() { + let r = channel(); + assert_eq!(direction(&r, &r.frames[1]), (Axis::X, true)); + let v = FlowView::for_result(&r); + assert_eq!(v.slice, Some(SliceView { axis: Axis::Y, position: None })); + assert!(v.surface && v.streamlines.is_some()); + assert_eq!(slice_position(&r, v.slice.as_ref().unwrap()), 2.0); + assert_eq!(slice_position(&r, &SliceView { axis: Axis::Z, position: Some(99.0) }), 4.0); + assert_eq!(slice_position(&r, &SliceView { axis: Axis::Z, position: Some(f64::NAN) }), 2.0); + // A flow along -y is sliced normal to x and seeded on the y_max face. + let mut r = channel(); + for v in &mut r.frames[1].velocity { + *v = [0.0, -v[0], 0.0]; + } + assert_eq!(direction(&r, &r.frames[1]), (Axis::Y, false)); + assert_eq!(FlowView::for_result(&r).slice.unwrap().axis, Axis::X); + let s = seeds(&r, &r.frames[1], &StreamlineView { count: 4, ..StreamlineView::default() }); + assert!(s.iter().all(|p| p.y == 3.5), "{s:?}"); + } + + #[test] + fn cell_fields_leave_the_solid_out() { + let r = channel(); + let f = &r.frames[1]; + let speed = cell_values(&r, Field::Velocity, f).unwrap(); + assert_eq!(speed.iter().filter(|v| v.is_nan()).count(), 8); + let ((lo, a), (hi, b)) = cell_range(&speed).unwrap(); + assert_eq!((a, b, r.grid.coords(lo)[0], r.grid.coords(hi)[0]), (1.0, 10.0, 0, 9)); + assert_eq!(cell_centre(&r, hi), [9.5, 0.5, 0.5]); + let p = cell_values(&r, Field::Pressure, f).unwrap(); + assert_eq!(cell_range(&p).map(|(lo, hi)| (lo.1, hi.1)), Some((1.0, 10.0))); + assert!(cell_values(&r, Field::VonMises, f).is_err()); + assert_eq!(cell_range(&[f64::NAN]), None); + assert!(frame(&r, Pick { mode: 1, frame: Some(2) }).is_err()); + assert_eq!(frame(&r, Pick::default()).unwrap().time, 1.0); + } + + /// A trace is kept for its result, frame and settings: the reply and the plot share one; + /// another frame, other settings or another result trace anew. + #[test] + fn a_trace_is_shared_until_its_result_frame_or_settings_change() { + let result = Arc::new(crate::StepResult::Fluid(channel())); + let view = ResultsView::for_result(&result); + let traced = TraceCache::default(); + let s = StreamlineView::default(); + let (seeds, a) = traced.trace(&result, 1, &s).unwrap(); + assert!(seeds > 0 && !a.is_empty()); + let (_, b) = traced.trace(&result, 1, &s).unwrap(); + assert!(Arc::ptr_eq(&a, &b), "the same trace again"); + let p = plot(&view, &result, &traced).unwrap(); + assert!(Arc::ptr_eq(&a, &p.streamlines), "the plot draws the kept trace"); + let (_, c) = traced.trace(&result, 0, &s).unwrap(); + assert!(!Arc::ptr_eq(&a, &c), "another frame: another trace"); + let (_, d) = traced.trace(&result, 1, &StreamlineView { count: 3, ..s.clone() }).unwrap(); + assert!(!Arc::ptr_eq(&a, &d) && d.len() < a.len(), "other settings: another trace ({} lines, then {})", a.len(), d.len()); + let other = Arc::new(crate::StepResult::Fluid(channel())); + let (_, e) = traced.trace(&other, 1, &StreamlineView { count: 3, ..s.clone() }).unwrap(); + assert!(!Arc::ptr_eq(&d, &e), "another result"); + assert!(traced.trace(&result, 2, &s).is_err(), "no frame 2"); + let cloned = traced.clone(); + assert!(Arc::ptr_eq(&cloned.trace(&other, 1, &StreamlineView { count: 3, ..s }).unwrap().1, &e), "a clone keeps the trace"); + let solid = Arc::new(crate::StepResult::Fluid(channel())); + let mut v = view.clone(); + v.step = "Other".into(); + assert!(plot(&v, &solid, &traced).is_ok()); + } + + /// The plot colours the slice, the faces beside the fluid and the streamlines by speed. + #[test] + fn the_plot_draws_the_slice_surface_and_streamlines() { + let r = channel(); + let result = Arc::new(crate::StepResult::Fluid(r.clone())); + let mut view = ResultsView::for_result(&result); + assert_eq!(view.contour, Some(Field::Velocity)); + let traced = TraceCache::default(); + let p = plot(&view, &result, &traced).unwrap(); + let (axis, at, slice) = p.slice.as_ref().unwrap(); + assert_eq!((*axis, *at), (Axis::Y, 2.0)); + assert!(!slice.tris.is_empty()); + assert!(!p.streamlines.is_empty() && p.speed.is_some()); + assert_eq!(p.bounds(), p.grid.bounds()); + // A face of the block, at x = 5, and one far off the grid. + let tri = |x: f64| fieldcraft_render::SceneTri { + p: [Vec3::new(x, 1.0, 1.0), Vec3::new(x, 3.0, 1.0), Vec3::new(x, 1.0, 3.0)], + normal: Vec3::X, + color: Rgb(1, 2, 3), + }; + let mut scene = Scene { tris: vec![tri(5.0), tri(500.0)], ..Scene::default() }; + let stops = [Rgb(0, 0, 255), Rgb(255, 0, 0)]; + let colors = FlowColors { stops: &stops, over: Rgb(9, 9, 9), plain: Rgb(7, 7, 7) }; + draw(&p, &mut scene, &colors); + assert!(scene.tris.iter().any(|t| t.color == Rgb(1, 2, 3)), "the face off the grid keeps its colour"); + let coloured = scene.tris.iter().filter(|t| t.color != Rgb(1, 2, 3)).count(); + assert!(coloured > slice.tris.len(), "the slice and the block's face are coloured"); + assert_eq!(scene.lines.len(), p.streamlines.iter().map(|l| l.points.len() - 1).sum::()); + assert!(scene.lines.iter().all(|l| l.color != colors.plain)); + // Pressure: streamlines in the plain colour; no field: no slice, no colours. + view.contour = Some(Field::Pressure); + let p = plot(&view, &result, &traced).unwrap(); + let mut scene = Scene::default(); + draw(&p, &mut scene, &colors); + assert!(scene.lines.iter().all(|l| l.color == colors.plain)); + view.contour = None; + let p = plot(&view, &result, &traced).unwrap(); + assert!(p.slice.is_none() && p.contour.is_none() && !p.streamlines.is_empty()); + view.flow = Some(FlowView { slice: None, surface: false, streamlines: None }); + view.contour = Some(Field::Velocity); + let p = plot(&view, &result, &traced).unwrap(); + assert!(p.slice.is_none() && p.streamlines.is_empty() && p.bounds().is_empty()); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index ece772a..1e32a84 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -17,6 +17,7 @@ pub mod bug_report; pub mod catalogue; pub mod cmd; pub mod crash; +pub mod flow; pub mod logfile; pub use cmd::analytic_surfaces; @@ -298,6 +299,9 @@ pub struct Session { /// What the last `geometry.check` found, drawn as markers while the design it checked is /// open ([`view::geometry_marks`]). Not an edit: neither journalled, saved nor undone. pub geometry_marks: Option>, + /// The streamlines last traced for a fluid step's display ([`flow::TraceCache`]): the + /// `results.streamlines` reply and the plot share them. Not an edit either. + pub traced: flow::TraceCache, } impl Session { diff --git a/crates/engine/src/results.rs b/crates/engine/src/results.rs index fd69009..68e885a 100644 --- a/crates/engine/src/results.rs +++ b/crates/engine/src/results.rs @@ -1,6 +1,7 @@ //! What the results display shows ([`ResultsView`], set by the `results.*` commands) and the //! nodal values it draws: displacements, mode shapes, explicit field frames and stress fields, -//! read from a solved step's [`StepResult`]. An explicit frame's one-point element stresses are +//! read from a solved step's [`StepResult`]; a fluid step's cell fields are drawn by +//! [`crate::flow`]. An explicit frame's one-point element stresses are //! averaged to the nodes (unweighted, like the implicit solver's nodal stresses). //! //! The view is session state, like the camera: changing it is not an edit, so it is neither @@ -21,7 +22,7 @@ pub enum Kind<'a> { Static(&'a StaticResult), Frequency(&'a FrequencyResult), Explicit(&'a ExplicitResult), - /// Per cell, not per node: no nodal fields to display yet. + /// Per cell, not per node: drawn by [`crate::flow`]. Fluid(&'a fieldcraft_lbm::FluidResult), Heat(&'a HeatResult), } @@ -72,8 +73,10 @@ pub enum Field { DisplacementX, DisplacementY, DisplacementZ, - /// Velocity magnitude (explicit steps). + /// Velocity magnitude (explicit steps; per cell in fluid steps). Velocity, + /// Gauge pressure (per cell, fluid steps). + Pressure, VonMises, MaxPrincipal, MidPrincipal, @@ -103,12 +106,13 @@ pub enum Field { } impl Field { - pub const ALL: [Field; 22] = [ + pub const ALL: [Field; 23] = [ Field::Displacement, Field::DisplacementX, Field::DisplacementY, Field::DisplacementZ, Field::Velocity, + Field::Pressure, Field::VonMises, Field::MaxPrincipal, Field::MidPrincipal, @@ -136,6 +140,7 @@ impl Field { Field::DisplacementY => "displacement_y", Field::DisplacementZ => "displacement_z", Field::Velocity => "velocity", + Field::Pressure => "pressure", Field::VonMises => "von_mises", Field::MaxPrincipal => "max_principal", Field::MidPrincipal => "mid_principal", @@ -163,6 +168,7 @@ impl Field { Field::DisplacementY => "Displacement Y", Field::DisplacementZ => "Displacement Z", Field::Velocity => "Velocity magnitude", + Field::Pressure => "Pressure (gauge)", Field::VonMises => "Von Mises stress", Field::MaxPrincipal => "Max. principal stress", Field::MidPrincipal => "Mid. principal stress", @@ -188,6 +194,7 @@ impl Field { match self { Field::Displacement | Field::DisplacementX | Field::DisplacementY | Field::DisplacementZ | Field::ContactSlip => Quantity::Length, Field::Velocity => Quantity::Velocity, + Field::Pressure => Quantity::Stress, Field::Temperature => Quantity::Temperature, Field::HeatFlux => Quantity::HeatFlux, Field::ContactGap | Field::ContactStatus => Quantity::None, @@ -239,6 +246,7 @@ impl Field { | Field::DisplacementY | Field::DisplacementZ | Field::Velocity + | Field::Pressure | Field::Temperature | Field::HeatFlux ) && !self.is_contact() @@ -264,14 +272,15 @@ impl Field { Kind::Explicit(e) => { e.frames.last().map_or((false, false, false, false), |f| (!f.stress.is_empty(), !f.velocity.is_empty(), false, false)) } - // Cell fields have their own display (M12 streamlines); no nodal field. - Kind::Fluid(_) => return Vec::new().into_iter(), + // Cell fields, drawn by `crate::flow`. + Kind::Fluid(_) => return vec![Field::Velocity, Field::Pressure].into_iter(), Kind::Heat(_) => return Field::ALL.into_iter().filter(|f| f.is_thermal()).collect::>().into_iter(), }; Field::ALL .into_iter() .filter(move |f| match f { Field::Velocity => velocity, + Field::Pressure => false, Field::Temperature => temperature, Field::HeatFlux => flux, f if f.is_contact() => contact, @@ -385,6 +394,9 @@ pub struct ResultsView { pub frame: Option, /// Animate the deformation (a mode swings through ±, a static step grows from 0). pub animate: bool, + /// A fluid step's slice, surface colours and streamlines. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub flow: Option, } impl ResultsView { @@ -393,11 +405,18 @@ impl ResultsView { /// shape, at the automatic scale); a frequency step's first mode deformed with displacement /// contours (a mode shape is a deformation); an explicit step at its last frame and a /// nonlinear static step at its last increment, deformed to true scale ([`true_scale`]; - /// with von Mises contours when they kept stresses); a heat step undeformed. + /// with von Mises contours when they kept stresses); a heat step undeformed; a fluid step's + /// velocity magnitude on a slice, the obstacles and streamlines ([`crate::flow::FlowView`]). pub fn for_result(r: &StepResult) -> ResultsView { let heat = matches!(r.kind(), Kind::Heat(_)); + let flow = match r.kind() { + Kind::Fluid(f) => Some(crate::flow::FlowView::for_result(f)), + _ => None, + }; let contour = if heat { Field::Temperature + } else if flow.is_some() { + Field::Velocity } else if Field::available(r).any(|f| f == Field::VonMises) { Field::VonMises } else { @@ -415,6 +434,7 @@ impl ResultsView { mode: 1, frame: None, animate: false, + flow, } } @@ -515,6 +535,8 @@ pub fn field_values(model: &Model, r: &StepResult, field: Field, pick: Pick) -> } Kind::Static(_) | Kind::Frequency(_) | Kind::Fluid(_) | Kind::Heat(_) => Err(unavailable()), }, + // Per cell (crate::flow::cell_values), not per node. + Field::Pressure => Err(unavailable()), Field::Temperature => match r.kind() { Kind::Heat(h) => Ok(heat_frame(h, pick)?.1.to_vec()), Kind::Static(StaticResult { temperature: Some(t), .. }) => Ok(t.clone()), @@ -679,6 +701,11 @@ impl Session { } Kind::Fluid(f) => { out["frames"] = f.frames.iter().map(|f| f.time).collect(); + if let Value::Object(flow) = crate::flow::report(v, f, self.model.units()) { + for (k, x) in flow { + out[k.as_str()] = x; + } + } } Kind::Heat(h) => { out["heat_flows"] = heat_flows(h); @@ -746,7 +773,7 @@ mod tests { } let (modes, _) = s.latest_result("Modes").unwrap(); assert_eq!(Field::available(modes).count(), 4); - assert_eq!(Field::available(bend).count(), Field::ALL.len() - 8, "all but velocity, the thermal and the contact fields"); + assert_eq!(Field::available(bend).count(), Field::ALL.len() - 9, "all but velocity, pressure, the thermal and the contact fields"); assert!(field_values(&s.model, modes, Field::VonMises, Pick { mode: 1, frame: None }).is_err()); assert!(field_values(&s.model, modes, Field::Displacement, Pick { mode: 9, frame: None }).is_err()); assert_eq!(field_values(&s.model, modes, Field::Displacement, Pick { mode: 2, frame: None }).unwrap().len(), modes.nodes().len()); diff --git a/crates/engine/src/view.rs b/crates/engine/src/view.rs index 6d62608..f9fb254 100644 --- a/crates/engine/src/view.rs +++ b/crates/engine/src/view.rs @@ -5,19 +5,20 @@ //! The picture follows the desktop viewport's conventions: geometry draws as faces with their //! boundaries while the model has no element faces, then as boundaries only; elements in their //! kind's colour with their edges; a results view ([`crate::results::ResultsView`]) draws the -//! deformed shape (automatic or chosen scale) with its contour bands. Animation is not drawn: a +//! deformed shape (automatic or chosen scale) with its contour bands, or a fluid step's slice, +//! coloured obstacles and streamlines over the undeformed part ([`crate::flow`]). Animation is not drawn: a //! picture shows the full deformation (a mode at its positive shape). The analysis setup's //! symbols ([`fieldcraft_render::glyphs`]) are drawn over the model as the viewport draws them, //! muted over a results plot (and only those its step used), at their default size on screen //! once the camera is known. use fieldcraft_model::ElementKind; -use fieldcraft_render::contour::{bands_color, split_triangle}; +use fieldcraft_render::contour::{banded, bands_color}; use fieldcraft_render::glyphs::{self, GlyphColors, GlyphShow, GlyphView, Glyphs}; use fieldcraft_render::{Aabb3, Camera, DisplayMesh, Orientation, Rgb, Scene, SceneLine, ScenePoint, SceneTri, Vec3}; use serde_json::Value; -use crate::results::{auto_scale, displacements, field_values, range}; +use crate::results::{Kind, auto_scale, displacements, field_values, range}; use crate::{EngineError, Result, Session}; /// The colours of a picture. @@ -32,6 +33,9 @@ pub struct Palette { pub shell: Rgb, pub line_element: Rgb, pub mesh_edge: Rgb, + /// Streamlines when the legend shows another field than velocity: stands out from the + /// background. + pub streamline: Rgb, /// Nodes, when they are shown. pub node: Rgb, pub geometry: Rgb, @@ -66,6 +70,7 @@ impl Palette { shell: Rgb(0xc0, 0x8c, 0x78), line_element: Rgb(0xc8, 0x8c, 0x1e), mesh_edge: Rgb(0x1e, 0x23, 0x2a), + streamline: Rgb(0x28, 0x2e, 0x38), node: Rgb(0xb4, 0x3c, 0x8c), geometry: Rgb(176, 186, 198), geometry_edge: Rgb(40, 44, 52), @@ -303,7 +308,8 @@ pub struct Shown { pub step: Option, /// The deformation scale factor drawn (0: undeformed). pub scale: f64, - /// The contour field drawn, with its smallest and largest value and their nodes. + /// The contour field drawn, with its smallest and largest value and their nodes (a fluid + /// step's: their cells). pub contour: Option<(String, (u32, f64), (u32, f64))>, /// Bodies that could not be tessellated for display. pub failed: Vec, @@ -500,6 +506,21 @@ fn picture(s: &Session, p: &Palette, section: Option
, toward: Vec3) -> return Picture { scene, drawn: base, bounds, shown, glyphs, colors: p.glyphs, datums, bodies }; }; shown.step = Some(view.step.clone()); + let colors = p.glyphs.over_results(p.background.1); + if matches!(result.kind(), Kind::Fluid(_)) { + // The part as it is, with the flow drawn through and around it. + let mut scene = model_scene(&base, p); + let mut bounds = base.bounds; + if let Ok(fp) = crate::flow::plot(view, result, &s.traced) { + if let Some(c) = &fp.contour { + let cell = |(k, v): (usize, f64)| (u32::try_from(k).unwrap_or(u32::MAX), v); + shown.contour = Some((c.field.id().to_string(), cell(c.min), cell(c.max))); + } + crate::flow::draw(&fp, &mut scene, &crate::flow::FlowColors { stops: &p.contour, over: p.contour_over, plain: p.streamline }); + bounds.union(fp.bounds()); + } + return Picture { scene, drawn: base, bounds, shown, glyphs, colors, datums: Aabb3::EMPTY, bodies }; + } let pick = view.pick(); let nodes = result.nodes(); let u = displacements(result, pick).unwrap_or(&[]); @@ -519,21 +540,15 @@ fn picture(s: &Session, p: &Palette, section: Option
, toward: Vec3) -> shown.contour = Some((field.id().to_string(), min, max)); let bands = view.bands(min.1, max.1); let at = |label: u32| nodes.binary_search(&label).ok().and_then(|i| values.get(i).copied()).unwrap_or(f64::NAN); + let color = |band: Option| band.map_or(p.no_result, |k| bands_color(&p.contour, p.contour_over, &bands, k)); scene.tris.clear(); for tri in &drawn.tris { - split_triangle(tri.p, tri.nodes.map(at), &bands, &mut |piece, band| { - let color = band.map_or(p.no_result, |k| bands_color(&p.contour, p.contour_over, &bands, k)); - for k in 1..piece.len().saturating_sub(1) { - if let (Some(&a), Some(&b), Some(&c)) = (piece.first(), piece.get(k), piece.get(k + 1)) { - scene.tris.push(SceneTri { p: [a, b, c], normal: tri.normal, color }); - } - } - }); + banded(tri.p, tri.nodes.map(at), tri.normal, &bands, &color, &mut scene.tris); } } let mut bounds = base.bounds; bounds.union(drawn.bounds); - Picture { scene, drawn, bounds, shown, glyphs, colors: p.glyphs.over_results(p.background.1), datums: Aabb3::EMPTY, bodies } + Picture { scene, drawn, bounds, shown, glyphs, colors, datums: Aabb3::EMPTY, bodies } } /// The scene of the session (the model, or the results view's plot) with the setup's symbols @@ -1070,6 +1085,34 @@ mod tests { } } + /// A fluid step draws the part with its faces coloured by the flow beside them, a coloured + /// slice through the domain and streamlines (#193: the part vanished and nothing was drawn). + #[test] + fn a_fluid_step_draws_its_flow_around_the_part() { + let mut s = crate::flow::tests::plate_in_a_tank(); + let p = Palette::LIGHT; + let (base, ..) = display(&s); + assert!(!base.surface_tris.is_empty()); + let part: Vec = model_scene(&base, &p).tris.iter().map(|t| t.color).collect(); + let (scene, bounds, shown) = super::scene(&s, &p); + let (field, min, max) = shown.contour.clone().unwrap(); + assert!(field == "velocity" && max.1 > min.1, "{shown:?}"); + assert!(scene.tris.len() > base.surface_tris.len(), "the slice is drawn"); + assert!(!scene.lines.is_empty(), "streamlines are drawn"); + let tank = Aabb3 { min: Vec3::new(-20.0, -20.0, -20.0), max: Vec3::new(60.0, 50.0, 30.0) }; + assert_eq!(bounds, tank, "the picture frames the tank"); + // The part is coloured by the flow; in its own colours without the surface colours. + let in_part_colour = |scene: &Scene| scene.tris.iter().filter(|t| part.contains(&t.color)).count(); + assert!(in_part_colour(&scene) < base.surface_tris.len()); + s.execute("results.surface", &json!({"show": false})).unwrap(); + s.execute("results.slice", &json!({"axis": "none"})).unwrap(); + s.execute("results.streamlines", &json!({"show": false})).unwrap(); + let (scene, ..) = super::scene(&s, &p); + assert_eq!((in_part_colour(&scene), scene.tris.len()), (base.surface_tris.len(), base.surface_tris.len()), "the part alone"); + let (png, _) = render_png(&s, &Orientation::of(StandardView::Iso), 160, 120, &p).unwrap(); + assert!(count_color(&png, p.background.0) < 160 * 120, "the part is in the picture"); + } + /// Pixels of colour `c` (within a few levels) in a PNG. fn count_color(png: &[u8], c: Rgb) -> usize { let d = png::Decoder::new(std::io::Cursor::new(png)); diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 5055453..d528276 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -62,6 +62,9 @@ pub struct Tokens { pub line_element: Color32, /// Element edges (the mesh wireframe). pub mesh_edge: Color32, + /// Streamlines when the legend shows another field than velocity: stands out from the + /// viewport background. + pub streamline: Color32, pub node: Color32, // ---- geometry ---- /// Faces of the open design and imported surfaces (SolidCraft's body colour). @@ -149,6 +152,7 @@ impl Tokens { shell: Color32::from_rgb(0xc0, 0x8c, 0x78), line_element: Color32::from_rgb(0xc8, 0x8c, 0x1e), mesh_edge: Color32::from_rgb(0x1e, 0x23, 0x2a), + streamline: Color32::from_rgb(0x28, 0x2e, 0x38), node: Color32::from_rgb(0xb4, 0x3c, 0x8c), geometry: Color32::from_rgb(176, 186, 198), geometry_edge: Color32::from_rgb(40, 44, 52), @@ -216,6 +220,7 @@ impl Tokens { t.shell = Color32::from_rgb(0xc8, 0x96, 0x82); t.line_element = Color32::from_rgb(0xf0, 0xc0, 0x50); t.mesh_edge = Color32::from_rgb(0x14, 0x18, 0x1e); + t.streamline = Color32::from_rgb(0xd8, 0xde, 0xe8); t.node = Color32::from_rgb(0xf2, 0xe6, 0x6e); t.geometry = Color32::from_rgb(152, 156, 162); t.geometry_edge = Color32::from_rgb(16, 16, 18); @@ -272,6 +277,7 @@ impl Tokens { shell: rgb(self.shell), line_element: rgb(self.line_element), mesh_edge: rgb(self.mesh_edge), + streamline: rgb(self.streamline), node: rgb(self.node), geometry: rgb(self.geometry), geometry_edge: rgb(self.geometry_edge), @@ -445,6 +451,20 @@ mod tests { } } + /// Streamlines in one colour (the legend showing pressure) stand out from the viewport + /// background in both themes (#193 review: they were near-black on the dark one). + #[test] + fn streamlines_stand_out_from_the_background() { + let luma = |c: Color32| i32::from(c.r()) * 3 + i32::from(c.g()) * 6 + i32::from(c.b()); + for dark in [true, false] { + let t = Tokens::get(dark); + for bg in [t.viewport_top, t.viewport_bottom] { + assert!((luma(t.streamline) - luma(bg)).abs() > 1200, "dark={dark}"); + } + assert_eq!(t.palette().streamline, rgb(t.streamline)); + } + } + /// The selection and hover colours differ from each other, from every element, geometry, /// node and symbol colour, and from the background. #[test] diff --git a/docs/features.md b/docs/features.md index 3b29f36..bcb3ddb 100644 --- a/docs/features.md +++ b/docs/features.md @@ -6,7 +6,7 @@ Every entry is a toolbar button. **Ready** entries run their command; **coming s shown greyed with the roadmap milestone that brings them (ROADMAP.md). Scripts, the command line, the control channel and AI agents over MCP (docs/mcp.md) run the same commands. -84 ready, 37 coming soon. +85 ready, 36 coming soon. ## Geometry @@ -159,7 +159,7 @@ the control channel and AI agents over MCP (docs/mcp.md) run the same commands. | Plots | Path Plot — Plot a quantity along a path | coming soon (M9) | | | Plots | Cut — Section cuts through the model | coming soon (M9) | | | Plots | Display Group — Show and hide parts of the model | coming soon (M9) | | -| Plots | Streamlines — Streamlines and flow fields | coming soon (M12) | | +| Plots | Streamlines — A fluid step's streamlines coloured by speed, its velocity or pressure on a slice, and the obstacles coloured by the flow | ready | `results.streamlines`, `results.slice`, `results.surface` | | Files | Export VTU — Write a solved step as VTK unstructured grids for ParaView | ready | `results.export.vtu` | | Files | Read Results — Read results in public formats | coming soon (M9) | | | Files | Convergence — Mesh convergence studies | coming soon (M9) | |