diff --git a/crates/engine/src/flow.rs b/crates/engine/src/flow.rs index 3c177e6..31b1f6a 100644 --- a/crates/engine/src/flow.rs +++ b/crates/engine/src/flow.rs @@ -355,6 +355,34 @@ pub fn plot_with_surfaces( bodies: &[&str], ) -> Result { let mut out = plot(view, result, traced)?; + if view.contour == Some(Field::Temperature) + && let Kind::Fluid(r) = result.kind() + { + // An obstacle face held at a temperature is drawn at it: sampling the fluid beside it + // would mix in the cooler cells further out. + let held = held_temperatures(session, r, base, bodies); + if held.iter().any(Option::is_some) { + if let Some(c) = &mut out.contour { + // A held face beyond the fluid's range is the extreme: the legend, its Max/Min + // lines and the viewport's marker all name it. Its label continues past the + // cells, into `wall_positions` (see [`FlowPlot::wall_positions`]). + let cells = c.values.len(); + for (t, v) in base.surface_tris.iter().zip(&held) { + let Some(v) = *v else { continue }; + let at = || (t.p[0] + t.p[1] + t.p[2]) * (1.0 / 3.0); + if v > c.max.1 { + c.max = (cells + out.wall_positions.len(), v); + out.wall_positions.push(Some(at())); + } else if v < c.min.1 { + c.min = (cells + out.wall_positions.len(), v); + out.wall_positions.push(Some(at())); + } + } + c.bands = view.bands(c.min.1, c.max.1); + } + out.wall_values = base.surface_tris.iter().zip(held).map(|(t, v)| (t.p, v)).collect(); + } + } if view.contour == Some(Field::WallHeatFlux) { let Kind::Fluid(r) = result.kind() else { return Ok(out) }; let faces = wall::faces(session, r, view.pick())?; @@ -372,6 +400,37 @@ pub fn plot_with_surfaces( Ok(out) } +/// Per displayed surface triangle, the temperature its obstacle face is held at, when the step's +/// domain holds that face at one and the result is of the current model. +fn held_temperatures(session: &crate::Session, r: &FluidResult, base: &fieldcraft_render::DisplayMesh, bodies: &[&str]) -> Vec> { + let none = || vec![None; base.surface_tris.len()]; + if session.latest_result(&r.step).is_some_and(|(_, current)| !current) { + return none(); + } + let Some(thermal) = session + .model + .step(&r.step) + .and_then(|st| st.procedure.fluid_domain()) + .and_then(|d| session.model.fluid_domain(d)) + .and_then(|d| d.thermal.as_ref()) + else { + return none(); + }; + let design = session.design.as_deref(); + let mut held = std::collections::BTreeMap::new(); + for sb in &thermal.surfaces { + let (fieldcraft_model::ThermalCondition::Temperature { value }, fieldcraft_model::Entity::Face(face)) = (&sb.condition, &sb.face.entity) + else { + continue; + }; + let index = design.and_then(|d| d.bodies.iter().find(|b| b.name == sb.face.body)).and_then(|b| b.face_index(face)); + if let Some(index) = index { + held.insert((sb.face.body.as_str(), index), *value); + } + } + base.surface_tris.iter().map(|t| bodies.get(t.body as usize).and_then(|name| held.get(&(*name, t.face)).copied())).collect() +} + /// The colours a flow plot is drawn in. #[derive(Clone, Copy, Debug)] pub struct FlowColors<'a> { @@ -401,7 +460,15 @@ pub fn draw(plot: &FlowPlot, scene: &mut Scene, colors: &FlowColors) { } } else if plot.surface { let model = std::mem::take(&mut scene.tris); - for t in model { + // Faces held at a temperature (`wall_values`, matched by position as for wall heat). + let held = (c.field == Field::Temperature && model.len() == plot.wall_values.len()).then_some(&plot.wall_values); + for (k, t) in model.into_iter().enumerate() { + if let Some((points, Some(v))) = held.and_then(|h| h.get(k)) + && t.p == *points + { + banded(t.p, [*v; 3], t.normal, &c.bands, &color, &mut scene.tris); + continue; + } 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 { @@ -542,6 +609,54 @@ pub(crate) mod tests { s } + /// A face held at a temperature is drawn at that temperature, and the legend reaches it, + /// rather than at the mean of the fluid cells around it (which mixes in the cooler ones + /// further out: an 80 °C bracket looked 50 °C). + #[test] + fn a_face_held_at_a_temperature_is_drawn_at_it() { + let mut s = plate_in_a_tank(); + let face = s.design.as_ref().unwrap().bodies[0].faces()[0].name.clone(); + s.execute( + "model.material.edit", + &json!({"name": "Water", "density": 1e-9, "fluid": {"type": "newtonian", "viscosity": 1e-7}, "thermal": {"conductivity": 400, "specific_heat": 4e9}}), + ) + .unwrap(); + s.execute( + "model.fluid_domain.edit", + &json!({"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}}, + "thermal": {"initial_temperature": 20}}), + ) + .unwrap(); + s.execute( + "model.fluid_domain.thermal_boundary", + &json!({"domain": "Tank", "at": {"body": "Body1", "face": face}, "condition": {"type": "temperature", "value": 80}}), + ) + .unwrap(); + s.execute("solve.fluid", &json!({})).unwrap(); + s.execute("results.surface", &json!({"field": "temperature", "show": true})).unwrap(); + let (result, current) = s.latest_result("Flow").unwrap(); + assert!(current); + let view = s.results_view.clone().unwrap(); + let base = crate::view::display(&s).0; + let names: Vec<&str> = s.design.as_ref().unwrap().bodies.iter().map(|b| b.name.as_str()).collect(); + let fp = plot_with_surfaces(&view, result, &s.traced, &s, &base, &names).unwrap(); + let c = fp.contour.as_ref().unwrap(); + // The fluid never quite reaches the wall's 80 °C; the legend does, and its maximum is the + // held face (a label past the cells, at a point on that face). + assert!(c.values.iter().all(|t| t.is_nan() || *t < 80.0)); + assert_eq!((c.max.1, c.bands.max), (80.0, 80.0), "{:?}", c.bands); + let at = c.max.0.checked_sub(c.values.len()).and_then(|k| fp.wall_positions.get(k).copied().flatten()); + assert!(at.is_some(), "{:?}", c.max); + let held: Vec = fp.wall_values.iter().filter_map(|(_, v)| *v).collect(); + assert!(!held.is_empty() && held.iter().all(|t| *t == 80.0), "{held:?}"); + // The headless picture draws the held face in the top band's colour. + let p = crate::view::Palette::LIGHT; + let top = bands_color(&p.contour, p.contour_over, &c.bands, c.bands.band(80.0).unwrap()); + let (scene, ..) = crate::view::scene(&s, &p); + assert!(scene.tris.iter().any(|t| t.color == top)); + } + #[test] fn a_fluid_result_is_first_shown_across_the_flow() { let r = channel(); diff --git a/crates/ui-egui/src/results.rs b/crates/ui-egui/src/results.rs index 59967b5..232e582 100644 --- a/crates/ui-egui/src/results.rs +++ b/crates/ui-egui/src/results.rs @@ -118,7 +118,14 @@ impl Plot { return self.flow.as_ref()?.wall_positions.get(label as usize).copied().flatten(); } match self.result.kind() { - Kind::Fluid(f) => Some(Vec3::from(fieldcraft_engine::flow::cell_centre(f, usize::try_from(label).ok()?))), + Kind::Fluid(f) => { + let label = usize::try_from(label).ok()?; + // Past the cells: a face held at a temperature that is the field's extreme. + match label.checked_sub(f.grid.len()) { + Some(k) => self.flow.as_ref()?.wall_positions.get(k).copied().flatten(), + None => Some(Vec3::from(fieldcraft_engine::flow::cell_centre(f, label))), + } + } _ => self.node_position(label), } } @@ -492,7 +499,13 @@ pub(crate) mod tests { assert!(p.contour.as_ref().unwrap().max.1 > 20.0); let last = p.frame().unwrap().2 - 1; s.execute("results.frame", &json!({"frame": 0})).unwrap(); - assert_eq!(plot_at(&s, &base, None).unwrap().contour.as_ref().unwrap().max.1, 20.0, "the frame selector changes the temperature shown"); + let at_rest = plot_at(&s, &base, None).unwrap(); + let c = at_rest.contour.as_ref().unwrap(); + let cells = &at_rest.flow.as_ref().unwrap().contour.as_ref().unwrap().values; + assert!(cells.iter().filter(|t| t.is_finite()).all(|t| *t == 20.0), "the frame selector changes the temperature shown"); + // The faces are held at 60 °C from the start: the legend's maximum is a face, at a point on it. + assert_eq!(c.max.1, 60.0); + assert!(at_rest.extreme_position(c.max.0).is_some()); s.execute("results.frame", &json!({"frame": last})).unwrap(); s.execute("results.surface", &json!({"field": "wall_heat_flux"})).unwrap(); let p = plot_at(&s, &base, None).unwrap();