Compare commits

...
Author SHA1 Message Date
Christian Svensson 9e19ba9bcd engine: name the temperature unit from the model's unit system
The results legend, probe rows and replies said "°C or K" for a
temperature. The unit system says which scale goes with it, so name it:
°C for the metric systems, °F for in-lbf-s-psi. Values are still shown
as entered; nothing is converted. An unspecified system still names no
unit.

Part of #157.
2026-10-18 12:40:36 +02:00
Christian Svensson 6b87dc508d engine: report a held face as the temperature extreme to scripts
The results reply named the fluid's 79.69 °C cell as the maximum while the
app showed the face held at 80 °C. It now names that face, by body and
face as for wall heat, so a script reads what the legend shows.

Part of #157.
2026-10-18 06:02:18 -04:00
Christian Svensson 7e3eaa14f2 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.
2026-10-18 06:02:18 -04:00
Christian Svensson c61a8b9a4d mesh: name the threshold test for what it asserts
The 135-degree vertex is no longer a corner, so the test is named that way.

Part of #556.
2026-10-18 06:02:13 -04:00
Christian Svensson c3c764c996 mesh: an angle exactly on the 135-degree threshold is not a corner, whichever way it rounds
A region's corners are its interior angles below 135 degrees, and an
O-grid's 45-degree cuts meet straight sides at exactly that angle. The
computed angle then rounds either way with the last bits of its points,
which differ between platforms' sin and cos and between tessellations:
one ulp in three points of the slot test's arcs, Windows' sin against
glibc's, turned a mapped region into a triangle and the whole blocking
into a different one with a corner scaled Jacobian of 0.375 for 0.707.
Angles within 1e-9 rad of the corner and reflex thresholds now count as
on them, and on a threshold is smooth (neither a corner nor reflex),
which keeps the slot's intended O-grid of 9 regions and 848 quads on
Linux, macOS and Windows. The region classification and the O-grid's
own corner tests share the rule, the slot test pins the blocking, and
its arc helper uses the mesher's portable trig.

Part of #556.
2026-10-18 06:02:13 -04:00
6 changed files with 225 additions and 14 deletions

No files matched your search

