engine: draw an obstacle face held at a temperature at that temperature

The coloured obstacle surface samples the fluid beside each face, and
the solid cells carry no temperature, so a face held at 80 °C took the
mean of the fluid cells around it, which mixes in the cooler ones further
out: the heated-flow sample's bracket looked 50 °C beside 79.7 °C air. A
face whose thermal condition holds it at a temperature is now drawn at
that value, and the legend's range reaches it (the fluid never quite
does). Adiabatic and flux faces keep showing the fluid beside them.

Part of #157.
This commit is contained in:
Christian Svensson authored and Irakli Kourtis committed 2026-10-18 06:02:18 -04:00
1 parent c61a8b9a4d
commit 7e3eaa14f2
2 files changed
+131 -3

No files matched your search

+116 -1
View File
@@ -355,6 +355,34 @@ pub fn plot_with_surfaces(
bodies: &[&str],
) -> Result<FlowPlot> {
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<Option<f64>> {
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<f64> = 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();
+15 -2
View File
@@ -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();