forked from 1b-agent/fieldcraft-upstream
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e19ba9bcd | ||
|
|
6b87dc508d | ||
|
|
7e3eaa14f2 | ||
|
|
c61a8b9a4d | ||
|
|
c3c764c996 |
No files matched your search
+134
-1
@@ -355,6 +355,34 @@ pub fn plot_with_surfaces(
|
|||||||
bodies: &[&str],
|
bodies: &[&str],
|
||||||
) -> Result<FlowPlot> {
|
) -> Result<FlowPlot> {
|
||||||
let mut out = plot(view, result, traced)?;
|
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) {
|
if view.contour == Some(Field::WallHeatFlux) {
|
||||||
let Kind::Fluid(r) = result.kind() else { return Ok(out) };
|
let Kind::Fluid(r) = result.kind() else { return Ok(out) };
|
||||||
let faces = wall::faces(session, r, view.pick())?;
|
let faces = wall::faces(session, r, view.pick())?;
|
||||||
@@ -372,6 +400,52 @@ pub fn plot_with_surfaces(
|
|||||||
Ok(out)
|
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.
|
/// The colours a flow plot is drawn in.
|
||||||
#[derive(Clone, Copy, Debug)]
|
#[derive(Clone, Copy, Debug)]
|
||||||
pub struct FlowColors<'a> {
|
pub struct FlowColors<'a> {
|
||||||
@@ -401,7 +475,15 @@ pub fn draw(plot: &FlowPlot, scene: &mut Scene, colors: &FlowColors) {
|
|||||||
}
|
}
|
||||||
} else if plot.surface {
|
} else if plot.surface {
|
||||||
let model = std::mem::take(&mut scene.tris);
|
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 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));
|
let (sum, known) = v.iter().filter(|x| !x.is_nan()).fold((0.0, 0u32), |(s, n), x| (s + x, n + 1));
|
||||||
if known == 0 {
|
if known == 0 {
|
||||||
@@ -542,6 +624,57 @@ pub(crate) mod tests {
|
|||||||
s
|
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]
|
#[test]
|
||||||
fn a_fluid_result_is_first_shown_across_the_flow() {
|
fn a_fluid_result_is_first_shown_across_the_flow() {
|
||||||
let r = channel();
|
let r = channel();
|
||||||
|
|||||||
@@ -332,8 +332,9 @@ pub enum Quantity {
|
|||||||
|
|
||||||
impl Quantity {
|
impl Quantity {
|
||||||
/// Its unit in `system` (`mm`, `MPa`, `N·mm`, `mW`…), or `None` (no unit, or the system is
|
/// 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
|
/// 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.
|
/// 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> {
|
pub fn unit(self, system: UnitSystem) -> Option<String> {
|
||||||
let dim = match self {
|
let dim = match self {
|
||||||
Quantity::None => return None,
|
Quantity::None => return None,
|
||||||
@@ -359,7 +360,8 @@ impl Quantity {
|
|||||||
Quantity::Energy => energy,
|
Quantity::Energy => energy,
|
||||||
Quantity::HeatFlow => power,
|
Quantity::HeatFlow => power,
|
||||||
Quantity::HeatFlux => flux,
|
Quantity::HeatFlux => flux,
|
||||||
_ => "°C or K",
|
_ if system == UnitSystem::InLbfS => "°F",
|
||||||
|
_ => "°C",
|
||||||
}
|
}
|
||||||
.into(),
|
.into(),
|
||||||
);
|
);
|
||||||
@@ -722,6 +724,18 @@ impl Session {
|
|||||||
out[k.as_str()] = x;
|
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)
|
if v.contour == Some(Field::WallHeatFlux)
|
||||||
&& let Ok(faces) = crate::flow::wall::faces(self, f, v.pick())
|
&& 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::Moment, mm).as_deref(), Some("N·mm"));
|
||||||
assert_eq!(unit(Quantity::Energy, UnitSystem::MKgS).as_deref(), Some("J"));
|
assert_eq!(unit(Quantity::Energy, UnitSystem::MKgS).as_deref(), Some("J"));
|
||||||
assert_eq!(unit(Quantity::Temperature, UnitSystem::Unspecified), None);
|
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);
|
assert_eq!(unit(Quantity::None, mm), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>> {
|
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 (Some(outer), Some(hole)) = (loops.first(), loops.get(1)) else { return Vec::new() };
|
||||||
let c = centroid(&hole.pts);
|
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 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) && p.angle(i) >= 1.25 * std::f64::consts::PI;
|
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`.
|
// 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 ray = |l: usize, i: usize, d: Q, target: usize| -> Option<Vec<Q>> {
|
||||||
let o = loops.get(l)?.at(i);
|
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;
|
let n = ((a1 - a0).abs() / 5.0).ceil().max(1.0) as usize;
|
||||||
(0..=n)
|
(0..=n)
|
||||||
.map(|k| {
|
.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();
|
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()
|
.collect()
|
||||||
}
|
}
|
||||||
@@ -1308,6 +1309,10 @@ mod tests {
|
|||||||
]);
|
]);
|
||||||
let m = mesh_profile(&profile, &[], 2.5);
|
let m = mesh_profile(&profile, &[], 2.5);
|
||||||
check(&m, &profile, 0.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]
|
#[test]
|
||||||
|
|||||||
@@ -47,6 +47,24 @@ const MAX_ANNULUS_NODES: usize = 4096;
|
|||||||
/// Smoothing sweeps over the interior of a triangle primitive.
|
/// Smoothing sweeps over the interior of a triangle primitive.
|
||||||
const TRIANGLE_SWEEPS: usize = 20;
|
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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub(crate) enum Scheme {
|
pub(crate) enum Scheme {
|
||||||
/// Corner positions (curve uses) in the single loop.
|
/// Corner positions (curve uses) in the single loop.
|
||||||
@@ -89,10 +107,10 @@ pub(crate) fn classify(loops: &[Vec<Vec<Q>>]) -> Scheme {
|
|||||||
match loops {
|
match loops {
|
||||||
[lp] => {
|
[lp] => {
|
||||||
let Some(a) = angles(lp, ccw(lp)) else { return Scheme::Free };
|
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;
|
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() {
|
if corners.is_empty() {
|
||||||
return Scheme::Disk;
|
return Scheme::Disk;
|
||||||
}
|
}
|
||||||
@@ -103,7 +121,7 @@ pub(crate) fn classify(loops: &[Vec<Vec<Q>>]) -> Scheme {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
[outer, inner] => {
|
[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 }
|
if smooth(outer) && smooth(inner) { Scheme::Annulus } else { Scheme::Free }
|
||||||
}
|
}
|
||||||
_ => Scheme::Free,
|
_ => Scheme::Free,
|
||||||
@@ -568,6 +586,30 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::size::UniformSize;
|
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> {
|
fn circle(r: f64, n: usize, ccw: bool) -> Vec<Q> {
|
||||||
(0..n)
|
(0..n)
|
||||||
.map(|k| {
|
.map(|k| {
|
||||||
|
|||||||
@@ -118,7 +118,14 @@ impl Plot {
|
|||||||
return self.flow.as_ref()?.wall_positions.get(label as usize).copied().flatten();
|
return self.flow.as_ref()?.wall_positions.get(label as usize).copied().flatten();
|
||||||
}
|
}
|
||||||
match self.result.kind() {
|
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),
|
_ => self.node_position(label),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -487,12 +494,18 @@ pub(crate) mod tests {
|
|||||||
s.execute("results.slice", &json!({"field": "temperature"})).unwrap();
|
s.execute("results.slice", &json!({"field": "temperature"})).unwrap();
|
||||||
let p = plot_at(&s, &base, None).unwrap();
|
let p = plot_at(&s, &base, None).unwrap();
|
||||||
assert_eq!(p.contour.as_ref().unwrap().field, Field::Temperature);
|
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!(Field::available(&p.result).any(|f| f == Field::Temperature));
|
||||||
assert!(p.contour.as_ref().unwrap().max.1 > 20.0);
|
assert!(p.contour.as_ref().unwrap().max.1 > 20.0);
|
||||||
let last = p.frame().unwrap().2 - 1;
|
let last = p.frame().unwrap().2 - 1;
|
||||||
s.execute("results.frame", &json!({"frame": 0})).unwrap();
|
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.frame", &json!({"frame": last})).unwrap();
|
||||||
s.execute("results.surface", &json!({"field": "wall_heat_flux"})).unwrap();
|
s.execute("results.surface", &json!({"field": "wall_heat_flux"})).unwrap();
|
||||||
let p = plot_at(&s, &base, None).unwrap();
|
let p = plot_at(&s, &base, None).unwrap();
|
||||||
|
|||||||
@@ -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 field list and through `results.contour`, `results.slice`, `results.surface` and
|
||||||
`results.streamlines` (`{"field":"temperature"}`). Slices and obstacle surfaces sample
|
`results.streamlines` (`{"field":"temperature"}`). Slices and obstacle surfaces sample
|
||||||
fluid-cell temperature; streamline colours sample temperature along the velocity traces.
|
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.
|
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.
|
The frame slider and `results.frame` select the field's time, including the initial frame.
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user