From e9ccf10692a08ea4c6037442c6213e1f68d344b8 Mon Sep 17 00:00:00 2001 From: Christian Svensson Date: Sat, 17 Oct 2026 17:41:32 +0200 Subject: [PATCH] engine: add a small heated flow sample Demonstrate temperature slices and signed face-average wall heat with cool air passing an isothermal bracket. Register the original CPU-rendered gallery thumbnail and document the coarse transient setup. Part of #157. --- ATTRIBUTION.md | 1 + crates/engine/src/cmd/forms.rs | 1 + crates/engine/src/sample.rs | 10 ++++ crates/engine/src/sample/heated_flow.rs | 58 ++++++++++++++++++++++ crates/ui-egui/src/samples.rs | 1 + crates/ui-egui/thumbnails/heated-flow.png | Bin 0 -> 5884 bytes docs/examples.md | 21 +++++++- 7 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 crates/engine/src/sample/heated_flow.rs create mode 100644 crates/ui-egui/thumbnails/heated-flow.png diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 03098d4..72d99d3 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -30,6 +30,7 @@ is committed and listed here with its exports. | docs/media/examples/impact.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the solved impact sample (docs/examples.md) | MIT OR Apache-2.0 | | docs/media/examples/ibeam.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the solved ibeam sample (docs/examples.md) | MIT OR Apache-2.0 | | crates/ui-egui/thumbnails/ibeam.png | FieldCraft contributors | Original: the solved ibeam sample rendered by FieldCraft (`ui.render`, light theme), cropped to the model and scaled to 320 px wide (ImageMagick, 128-colour palette), the ibeam sample's thumbnail on the Start card | MIT OR Apache-2.0 | +| crates/ui-egui/thumbnails/heated-flow.png | FieldCraft contributors | Original: the heated-flow sample's temperature slice rendered directly at 320 × 213 by `fieldcraft-cli snapshot` with the CPU renderer; geometry is the original upright-bracket recipe | MIT OR Apache-2.0 | | docs/media/examples/parts.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the parts sample (meshes only, nothing to solve) (docs/examples.md) | MIT OR Apache-2.0 | | crates/ui-egui/thumbnails/flow-past.png | FieldCraft contributors | Original: the flow-past sample as docs/media/examples/flow-past.png shows it, rendered by FieldCraft (`ui.render`, light theme), cropped to the model and scaled to 320 px wide (ImageMagick, 128-colour palette), the flow-past sample's thumbnail on the Start card | MIT OR Apache-2.0 | | crates/ui-egui/thumbnails/cantilever.png | FieldCraft contributors | Original: the cantilever sample as docs/media/examples/cantilever.png shows it, rendered by FieldCraft (`ui.render`, light theme), cropped to the model and scaled to 320 px wide (ImageMagick, 128-colour palette), the cantilever sample's thumbnail on the Start card | MIT OR Apache-2.0 | diff --git a/crates/engine/src/cmd/forms.rs b/crates/engine/src/cmd/forms.rs index 49f3449..60a3ed4 100644 --- a/crates/engine/src/cmd/forms.rs +++ b/crates/engine/src/cmd/forms.rs @@ -1971,6 +1971,7 @@ pub const SAMPLE: &[Field] = &[choice( ("drop-test", "Drop test"), ("rubber-seal", "Rubber seal"), ("flow-past", "Flow past a bracket"), + ("heated-flow", "Heated flow past a bracket"), ("heated-bracket", "Heated bracket"), ("bracket-hex", "Hex-meshed bracket"), ("crank-arm", "Bicycle crank arm"), diff --git a/crates/engine/src/sample.rs b/crates/engine/src/sample.rs index 7ef03ca..ba266fd 100644 --- a/crates/engine/src/sample.rs +++ b/crates/engine/src/sample.rs @@ -18,6 +18,8 @@ use fieldcraft_geometry::Shape; use serde_json::{Value, json}; +mod heated_flow; + /// A sample the app offers (Help › Samples, the empty viewport, `file.sample`, `--sample NAME`). pub struct Sample { pub name: &'static str, @@ -92,6 +94,14 @@ pub const SAMPLES: &[Sample] = &[ prepare: None, script: |_| flow_past(), }, + Sample { + name: "heated-flow", + label: "Heated Flow Past a Bracket", + about: "Cool air passing an 80 °C bracket: temperature slices and signed wall heat, a small transient demonstration", + design: Some(UPRIGHT_BRACKET), + prepare: None, + script: heated_flow::script, + }, Sample { name: "heated-bracket", label: "Heated Bracket", diff --git a/crates/engine/src/sample/heated_flow.rs b/crates/engine/src/sample/heated_flow.rs new file mode 100644 index 0000000..5a6f4e0 --- /dev/null +++ b/crates/engine/src/sample/heated_flow.rs @@ -0,0 +1,58 @@ +//! A small transient heated-flow demonstration using the original upright bracket. +//! Air at 20 °C passes an 80 °C bracket; no buoyancy or conjugate solid conduction. + +use fieldcraft_geometry::Shape; +use serde_json::{Value, json}; + +pub(super) fn script(bodies: &[Shape]) -> Value { + let walls: Vec = bodies + .iter() + .filter(|b| b.name == "Bracket") + .flat_map(|b| { + b.faces().iter().map(move |f| { + json!({ + "face": {"body": b.name, "face": f.name}, + "condition": {"type": "temperature", "value": 80.0} + }) + }) + }) + .collect(); + let [density, viscosity] = super::AIR; + json!([ + {"command": "model.units", "params": {"system": "mm-t-s-N-MPa"}}, + {"command": "model.material.create", "params": {"name": "Air", "density": density, + "fluid": {"type": "newtonian", "viscosity": viscosity}, + "thermal": {"conductivity": 0.026, "specific_heat": 1.005e9}}}, + {"command": "model.fluid_domain.create", "params": { + "name": "WarmAir", "material": "Air", "min": [-30, -20, 0], "max": [100, 60, 70], "spacing": 5, + "faces": {"x_min": {"type": "velocity", "velocity": [1, 0, 0]}, "x_max": {"type": "pressure", "pressure": 0}, + "y_min": {"type": "slip"}, "y_max": {"type": "slip"}, "z_min": {"type": "wall"}, "z_max": {"type": "slip"}}, + "obstacles": ["Bracket"], "initial_velocity": [1, 0, 0], + "thermal": {"initial_temperature": 20, "surfaces": walls, + "faces": {"x_min": {"type": "temperature", "value": 20}}} + }}, + {"command": "model.step.create", "params": {"name": "HeatedFlow", "procedure": { + "type": "fluid", "domain": "WarmAir", "steps": 180, "frames": 6 + }}} + ]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn heated_flow_sample_solves_and_exposes_temperature_and_wall_heat() { + let mut s = crate::Session::default(); + s.execute("file.sample", &json!({"name": "heated-flow"})).unwrap(); + s.execute("solve.fluid", &json!({"backend": "cpu"})).unwrap(); + let report = s.execute("results.slice", &json!({"field": "temperature"})).unwrap(); + let max = report["contour"]["range"]["max"]["value"].as_f64().unwrap(); + let min = report["contour"]["range"]["min"]["value"].as_f64().unwrap(); + assert!(max > 20.1 && max <= 80.01 && min >= 19.99, "{report}"); + let report = s.execute("results.surface", &json!({"field": "wall_heat_flux"})).unwrap(); + assert_eq!(report["contour"]["unit"], "mW/mm²"); + assert!(report["wall_heat"]["faces"].as_array().unwrap().iter().any(|f| f["power"].as_f64().unwrap_or(0.0) < 0.0)); + assert_eq!(report["frames"].as_array().unwrap().len(), 7); + } +} diff --git a/crates/ui-egui/src/samples.rs b/crates/ui-egui/src/samples.rs index 0698197..a3992b8 100644 --- a/crates/ui-egui/src/samples.rs +++ b/crates/ui-egui/src/samples.rs @@ -20,6 +20,7 @@ const THUMBNAILS: &[(&str, &[u8])] = &[ ("heated-bracket", include_bytes!("../thumbnails/heated-bracket.png")), ("bracket-hex", include_bytes!("../thumbnails/bracket-hex.png")), ("flow-past", include_bytes!("../thumbnails/flow-past.png")), + ("heated-flow", include_bytes!("../thumbnails/heated-flow.png")), ("cantilever", include_bytes!("../thumbnails/cantilever.png")), ("impact", include_bytes!("../thumbnails/impact.png")), ("parts", include_bytes!("../thumbnails/parts.png")), diff --git a/crates/ui-egui/thumbnails/heated-flow.png b/crates/ui-egui/thumbnails/heated-flow.png new file mode 100644 index 0000000000000000000000000000000000000000..e7d376dd73348f9a40c2e11028158e427d25ad2f GIT binary patch literal 5884 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 literal 0 HcmV?d00001 diff --git a/docs/examples.md b/docs/examples.md index eaeea9c..c37a9da 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -6,7 +6,7 @@ press **Solve** (`solve.run`). Each opens fully defined — geometry, mesh, mate conditions, loads and steps — in mm, t, s, N and MPa, and opens and solves within half a minute. The geometry is our own: SolidCraft scripts run when the sample opens (a recipe from [`tests/corpus`](../tests/corpus) or one written in `crates/engine/src/sample.rs`). Each sample has a test in -`crates/engine/src/sample.rs` that opens it, checks it clean, solves it and holds its result to +`crates/engine/src/sample.rs` or its modules that opens it, checks it clean, solves it and holds its result to the check given below. ## Drop test (`drop-test`) @@ -200,3 +200,22 @@ SolidCraft geometry with persistent face names, swept all-hex (hex8 of 4 mm, inc clamped at `F2:start` and loaded with 1000 N down through a kinematic coupling to `F2:top`. **Look at:** the bending stress in the flanges, highest at the clamp; the tip moves 0.18 mm down (beam theory with shear: 0.184 mm). + +## Heated flow past a bracket (`heated-flow`) + +Air at 20 °C enters at 1 mm/s and passes the original upright bracket, whose faces are +held at 80 °C. The 130 × 80 × 70 mm domain has 5 mm cells (5,824 cells), a pressure outlet, +slip sides and top, and a no-slip floor. There are 180 steps and six saved output intervals, +plus the initial frame. Open `file.sample {"name":"heated-flow"}` and solve on CPU or GPU. +The slow prescribed flow keeps this coarse lattice away from its small-relaxation-time limit. + +**Look at:** Results → Temperature, or `results.slice {"field":"temperature"}`, for the +warming air. `results.surface {"field":"wall_heat_flux"}` shows heat received by each +bracket face per area: the hot wall heating the air is negative. In this model's mm–tonne–second +units the wall flux is mW/mm². The initial frame has no recorded wall heat and stays uncoloured. + +**Check:** the fluid warms above its inlet temperature while staying below the held wall +temperature (with a small numerical tolerance); recorded hot walls give heat to the fluid. +This coarse, short transient is a quick display demonstration, not a converged convection +benchmark. It has neither buoyancy nor solid heat conduction. The sample is isolated in +`crates/engine/src/sample/heated_flow.rs` and its gallery registration.