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],
|
||||
) -> 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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in new issue
Block a user