Compare commits

...
Author SHA1 Message Date
Christian Svensson c1fe5def0f examples: electronics cooling — sealed enclosure, natural convection
Second electronics-cooling study (discussion #584): a chip held at 40 C on a
vertical PCB in a sealed 60x40x60 mm air box at 25 C ambient, cooled only by
buoyant natural convection (Boussinesq). It sheds 63 mW, a natural-convection
coefficient near 5 W/m2K and a chip-to-ambient resistance of about 237 K/W —
roughly 18x the finned, fan-cooled sink, which is the lesson.

The buoyant start-up needs a small fixed time step (the automatic step
over-accelerates and goes unstable), and the rise is capped at 15 K; the steady
plume is gentle (cell Reynolds ~5). Convection only — no surface-to-surface
radiation yet. Documented in docs/examples.md with the Gaps on the PR.

- examples/cooling/enclosure.solidcraft.json: the committed recipe (PCB + chip).
- tools/cooling/make.py: the enclosure case, numbers and still.

Part of #584.
2026-10-18 21:47:16 +02:00
Christian Svensson 732e57ebf1 examples: electronics cooling — heat sink in forced air
First of three electronics-cooling studies for the planned CFD release
(discussion #584): a six-fin aluminium heat sink held isothermal at 80 C in a
duct with gentle forced air, solved with the thermal lattice Boltzmann solver.
It sheds 4.4 W, a sink-to-inlet thermal resistance of 13.5 K/W, converged to
under 3 % when the time step is halved.

Built only from what the solver does today, with honest stand-ins for what it
does not: the high-conductivity sink is an isothermal surface (no conjugate
conduction into solids yet, #157), the fan is a modest inlet velocity (the TRT
is resolution-bound near its stability limit, #557). These are documented in
docs/examples.md under Electronics cooling studies.

- examples/cooling/heatsink.solidcraft.json: the committed SolidCraft recipe.
- tools/cooling/make.py: runs the case, prints the numbers, renders the still.
- crates/geometry/examples/compile_recipe.rs: compiles a recipe into a design.

Part of #584.
2026-10-18 21:20:31 +02:00
9 changed files with 587 additions and 0 deletions

No files matched your search

+2
View File
@@ -26,6 +26,8 @@ is committed and listed here with its exports.
| docs/media/examples/crank-mirror.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the solved crank-mirror sample (docs/examples.md) | MIT OR Apache-2.0 |
| docs/meshing/skill/analytic-cylinder.png, docs/meshing/skill/analytic-pipe.png, docs/meshing/skill/analytic-plate-hole.png, docs/meshing/skill/analytic-sphere.png, docs/meshing/skill/analytic-torus.png, docs/meshing/skill/bent-tube.png, docs/meshing/skill/bolted-assembly.png, docs/meshing/skill/cantilever-i-beam.png, docs/meshing/skill/crank-arm.png, docs/meshing/skill/cup.png, docs/meshing/skill/filleted-bracket-blocks.png, docs/meshing/skill/filleted-bracket.png, docs/meshing/skill/i-beam.png, docs/meshing/skill/loft-transition.png, docs/meshing/skill/perforated-plate.png, docs/meshing/skill/pi5-base.png, docs/meshing/skill/pi5-lid.png, docs/meshing/skill/pinhole-block.png, docs/meshing/skill/plain-cup.png, docs/meshing/skill/rounded-box.png, docs/meshing/skill/shallow-groove.png, docs/meshing/skill/sharp-wedge.png, docs/meshing/skill/sphere-on-plate.png, docs/meshing/skill/spline-vase.png, docs/meshing/skill/stepped-block.png, docs/meshing/skill/thin-ribs.png, docs/meshing/skill/thin-wall-enclosure.png, docs/meshing/skill/upright-bracket.png | FieldCraft contributors | Original: section pictures of the corpus parts and samples meshed by the meshing skill, drawn by `fieldcraft-cli snapshot` (`cargo xtask skill-results`, docs/meshing/skill-results.md) | MIT OR Apache-2.0 |
| docs/media/examples/flow-past.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the flow-past sample before solving (its bracket as the sample opens) (docs/examples.md) | MIT OR Apache-2.0 |
| docs/media/examples/cooling-enclosure.png | FieldCraft contributors | Original: the natural-convection sealed-enclosure cooling study rendered by FieldCraft (`ui.render`, light theme), generated by `tools/cooling/make.py`; geometry is the committed `examples/cooling/enclosure.solidcraft.json` recipe | MIT OR Apache-2.0 |
| docs/media/examples/cooling-heat-sink.png | FieldCraft contributors | Original: the forced-convection heat-sink cooling study rendered by FieldCraft (`ui.render`, light theme), generated by `tools/cooling/make.py`; geometry is the committed `examples/cooling/heatsink.solidcraft.json` recipe | MIT OR Apache-2.0 |
| docs/media/examples/flow-past-flow.png | FieldCraft contributors | Original: the solved flow-past sample rendered by FieldCraft (`ui.render`, light theme), velocity on the mid-slice with the bracket and upstream streamlines, generated by `tools/flow-clip/make.py` | MIT OR Apache-2.0 |
| docs/media/examples/flow-past-transient.mp4 | FieldCraft contributors | Original: the flow-past start-up transient rendered frame by frame by FieldCraft (`ui.render`), generated by `tools/flow-clip/make.py` (captions drawn by `tools/demo-video/caption.py` in Inter, OFL, crates/ui-egui/fonts) | MIT OR Apache-2.0 |
| docs/media/examples/cantilever.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the solved cantilever sample (docs/examples.md) | MIT OR Apache-2.0 |
@@ -0,0 +1,17 @@
//! Compile a SolidCraft recipe script (`{"commands": [...]}`) into a `.solidcraft` design that
//! `geometry.open` (and so `fieldcraft-cli run`/`snapshot`) can open. Used by the cooling-examples
//! runner (`tools/cooling/make.py`) to build the committed example geometry from its recipe.
//!
//! `cargo run --example compile_recipe -p fieldcraft-geometry --features scripts -- recipe.json out.solidcraft`
fn main() -> Result<(), String> {
let args: Vec<String> = std::env::args().skip(1).collect();
let (recipe, out) = match args.as_slice() {
[recipe, out] => (recipe, out),
_ => return Err("usage: compile_recipe <recipe.json> <out.solidcraft>".into()),
};
let script = std::fs::read_to_string(recipe).map_err(|e| format!("read {recipe}: {e}"))?;
let bytes = fieldcraft_geometry::script_design(&script).map_err(|e| format!("compile: {e}"))?;
std::fs::write(out, &bytes).map_err(|e| format!("write {out}: {e}"))?;
eprintln!("compile_recipe: wrote {out} ({} bytes)", bytes.len());
Ok(())
}
+79
View File
@@ -227,3 +227,82 @@ temperature (with a small numerical tolerance); recorded hot walls give heat to
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.
# Electronics cooling studies
These are larger CFD studies, not one-click samples: each sets up a thermal lattice-Boltzmann
model from a committed SolidCraft recipe ([`examples/cooling`](../examples/cooling)) and solves it
on the CPU in a few minutes. `tools/cooling/make.py` (see its
[README](../tools/cooling/README.md)) rebuilds their numbers and pictures. They show what the
fluid solver can do for electronics cooling today, and where it stands in for what it cannot yet
do — approximations made honestly:
- **No conjugate heat transfer into solids yet**
([#157](https://github.com/tangency-labs/fieldcraft/issues/157)): a high-conductivity heat sink
or cold plate is modelled as an **isothermal surface**, and a component as a **held-temperature
or heat-flux face**. Real junction temperatures need conduction through the solid.
- **No fan curve**: a fan is a **uniform inlet velocity**. The current two-relaxation-time
collision is resolution-bound near its stability limit
([#557](https://github.com/tangency-labs/fieldcraft/issues/557)), so the forced cases use a
**modest duct velocity** standing in for a low-speed fan; higher fan speeds need the finer-grid
and turbulence work tracked there.
- **No surface-to-surface radiation**: the sealed-enclosure case is **convection only** (radiation
carries a real share of the heat in a sealed box).
Background for the planned electronics-cooling release is in
[discussion #584](https://github.com/tangency-labs/fieldcraft/discussions/584).
## Heat sink in forced air (`examples/cooling/heatsink`)
![Cool air entering a six-fin heat sink held at 80 °C, warming through the fins and carrying a hot wake downstream, streamlines coloured by air temperature](media/examples/cooling-heat-sink.png)
A 40 × 40 mm aluminium heat sink with six 18 mm fins, held isothermal at 80 °C (a high-conductivity
sink with fin efficiency near 1 — see the note above), in a 120 × 44 × 30 mm duct. Air at 20 °C
enters at 100 mm/s (0.1 m/s, a gentle fan), the duct walls no-slip and the outlet at zero pressure.
D3Q19 TRT with the D3Q7 thermal lattice on 158,400 cells of 1 mm, solved to 4 s (16,000 steps at a
halved time step, so the weakly-compressible density error stays near 1 %). About four minutes on
one CPU.
**Numbers:** the sink sheds **4.4 W** to the air — a sink-to-inlet **thermal resistance of
13.5 K/W** over the 60 K rise. Halving the time step again moves the heat by under 3 %, so the
figure is converged; it is in the range expected for a small sink in gentle airflow.
**Look at:** the air warming on the mid-plane temperature slice and the streamlines coloured by
temperature (`results.slice` / `results.streamlines` with `field: "temperature"`), with the sink
coloured by the flow beside it (`results.surface`). The heat each fin face gives the air is in the
`solve.fluid` report (`heat`, negative into the fluid).
**Caveat:** the reported minimum air temperature dips a few kelvin below the inlet near the sharp
fin corners — a local artifact of the D3Q7 anti-bounce-back wall at convex edges, not a physical
undershoot; the legend is clamped to the physical range.
**Regenerate:** `python3 tools/cooling/make.py heat-sink` (needs a display, or `xvfb-run`).
## Sealed enclosure with a PCB (`examples/cooling/enclosure`)
![A chip on a vertical PCB in a sealed box, a warm buoyant plume rising to a hot ceiling over cool air below, with the recirculation drawn by streamlines](media/examples/cooling-enclosure.png)
A small package held at 40 °C on a vertical PCB inside a sealed 60 × 40 × 60 mm air box whose walls
sit at 25 °C ambient — no fan. Buoyancy (Boussinesq, β = 3.4 × 10⁻³ K⁻¹, g along −z) drives a plume
up off the chip; the air stratifies warm at the ceiling over cool air below, and recirculates down
the walls. D3Q19 TRT with the D3Q7 thermal lattice and buoyancy on 144,000 cells of 1 mm, stepped
at a fixed 0.2 ms to 3 s (15,000 steps). About four minutes on one CPU.
**Numbers:** the chip sheds **63 mW** by natural convection — a natural-convection coefficient of
about **5 W/m²K** over its surface, which is in the textbook range for a small body in still air,
and a chip-to-ambient resistance of about **237 K/W**. That the bare chip's resistance is ~18×
the finned, fan-cooled sink's (13.5 K/W) is the point: natural convection alone carries little, so
real parts need a heat sink or airflow. The air stays between the wall and chip temperatures
(24.9–39.5 °C) with no numerical undershoot.
**Look at:** the temperature on the vertical mid-plane slice (the stratification), the streamlines
of the buoyant recirculation, and the heat the chip sheds in the `solve.fluid` report.
**Caveats:** the buoyant **start-up** needs a small fixed time step — the solver's automatic step
over-accelerates the first instant and goes unstable (lattice speed 0.505 at step 38); the steady
plume itself is gentle (73 mm/s, cell Reynolds ≈ 5), so a steady or accelerated solve would avoid
resolving the transient (see the Gaps note on the pull request). The rise is capped at 15 K for the
same reason. This is **convection only**: a sealed enclosure also loses a real share of the heat by
**surface-to-surface radiation**, which the solver does not model yet.
**Regenerate:** `python3 tools/cooling/make.py enclosure` (needs a display, or `xvfb-run`).
Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

@@ -0,0 +1,32 @@
{
"commands": [
{
"command": "solid.box",
"params": {
"corner": [
10,
19,
6
],
"length": 40,
"width": 2,
"height": 44,
"body_name": "PCB"
}
},
{
"command": "solid.box",
"params": {
"corner": [
27,
21,
26
],
"length": 16,
"width": 6,
"height": 12,
"body_name": "Chip"
}
}
]
}
+120
View File
@@ -0,0 +1,120 @@
{
"commands": [
{
"command": "solid.box",
"params": {
"corner": [
0,
0,
0
],
"length": 40,
"width": 40,
"height": 3,
"body_name": "HeatSink"
}
},
{
"command": "solid.box",
"params": {
"corner": [
0,
4.25,
3
],
"length": 40,
"width": 1.5,
"height": 18,
"operation": "join",
"targets": [
"HeatSink"
]
}
},
{
"command": "solid.box",
"params": {
"corner": [
0,
10.25,
3
],
"length": 40,
"width": 1.5,
"height": 18,
"operation": "join",
"targets": [
"HeatSink"
]
}
},
{
"command": "solid.box",
"params": {
"corner": [
0,
16.25,
3
],
"length": 40,
"width": 1.5,
"height": 18,
"operation": "join",
"targets": [
"HeatSink"
]
}
},
{
"command": "solid.box",
"params": {
"corner": [
0,
22.25,
3
],
"length": 40,
"width": 1.5,
"height": 18,
"operation": "join",
"targets": [
"HeatSink"
]
}
},
{
"command": "solid.box",
"params": {
"corner": [
0,
28.25,
3
],
"length": 40,
"width": 1.5,
"height": 18,
"operation": "join",
"targets": [
"HeatSink"
]
}
},
{
"command": "solid.box",
"params": {
"corner": [
0,
34.25,
3
],
"length": 40,
"width": 1.5,
"height": 18,
"operation": "join",
"targets": [
"HeatSink"
]
}
}
]
}
+23
View File
@@ -0,0 +1,23 @@
# Electronics cooling examples
`make.py` runs the electronics-cooling studies in [docs/examples.md](../../docs/examples.md) and
regenerates their numbers and pictures in `docs/media/examples/`. Each case compiles its committed
SolidCraft recipe ([`examples/cooling`](../../examples/cooling)) with the `compile_recipe` example,
sets up a thermal lattice-Boltzmann model (the setup is the per-case function in `make.py`), solves
it on the CPU, prints the headline numbers, and renders a still (and, for the transient cases, a
short MP4).
```sh
python3 tools/cooling/make.py heat-sink # build, run, render into docs/media/examples
python3 tools/cooling/make.py heat-sink --no-build # with the release binaries already built
python3 tools/cooling/make.py heat-sink --no-clip # still only, skip the MP4
```
Needs Python 3 (standard library only), a display for the FieldCraft window (`xvfb-run` on a
headless machine), and `ffmpeg` for the MP4s. Honours `CARGO_TARGET_DIR`; set
`FIELDCRAFT_GPU=software` for a software renderer. The solves take a few minutes each.
What the solver does today, and the stand-ins these cases use for what it does not (conjugate heat
transfer into solids #157, fan curves, surface-to-surface radiation), are described in
docs/examples.md under *Electronics cooling studies*. The geometry is our own SolidCraft recipes;
nothing comes from a commercial product.
+314
View File
@@ -0,0 +1,314 @@
#!/usr/bin/env python3
"""Run the electronics-cooling examples and regenerate their gallery numbers and pictures.
Each case sets up a thermal lattice-Boltzmann model from a committed SolidCraft recipe
(`examples/cooling/*.solidcraft.json`, compiled with the `compile_recipe` example), solves it on
the CPU, prints the headline numbers (heat removed, temperatures, flow, thermal resistance), and
renders a still (and, for the transient cases, a short MP4) into `docs/media/examples/`.
What the solver does today and how these cases stand in for what it does not (see docs/examples.md):
* No conjugate heat transfer into solids yet (#157): a high-conductivity heat sink or cold plate
is modelled as an isothermal surface, and a component as a held-temperature or heat-flux face.
* No fan curve: a fan is a uniform inlet velocity. The current TRT collision is resolution-bound
near its stability limit (#557), so the forced cases use a modest duct velocity.
* No surface-to-surface radiation: the sealed-enclosure case is convection only (radiation, which
carries a real share in a sealed box, is noted as missing).
Python standard library only; ffmpeg encodes the MP4s. Needs a display for the app window (run
under xvfb-run on a headless machine) and the release binaries (built unless --no-build).
"""
import argparse
import base64
import json
import os
import shutil
import socket
import subprocess
import sys
import tempfile
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "tools" / "demo-video"))
import caption # noqa: E402
AIR = {
"name": "Air",
"density": 1.2e-12,
"fluid": {"type": "newtonian", "viscosity": 1.8e-11},
"thermal": {"conductivity": 0.026, "specific_heat": 1.005e9},
}
WIDTH, HEIGHT = 1280, 720
CAPTION_FONT = ROOT / "crates" / "ui-egui" / "fonts" / "Inter-SemiBold.ttf"
CAPTION_HEIGHT = 44
FPS = 12
def log(msg):
print(f"cooling: {msg}", file=sys.stderr, flush=True)
def target_dir():
return Path(os.environ.get("CARGO_TARGET_DIR", ROOT / "target"))
def build():
cargo = os.environ.get("CARGO", "cargo")
subprocess.run([cargo, "build", "--release", "-p", "fieldcraft", "-p", "fieldcraft-cli"], cwd=ROOT, check=True)
def compile_recipe(recipe, out):
cargo = os.environ.get("CARGO", "cargo")
subprocess.run(
[cargo, "run", "-q", "--release", "--example", "compile_recipe", "-p", "fieldcraft-geometry",
"--features", "scripts", "--", str(recipe), str(out)],
cwd=ROOT, check=True,
)
def free_port():
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
class Control:
def __init__(self, port, timeout=180.0):
end = time.monotonic() + timeout
while True:
try:
self.sock = socket.create_connection(("127.0.0.1", port), timeout=5.0)
break
except OSError:
if time.monotonic() > end:
raise SystemExit(f"control channel on port {port} did not open")
time.sleep(0.5)
self.sock.settimeout(1200.0)
self.file = self.sock.makefile("rwb")
self.id = 0
def send(self, method, params):
self.id += 1
self.file.write((json.dumps({"id": self.id, "method": method, "params": params}) + "\n").encode())
self.file.flush()
reply = json.loads(self.file.readline())
if not reply.get("ok"):
raise SystemExit(f"{method} {json.dumps(params)[:120]}: {reply.get('error')}")
return reply.get("result")
def execute(self, command, params=None):
return self.send("engine.execute", {"command": command, "params": params or {}})
def close(self):
try:
self.send("app.quit", {})
except (OSError, SystemExit):
pass
self.file.close()
self.sock.close()
def hold_all_faces(ctl, body, value):
"""Every face of `body` held at a temperature (an isothermal high-k solid, or a held component)."""
bodies = ctl.execute("geometry.bodies", {})
b = next(x for x in bodies if x["name"] == body)
return [{"face": {"body": body, "face": f["name"]}, "condition": {"type": "temperature", "value": value}}
for f in b["faces"]]
# ---------------------------------------------------------------------------- heat sink + fan
def heat_sink(ctl, design):
"""Forced convection: air pushed along a 6-fin aluminium sink held isothermal at 80 C, inlet
air at 20 C. Reports the heat removed and the sink-to-air thermal resistance."""
V, Tsink, Tin = 100.0, 80.0, 20.0 # mm/s, C, C
ctl.execute("geometry.open", {"path": str(design)})
surfaces = hold_all_faces(ctl, "HeatSink", Tsink)
ctl.execute("model.units", {"system": "mm-t-s-N-MPa"})
ctl.execute("model.material.create", AIR)
ctl.execute("model.fluid_domain.create", {
"name": "Duct", "material": "Air", "min": [-30, -2, 0], "max": [90, 42, 30], "spacing": 1.0,
"faces": {"x_min": {"type": "velocity", "velocity": [V, 0, 0]}, "x_max": {"type": "pressure", "pressure": 0},
"y_min": {"type": "wall"}, "y_max": {"type": "wall"}, "z_min": {"type": "wall"}, "z_max": {"type": "wall"}},
"obstacles": ["HeatSink"], "initial_velocity": [V, 0, 0],
"thermal": {"initial_temperature": Tin, "faces": {"x_min": {"type": "temperature", "value": Tin}}, "surfaces": surfaces},
})
ctl.execute("model.step.create", {"name": "Cool",
"procedure": {"type": "fluid", "domain": "Duct", "end_time": 4.0, "frames": 40, "time_step": 0.00025}})
log("solving the heat sink (forced convection) ...")
report = ctl.execute("solve.fluid", {"backend": "cpu"})
numbers = heat_sink_numbers(report, Tsink, Tin, V)
render = {"field": "temperature", "clamp": [Tin, Tsink], "streamlines": True,
"camera": {"yaw": -40.0, "pitch": 22.0, "target": {"center_of": "bbox"}, "fit": True},
"caption": "heat sink · forced air 0.1 m/s", "mp4": False}
return report, numbers, render
def heat_sink_numbers(report, Tsink, Tin, V):
heat = report.get("heat", [])
Q = -sum(h["final"] for h in heat if h.get("body") == "HeatSink") # mW removed from the sink
R = (Tsink - Tin) / (Q / 1000.0) if Q else None # K/W
return {
"inlet_velocity_mm_s": V,
"sink_temperature_C": Tsink, "inlet_air_C": Tin,
"heat_removed_W": round(Q / 1000.0, 3),
"thermal_resistance_K_per_W": round(R, 2) if R else None,
"air_temperature_C": report.get("temperature"),
"cells": report.get("cells"), "steps": report.get("steps"),
"density_change": report.get("mass", {}).get("relative_change"),
"warnings": report.get("warnings"),
}
# ---------------------------------------------------------------------------- sealed enclosure (PCB)
def enclosure(ctl, design):
"""Natural convection: a chip on a vertical PCB in a sealed 60x40x60 mm box, no fan. The chip is
held at 40 C, the enclosure walls at 25 C ambient; buoyancy (Boussinesq) drives the plume. The
buoyant start-up needs a small, fixed time step — the auto step over-accelerates and goes
unstable (see the Gaps note in docs/examples.md). Convection only; no radiation."""
Tchip, Tamb = 40.0, 25.0 # C
ctl.execute("geometry.open", {"path": str(design)})
surfaces = hold_all_faces(ctl, "Chip", Tchip)
ctl.execute("model.units", {"system": "mm-t-s-N-MPa"})
ctl.execute("model.material.create", AIR)
wall = {"type": "temperature", "value": Tamb}
ctl.execute("model.fluid_domain.create", {
"name": "Box", "material": "Air", "min": [0, 0, 0], "max": [60, 40, 60], "spacing": 1.0,
"faces": {f: {"type": "wall"} for f in ("x_min", "x_max", "y_min", "y_max", "z_min", "z_max")},
"obstacles": ["PCB", "Chip"], "initial_velocity": [0, 0, 0],
"thermal": {"initial_temperature": Tamb, "gravity": [0, 0, -9810], "expansion": 3.4e-3,
"faces": {f: wall for f in ("x_min", "x_max", "y_min", "y_max", "z_min", "z_max")},
"surfaces": surfaces},
})
ctl.execute("model.step.create", {"name": "Natural",
"procedure": {"type": "fluid", "domain": "Box", "end_time": 3.0, "frames": 40, "time_step": 0.0002}})
log("solving the enclosure (natural convection) ...")
report = ctl.execute("solve.fluid", {"backend": "cpu"})
heat = report.get("heat", [])
Q = -sum(h["final"] for h in heat if h.get("body") == "Chip") # mW shed by the chip
numbers = {
"chip_temperature_C": Tchip, "ambient_C": Tamb,
"heat_shed_W": round(Q / 1000.0, 4),
"chip_to_ambient_K_per_W": round((Tchip - Tamb) / (Q / 1000.0), 1) if Q else None,
"air_temperature_C": report.get("temperature"),
"max_speed_mm_s": round(report.get("max_speed", 0.0), 1),
"cells": report.get("cells"), "steps": report.get("steps"),
"warnings": report.get("warnings"),
}
render = {"field": "temperature", "clamp": [Tamb, Tchip],
"slice": {"axis": "y", "position": 24},
"streamlines": {"count": 14, "max_steps": 1400, "line": {"from": [27, 24, 39], "to": [43, 24, 39]}},
"camera": {"yaw": -14.0, "pitch": 8.0, "target": {"center_of": "bbox"}, "fit": True},
"caption": "sealed enclosure · natural convection", "mp4": False}
return report, numbers, render
CASES = {
"heat-sink": {"recipe": "heatsink.solidcraft.json", "run": heat_sink, "out": "cooling-heat-sink"},
"enclosure": {"recipe": "enclosure.solidcraft.json", "run": enclosure, "out": "cooling-enclosure"},
}
# ---------------------------------------------------------------------------- render + encode
def apply_view(ctl, render):
ctl.send("ui.set", {"dark": False, "style": "shaded_edges", "show_tree": False})
ctl.execute("results.contour", {"field": render["field"], "range": {"min": render["clamp"][0], "max": render["clamp"][1]}})
ctl.execute("results.surface", {"show": True})
if render.get("slice"):
ctl.execute("results.slice", {"field": render["field"], **render["slice"]})
sl = render.get("streamlines")
if sl:
ctl.execute("results.streamlines", dict({"show": True}, **(sl if isinstance(sl, dict) else {})))
ctl.send("ui.camera", render["camera"])
def render_still(ctl, render, out_png):
apply_view(ctl, render)
ctl.send("ui.render", {"path": str(out_png), "width": WIDTH, "height": HEIGHT})
log(f"wrote still {out_png}")
def render_clip(ctl, render, frames_dir, mp4):
frames_dir.mkdir(parents=True, exist_ok=True)
font = caption.Font.load(CAPTION_FONT)
# Discover the frame count from a results reply.
info = ctl.execute("results.slice", {"field": render["field"]})
nframes = info.get("frames", [])
nframes = len(nframes) if isinstance(nframes, list) else 0
if nframes <= 1:
return None
shots = []
for k in range(nframes):
ctl.execute("results.frame", {"frame": k})
shot = frames_dir / f"f{k:04d}.png"
ctl.send("ui.render", {"path": str(shot), "width": WIDTH, "height": HEIGHT})
bar = frames_dir / f"c{k:04d}.png"
bar.write_bytes(caption.caption_bar(font, (WIDTH, CAPTION_HEIGHT), render["caption"], f"frame {k + 1}/{nframes}"))
shots.append((shot, bar))
seqdir = frames_dir / "seq"
shutil.rmtree(seqdir, ignore_errors=True)
(seqdir / "caps").mkdir(parents=True)
i = 0
held = [(s, b, 1) for s, b in shots]
held[-1] = (held[-1][0], held[-1][1], 16)
for s, b, c in held:
for _ in range(c):
os.link(s, seqdir / f"{i:05d}.png")
os.link(b, seqdir / "caps" / f"{i:05d}.png")
i += 1
ffmpeg = os.environ.get("FFMPEG", shutil.which("ffmpeg") or "ffmpeg")
src = ["-framerate", str(FPS), "-i", str(seqdir / "%05d.png"), "-framerate", str(FPS), "-i", str(seqdir / "caps" / "%05d.png")]
cap = f"[0:v]pad={WIDTH}:{HEIGHT + CAPTION_HEIGHT}:0:0[w];[w][1:v]overlay=0:{HEIGHT}"
mp4.parent.mkdir(parents=True, exist_ok=True)
subprocess.run([ffmpeg, "-hide_banner", "-loglevel", "error", "-y", *src, "-filter_complex", cap,
"-c:v", "libx264", "-preset", "veryslow", "-crf", "26", "-pix_fmt", "yuv420p", "-movflags", "+faststart", str(mp4)], check=True)
log(f"wrote clip {mp4} ({i} frames)")
return mp4
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("case", choices=sorted(CASES), help="which example to run")
ap.add_argument("--out", type=Path, default=ROOT / "docs" / "media" / "examples")
ap.add_argument("--no-build", action="store_true")
ap.add_argument("--no-clip", action="store_true", help="still only, skip the MP4")
args = ap.parse_args()
case = CASES[args.case]
if not args.no_build:
build()
bin_dir = target_dir() / "release"
work = Path(tempfile.mkdtemp(prefix="fieldcraft-cooling-"))
design = work / "part.solidcraft"
compile_recipe(ROOT / "examples" / "cooling" / case["recipe"], design)
args.out.mkdir(parents=True, exist_ok=True)
port = free_port()
env = {**os.environ, "FIELDCRAFT_GPU": os.environ.get("FIELDCRAFT_GPU", "software")}
app = subprocess.Popen([str(bin_dir / "fieldcraft"), "--control", str(port)],
stdout=open(work / "app.log", "w"), stderr=subprocess.STDOUT, env=env)
ctl = None
try:
ctl = Control(port)
report, numbers, render = case["run"](ctl, design)
print(json.dumps(numbers, indent=2))
render_still(ctl, render, args.out / f"{case['out']}.png")
if render.get("mp4") and not args.no_clip:
render_clip(ctl, render, work / "frames", args.out / f"{case['out']}-transient.mp4")
finally:
if ctl:
ctl.close()
app.terminate()
try:
app.wait(timeout=10)
except subprocess.TimeoutExpired:
app.kill()
shutil.rmtree(work, ignore_errors=True)
if __name__ == "__main__":
main()