+134 -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,52 @@ 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 faces = held_faces(session, r);
let held: std::collections::BTreeMap<_, _> = faces.iter().map(|h| ((h.body.as_str(), h.index), h.value)).collect();
base.surface_tris.iter().map(|t| bodies.get(t.body as usize).and_then(|name| held.get(&(*name, t.face)).copied())).collect()
}
/// An obstacle face the step's fluid domain holds at a temperature.
pub struct HeldFace {
pub body: String,
pub face: String,
pub index: u32,
pub value: f64,
}
/// The faces the step's fluid domain holds at a temperature, when the result is of the current
/// model (a stale result was solved with other conditions).
pub fn held_faces(session: &crate::Session, r: &FluidResult) -> Vec<HeldFace> {
if session.latest_result(&r.step).is_some_and(|(_, current)| !current) {
return Vec::new();
}
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 Vec::new();
};
let design = session.design.as_deref();
let mut out = Vec::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 {
out.push(HeldFace { body: sb.face.body.clone(), face: face.clone(), index, value: *value });
}
}
out
}
/// The colours a flow plot is drawn in.
#[derive(Clone, Copy, Debug)]
pub struct FlowColors<'a> {
@@ -401,7 +475,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 +624,57 @@ 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:?}");
// A script reads the same maximum as the app shows: the held face.
let reply = s.results_report();
assert_eq!(reply["contour"]["range"]["max"], json!({"body": "Body1", "face": face, "value": 80.0}), "{reply}");
// 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();
+20 -3
View File
@@ -332,8 +332,9 @@ pub enum Quantity {
impl Quantity {
/// Its unit in `system` (`mm`, `MPa`, `N·mm`, `mW`…), or `None` (no unit, or the system is
/// unspecified). The model never converts, so this only names the system's own unit; a
/// temperature is in whatever scale its values were entered in.
/// unspecified). The model never converts, so this only names the system's own unit. A
/// temperature is named in the scale that goes with the system: °C for the metric systems,
/// °F for inch–pound-force–second.
pub fn unit(self, system: UnitSystem) -> Option<String> {
let dim = match self {
Quantity::None => return None,
@@ -359,7 +360,8 @@ impl Quantity {
Quantity::Energy => energy,
Quantity::HeatFlow => power,
Quantity::HeatFlux => flux,
_ => "°C or K",
_ if system == UnitSystem::InLbfS => "°F",
_ => "°C",
}
.into(),
);
@@ -722,6 +724,18 @@ impl Session {
out[k.as_str()] = x;
}
}
// A face held beyond the fluid's range is the extreme, as the legend shows it.
if v.contour == Some(Field::Temperature) && out["contour"]["range"].is_object() {
for h in crate::flow::held_faces(self, f) {
let end = json!({"body": h.body, "face": h.face, "value": h.value});
let range = &mut out["contour"]["range"];
if range["max"]["value"].as_f64().is_some_and(|m| h.value > m) {
range["max"] = end;
} else if range["min"]["value"].as_f64().is_some_and(|m| h.value < m) {
range["min"] = end;
}
}
}
if v.contour == Some(Field::WallHeatFlux)
&& let Ok(faces) = crate::flow::wall::faces(self, f, v.pick())
{
@@ -862,6 +876,9 @@ mod tests {
assert_eq!(unit(Quantity::Moment, mm).as_deref(), Some("N·mm"));
assert_eq!(unit(Quantity::Energy, UnitSystem::MKgS).as_deref(), Some("J"));
assert_eq!(unit(Quantity::Temperature, UnitSystem::Unspecified), None);
assert_eq!(unit(Quantity::Temperature, mm).as_deref(), Some("°C"));
assert_eq!(unit(Quantity::Temperature, UnitSystem::MKgS).as_deref(), Some("°C"));
assert_eq!(unit(Quantity::Temperature, UnitSystem::InLbfS).as_deref(), Some("°F"));
assert_eq!(unit(Quantity::None, mm), None);
}
+8 -3
View File
@@ -916,8 +916,8 @@ fn assignable(loops: &[Poly], cuts: &[Vec<Q>], s: &Settings) -> bool {
fn o_grid(loops: &[Poly], s: &Settings, budget: &mut usize) -> Vec<Vec<Q>> {
let (Some(outer), Some(hole)) = (loops.first(), loops.get(1)) else { return Vec::new() };
let c = centroid(&hole.pts);
let corner = |p: &Poly, i: usize| p.is_end(i) && p.angle(i) <= 0.75 * std::f64::consts::PI;
let concave = |p: &Poly, i: usize| p.is_end(i) && p.angle(i) >= 1.25 * std::f64::consts::PI;
let corner = |p: &Poly, i: usize| p.is_end(i) && region::is_corner(p.angle(i));
let concave = |p: &Poly, i: usize| p.is_end(i) && region::is_reflex(p.angle(i));
// A cut from loop point (l, i) along d that must end on loop `target`.
let ray = |l: usize, i: usize, d: Q, target: usize| -> Option<Vec<Q>> {
let o = loops.get(l)?.at(i);
@@ -1134,8 +1134,9 @@ mod tests {
let n = ((a1 - a0).abs() / 5.0).ceil().max(1.0) as usize;
(0..=n)
.map(|k| {
// The mesher's portable trig, so every platform gets the same points (#556).
let t = (a0 + (a1 - a0) * k as f64 / n as f64).to_radians();
[c[0] + r * t.cos(), c[1] + r * t.sin()]
[c[0] + r * libm::cos(t), c[1] + r * libm::sin(t)]
})
.collect()
}
@@ -1308,6 +1309,10 @@ mod tests {
]);
let m = mesh_profile(&profile, &[], 2.5);
check(&m, &profile, 0.5);
// The intended O-grid on every platform (#556): Windows' last-bit-different arcs used to
// give 10 regions and 2063 quads with a 0.375 corner.
assert_eq!(m.section.schemes.len(), 9, "{:?}", m.section.schemes);
assert_eq!(m.section.quads.iter().map(Vec::len).sum::<usize>(), 848);
}
#[test]
+45 -3
View File
@@ -47,6 +47,24 @@ const MAX_ANNULUS_NODES: usize = 4096;
/// Smoothing sweeps over the interior of a triangle primitive.
const TRIANGLE_SWEEPS: usize = 20;
/// How close (radians) an interior angle must come to a classification threshold to count as on
/// it. Exact geometry lands on the thresholds (an O-grid's 45° cuts meet straight sides at
/// exactly 135°), and the computed angle is then rounded either way by the last bits of its
/// points: those differ between platforms' `sin`/`cos` and between tessellations, and the
/// scheme must not (#556). An angle within this of a threshold is taken as exactly on it, and
/// on a threshold is smooth, neither a corner nor reflex: that keeps the slot's intended O-grid.
const ANGLE_TOLERANCE: f64 = 1e-9;
/// A corner: an interior angle clearly below 135° (exactly 135° is not one).
pub(crate) fn is_corner(angle: f64) -> bool {
angle < 0.75 * PI - ANGLE_TOLERANCE
}
/// A reflex turn no mapped or triangle region can take: clearly above 225°.
pub(crate) fn is_reflex(angle: f64) -> bool {
angle > 1.25 * PI + ANGLE_TOLERANCE
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Scheme {
/// Corner positions (curve uses) in the single loop.
@@ -89,10 +107,10 @@ pub(crate) fn classify(loops: &[Vec<Vec<Q>>]) -> Scheme {
match loops {
[lp] => {
let Some(a) = angles(lp, ccw(lp)) else { return Scheme::Free };
if a.iter().any(|&x| x >= 1.25 * PI) {
if a.iter().any(|&x| is_reflex(x)) {
return Scheme::Free;
}
let corners: Vec<usize> = (0..a.len()).filter(|&k| a.get(k).is_some_and(|&x| x <= 0.75 * PI)).collect();
let corners: Vec<usize> = (0..a.len()).filter(|&k| a.get(k).is_some_and(|&x| is_corner(x))).collect();
if corners.is_empty() {
return Scheme::Disk;
}
@@ -103,7 +121,7 @@ pub(crate) fn classify(loops: &[Vec<Vec<Q>>]) -> Scheme {
}
}
[outer, inner] => {
let smooth = |lp: &Vec<Vec<Q>>| angles(lp, ccw(lp)).is_some_and(|a| a.iter().all(|&x| x > 0.75 * PI && x < 1.25 * PI));
let smooth = |lp: &Vec<Vec<Q>>| angles(lp, ccw(lp)).is_some_and(|a| a.iter().all(|&x| !is_corner(x) && !is_reflex(x)));
if smooth(outer) && smooth(inner) { Scheme::Annulus } else { Scheme::Free }
}
_ => Scheme::Free,
@@ -568,6 +586,30 @@ mod tests {
use super::*;
use crate::size::UniformSize;
/// An angle of exactly 135°, the threshold, is not a corner however the last bits of its
/// points round (Windows' `sin` against glibc's, or two tessellations): a trapezoid with one
/// stays a triangle rather than flipping to mapped when that vertex moves by an ulp (#556).
#[test]
fn an_angle_on_the_threshold_is_not_a_corner_whichever_way_it_rounds() {
// (0, 0) 90°, (3, 0) 45°, (2, 1) 135°, (0, 1) 90°: three corners and a 135° bend.
let trapezoid = |p: Q| {
let pts = [[0.0, 0.0], [3.0, 0.0], p, [0.0, 1.0]];
let curves: Vec<Vec<Q>> = (0..4).map(|k| vec![pts[k], pts[(k + 1) % 4]]).collect();
classify(&[curves])
};
let steps: [fn(f64) -> f64; 3] = [|v| v, f64::next_up, f64::next_down];
for nx in steps {
for ny in steps {
let mut p = [nx(2.0), ny(1.0)];
// A few ulps further each way as well.
for _ in 0..3 {
assert_eq!(trapezoid(p), Scheme::Triangle([0, 1, 3]), "{p:?}");
p = [nx(p[0]), ny(p[1])];
}
}
}
}
fn circle(r: f64, n: usize, ccw: bool) -> Vec<Q> {
(0..n)
.map(|k| {
+16 -3
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),
}
}
@@ -487,12 +494,18 @@ pub(crate) mod tests {
s.execute("results.slice", &json!({"field": "temperature"})).unwrap();
let p = plot_at(&s, &base, None).unwrap();
assert_eq!(p.contour.as_ref().unwrap().field, Field::Temperature);
assert!(Field::Temperature.title(&p.result, p.units).contains("°C or K"));
assert!(Field::Temperature.title(&p.result, p.units).ends_with("[°C]"));
assert!(Field::available(&p.result).any(|f| f == Field::Temperature));
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();
+2 -1
View File
@@ -146,7 +146,8 @@ A solved fluid step with temperature in every saved frame offers `temperature` i
Results field list and through `results.contour`, `results.slice`, `results.surface` and
`results.streamlines` (`{"field":"temperature"}`). Slices and obstacle surfaces sample
fluid-cell temperature; streamline colours sample temperature along the velocity traces.
The legend and existing `solve.probe` use the model's temperature scale, without conversion.
The legend and `solve.probe` name the temperature unit that goes with the model's unit system
(°C for the metric systems, °F for in-lbf-s-psi); values are shown as entered, never converted.
An isothermal step hides the field and rejects an explicit request with an explanation.
The frame slider and `results.frame` select the field's time, including the initial frame.