From f7dcd46b2fb16d389f2a726fed2dd3c65e76fa4d Mon Sep 17 00:00:00 2001 From: Christian Svensson Date: Sat, 17 Oct 2026 09:33:30 +0200 Subject: [PATCH] engine: draw fluid results as slices, coloured obstacles and streamlines After solve.fluid the results view had nothing to draw: a fluid step's fields are per cell, the view only knew nodal fields, and the deformed display it built dropped the geometry surfaces, so even the part vanished. A fluid step now has its own display (`engine::flow`, on render's flow display data), shared by the headless pictures and the window: the part drawn as it is, its faces coloured by the field in the fluid beside them, the field on a slice through the domain and streamlines seeded on the inlet face upstream of the obstacles, coloured by speed when the legend shows velocity. It is first shown with velocity magnitude on a slice through the middle, normal to the horizontal axis across the flow. New commands: results.slice {step, field: velocity|pressure, axis: x|y|z|none, position, frame}, results.surface {step, show} and results.streamlines {step, show, count, max_steps, seeds | line, frame}, which replies with what it traced. results.contour takes velocity and pressure for fluid steps (a new `pressure` field) with the usual bands and legend clamp, and results.frame walks a fluid step's frames. Streamlines leave the coming-soon list. Hostile parameters are refused, and the counts are capped. Part of #193. --- crates/engine/src/catalogue.rs | 9 +- crates/engine/src/cmd/forms.rs | 31 ++ crates/engine/src/cmd/mesh.rs | 2 +- crates/engine/src/cmd/mod.rs | 3 + crates/engine/src/cmd/results.rs | 347 +++++++++++++++++- crates/engine/src/flow.rs | 586 +++++++++++++++++++++++++++++++ crates/engine/src/lib.rs | 4 + crates/engine/src/results.rs | 43 ++- crates/engine/src/view.rs | 69 +++- crates/ui-egui/src/theme.rs | 20 ++ docs/features.md | 4 +- 11 files changed, 1086 insertions(+), 32 deletions(-) create mode 100644 crates/engine/src/flow.rs 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) | |