docs: a flow-past gallery clip and solved still

Add the start-up-transient clip and a solved still of the flow-past sample to
the examples gallery: air flowing past the L-bracket, the wake forming behind
the upright and the drag settling over the 16 s run (it does not shed; that
needs a finer grid or a collision operator stable near the lattice's stability
limit, #557).

tools/flow-clip/make.py regenerates them: it drives the desktop app over the
control channel, solves the lattice Boltzmann step with a dense frame output,
renders the model-only viewport at each frame with a fixed legend clamp
(results.frame then ui.render), and encodes an MP4 and a GIF with ffmpeg, each
frame captioned with the elapsed flow time. The geometry is the sample's own
upright-bracket recipe; nothing comes from a commercial product.

Part of #164.
This commit is contained in:
Christian Svensson authored and Irakli Kourtis committed 2026-10-18 10:15:45 -04:00
1 parent 6996fd71b0
commit 8201f9a954
7 files changed
+306 -2

No files matched your search

+3 -1
View File
@@ -25,7 +25,9 @@ is committed and listed here with its exports.
| docs/media/examples/crank-arm.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the solved crank-arm sample (docs/examples.md) | MIT OR Apache-2.0 |
| 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 (its bracket; the viewport does not draw flow results yet) (docs/examples.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/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 |
| 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 |
+10
View File
@@ -130,3 +130,13 @@ Temperature is available on fluid slices, obstacle surfaces and streamline colou
saved frames carry it. Signed face-average wall heat follows recorded histories and the
model's power/area units. Commands, the Results panel and headless pictures share the view;
initial frames without recorded wall heat remain uncoloured.
### Flow start-up transient and gallery clip (#164)
`results.animate` now plays a fluid step's frames in order, the start-up transient from the
uniform initial field to the settled flow, the way an explicit step plays its frames; the fluid Results
panel gains an Animate control. The `flow-past` gallery sample gains a clip and a solved still of
the air flowing past the L-bracket, regenerated by `tools/flow-clip/make.py` (the app rendered
frame by frame over the control channel, encoded with ffmpeg). The wake forms and the drag
settles; it does not shed (that needs a finer grid or a collision operator stable near τ⁺ = 0.5,
#557).
+9 -1
View File
@@ -52,7 +52,7 @@ contact (#161) the ring will be pressed by contact against a groove.
Air (ρ = 1.2 kg/m³, μ = 1.8 × 10⁻⁵ Pa s) at 25 mm/s past the same L-bracket standing on the
floor, the upright's back to the wind. A 240 × 120 × 100 mm box of 2.5 mm cells (184 320),
uniform inlet, outlet at zero pressure, a no-slip floor and slip walls at the sides and top;
D3Q19 lattice Boltzmann (TRT) for 16 s, about 6.7 flow-throughs, ten frames. Reynolds number
D3Q19 lattice Boltzmann (TRT) for 16 s, about 1.7 passes through the 240 mm box, ten frames. Reynolds number
83 on the upright's 50 mm height: laminar, a steady wake. About 10 s.
**Look at:** the step's report (`solve.fluid`, or Results after Solve): the force on every
@@ -72,6 +72,14 @@ velocity magnitude on a slice through the middle of the box (`results.slice`; pr
`field: "pressure"`), the bracket coloured by the flow beside it (`results.surface`) and
streamlines seeded upstream of it, coloured by speed (`results.streamlines`).
![The solved flow: velocity on the mid-slice, a slow wake behind the upright and faster flow over it, with streamlines bending around the bracket](media/examples/flow-past-flow.png)
**Watch it start up:** a fluid step animates its frames (Results ▸ Animate, or `results.animate`),
playing the start-up transient from the uniform 25 mm/s initial field to the settled wake — the
wake forming behind the upright and the drag settling over the 16 s run. A clip is at
[flow-past-transient.mp4](media/examples/flow-past-transient.mp4), regenerated by
[tools/flow-clip](../tools/flow-clip/README.md).
## Heated bracket (`heated-bracket`)
![The steel bracket's temperature, 120 °C at the heated end of the upright falling to 95 °C at the foot](media/examples/heated-bracket.png)
Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.
+39
View File
@@ -0,0 +1,39 @@
# Flow-past clip
`make.py` regenerates the gallery clip and still of the `flow-past` sample (docs/examples.md):
`docs/media/examples/flow-past-transient.mp4` (the start-up transient of air flowing past the
L-bracket, from the uniform 25 mm/s initial field to the settled wake) and
`docs/media/examples/flow-past-flow.png` (a clean iso render of the settled flow).
```sh
python3 tools/flow-clip/make.py # build, run, encode into docs/media/examples
python3 tools/flow-clip/make.py --no-build # with the release binaries already built
python3 tools/flow-clip/make.py --frames /tmp/f # also keep the rendered pictures
python3 tools/flow-clip/make.py --gif # also write a GIF (not committed; larger)
```
Needs Python 3 (standard library only), a display for the FieldCraft window (the pictures are
drawn offscreen with `ui.render`, so a locked or virtual display is fine — on a headless machine
run it under `xvfb-run`), and `ffmpeg` with libx264 on the `PATH` (or `FFMPEG`). It honours
`CARGO_TARGET_DIR`; set `FIELDCRAFT_GPU=software` for a software renderer.
What it does:
1. Builds `fieldcraft` and `fieldcraft-cli` (release) and starts the app with `--control PORT`.
2. Opens the `flow-past` sample and, so the transient plays smoothly, edits its fluid step to
store `FLOW_CLIP_FRAMES` frames (48 by default) over the 16 s run (`model.step.edit`), then
solves the lattice Boltzmann step on the CPU (`solve.fluid`).
3. Fixes the velocity legend clamp (0 to 45 mm/s) so the colours mean the same thing from frame
to frame, sets a light theme and an iso view, and renders the model-only viewport at each
field frame (`results.frame` then `ui.render`): velocity on the mid-slice, the bracket
coloured by the flow beside it, and streamlines seeded upstream, coloured by speed.
4. Captions each frame with the elapsed flow time in a band below, drawn by the demo-video tool's
standard-library TrueType caption drawer (`tools/demo-video/caption.py`, Inter SemiBold).
5. Encodes the frames at 12 fps (the settled wake held at the end) into the MP4 (H.264) and writes
the settled still. `--gif` also writes a GIF (800 px, its own palette); it is not committed
because it is several times larger than the MP4.
The clip shows the wake forming behind the upright and the drag settling; it does not shed, which
needs a finer grid or a collision operator stable near τ⁺ = 0.5 (see issue #557). The geometry is
the original upright-bracket SolidCraft recipe (the `flow-past` sample); nothing comes from a
commercial product.
+245
View File
@@ -0,0 +1,245 @@
#!/usr/bin/env python3
"""Regenerate the flow-past gallery clip and still image: the start-up transient of air flowing
past the L-bracket, from the uniform 25 mm/s initial field to the settled wake.
The FieldCraft desktop app opens the `flow-past` sample, solves the lattice Boltzmann step with a
dense frame output, and draws the flow (velocity on a mid-slice, the bracket coloured by the flow
beside it, streamlines seeded upstream). Each frame of the step is rendered through the control
channel (`ui.render`, the model only, no window furniture), with a fixed legend clamp so the
colours mean the same thing from frame to frame. ffmpeg encodes the frames as `docs/media/examples/
flow-past-transient.mp4`, captioned with the elapsed flow time, and a clean iso render of the
settled flow is written as `docs/media/examples/flow-past-flow.png`. `--gif` also writes a GIF (not
committed — it is several times larger than the MP4; use it for hosting the clip elsewhere).
Python standard library only; ffmpeg encodes the frames. caption.py (the demo-video tool's
standard-library TrueType caption drawer) draws the time bars.
"""
import argparse
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 (the demo-video caption drawer, standard library only)
# The flow-past sample: air at 25 mm/s past the upright L-bracket (Reynolds number 83 on the
# 50 mm upright). The sample solves 16 s (about 1.7 passes through the 240 mm box at 25 mm/s); the
# clip plays that start-up transient. FRAMES field frames are stored (the sample's 10 is too coarse
# for a smooth clip), so the clip holds FRAMES + 1 pictures from the uniform initial field (frame 0,
# 25 mm/s everywhere) to the settled flow.
SAMPLE = "flow-past"
STEP = "Flow"
DOMAIN = "Wind"
END_TIME = float(os.environ.get("FLOW_CLIP_END_TIME", 16.0))
FRAMES = int(os.environ.get("FLOW_CLIP_FRAMES", 48))
# The render: a clean iso view of the domain, each frame the model only (no window chrome). The
# legend clamp is fixed over the whole run so the wake's colours are comparable frame to frame;
# 45 mm/s sits just above the settled peak (about 42 mm/s), 0 for the still fluid in the wake.
WIDTH, HEIGHT = 1280, 720
CLAMP = (0.0, 45.0)
CAMERA = {"yaw": -40.0, "pitch": 24.0, "target": {"center_of": "bbox"}, "fit": True}
# The video: frames per second, and how long the settled flow is held at the end.
FPS = 12
HOLD_END = 18 # frames (1.5 s) on the settled wake
# The caption bar, a band under each picture (so it never hides the flow).
CAPTION_FONT = ROOT / "crates" / "ui-egui" / "fonts" / "Inter-SemiBold.ttf"
CAPTION_HEIGHT = 44
def log(msg):
print(f"flow-clip: {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 free_port():
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
class Control:
"""A client of the FieldCraft control channel (one JSON line per request and reply)."""
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"the control channel on port {port} did not open within {timeout:.0f} s")
time.sleep(0.5)
self.sock.settimeout(600.0)
self.file = self.sock.makefile("rwb")
self.next_id = 0
def send(self, method, params):
self.next_id += 1
self.file.write((json.dumps({"id": self.next_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} {params}: {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 solve(ctl):
"""Open the sample, make the step store FRAMES frames, and solve the flow."""
ctl.execute("file.sample", {"name": SAMPLE})
# The sample's step stores 10 frames; the clip wants a smoother transient.
ctl.execute("model.step.edit", {"name": STEP, "procedure": {"type": "fluid", "domain": DOMAIN, "end_time": END_TIME, "frames": FRAMES}})
log("solving the flow (lattice Boltzmann) …")
report = ctl.execute("solve.fluid", {"backend": "cpu"})
forces = report.get("forces") or report.get("faces")
log(f"solved: {json.dumps(report.get('stats', report.get('summary', {})))[:200]}")
return forces
def render_frames(ctl, frames_dir):
"""Render the model-only viewport at each field frame, with a fixed legend clamp and the iso
view, and draw its time caption. Return the list of (picture, caption) pairs."""
# The flow display the sample opens with: velocity on the mid-slice, the obstacle coloured and
# streamlines upstream. Fix the legend clamp so the colours are comparable across frames.
# A light background and shaded part, to match the gallery's still pictures.
ctl.send("ui.set", {"dark": False, "style": "shaded_edges", "show_tree": False})
ctl.execute("results.contour", {"field": "velocity", "range": {"min": CLAMP[0], "max": CLAMP[1]}})
ctl.execute("results.surface", {"show": True})
ctl.execute("results.streamlines", {"show": True})
ctl.send("ui.camera", CAMERA)
font = caption.Font.load(CAPTION_FONT)
shots = []
for k in range(FRAMES + 1):
ctl.execute("results.frame", {"frame": k})
shot = frames_dir / f"frame{k:04d}.png"
ctl.send("ui.render", {"path": str(shot), "width": WIDTH, "height": HEIGHT})
t = END_TIME * k / FRAMES
bar = frames_dir / f"cap{k:04d}.png"
bar.write_bytes(caption.caption_bar(font, (WIDTH, CAPTION_HEIGHT), "flow past a bracket", f"t = {t:4.1f} s · Re 83"))
shots.append((shot, bar))
return shots
def sequence(shots, folder):
"""Lay the clip out as numbered frames and captions (the last picture held), returning the two
ffmpeg input patterns and the frame count."""
shutil.rmtree(folder, ignore_errors=True)
(folder / "caps").mkdir(parents=True)
n = 0
held = [(shot, bar, 1) for shot, bar in shots]
held[-1] = (held[-1][0], held[-1][1], HOLD_END)
for shot, bar, count in held:
for _ in range(count):
os.link(shot, folder / f"{n:05d}.png")
os.link(bar, folder / "caps" / f"{n:05d}.png")
n += 1
return str(folder / "%05d.png"), str(folder / "caps" / "%05d.png"), n
def encode(shots, frames_dir, mp4, gif=None):
"""Encode the frames into the MP4 (committed to the repo) and, when `gif` is given, a GIF too
(not committed — it is several times larger; use it for hosting the clip elsewhere)."""
ffmpeg = os.environ.get("FFMPEG", shutil.which("ffmpeg") or "ffmpeg")
pattern, caps, count = sequence(shots, frames_dir / "sequence")
log(f"encoding {count} frames ({count / FPS:.1f} s) into {mp4.name}" + (f" and {gif.name}" if gif else ""))
src = ["-framerate", str(FPS), "-i", pattern, "-framerate", str(FPS), "-i", caps]
# Each picture padded with a band below, the caption bar laid over the band.
captioned = f"[0:v]pad={WIDTH}:{HEIGHT + CAPTION_HEIGHT}:0:0[w];[w][1:v]overlay=0:{HEIGHT}"
common = [ffmpeg, "-hide_banner", "-loglevel", "error", "-y"]
mp4.parent.mkdir(parents=True, exist_ok=True)
subprocess.run(
[*common, *src, "-filter_complex", captioned, "-c:v", "libx264", "-preset", "veryslow", "-crf", "26",
"-pix_fmt", "yuv420p", "-movflags", "+faststart", str(mp4)],
check=True,
)
if gif:
subprocess.run(
[*common, *src, "-filter_complex",
f"{captioned},scale=800:-1:flags=lanczos,split[a][b];[a]palettegen=stats_mode=diff[p];"
"[b][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle", str(gif)],
check=True,
)
def still(ctl, path):
"""A clean iso render of the settled flow for the gallery still."""
ctl.execute("results.frame", {"frame": FRAMES})
ctl.send("ui.render", {"path": str(path), "width": WIDTH, "height": HEIGHT})
log(f"wrote the still {path}")
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--out", type=Path, default=ROOT / "docs" / "media" / "examples", help="folder for the clip and still")
ap.add_argument("--frames", type=Path, help="keep the rendered pictures in this folder")
ap.add_argument("--gif", action="store_true", help="also write the GIF (not committed; for hosting the clip elsewhere)")
ap.add_argument("--no-build", action="store_true", help="use the binaries already built")
args = ap.parse_args()
if not args.no_build:
build()
bin_dir = target_dir() / "release"
work = Path(tempfile.mkdtemp(prefix="fieldcraft-flow-clip-"))
frames_dir = args.frames.resolve() if args.frames else work / "frames"
frames_dir.mkdir(parents=True, exist_ok=True)
args.out.mkdir(parents=True, exist_ok=True)
port = free_port()
app_log = open(work / "app.log", "w")
env = {**os.environ, "FIELDCRAFT_GPU": os.environ.get("FIELDCRAFT_GPU", "software")}
app = subprocess.Popen([str(bin_dir / "fieldcraft"), "--control", str(port)], stdout=app_log, stderr=subprocess.STDOUT, env=env)
ctl = None
try:
ctl = Control(port)
forces = solve(ctl)
if forces:
log(f"face forces: {json.dumps(forces)[:400]}")
shots = render_frames(ctl, frames_dir)
still(ctl, args.out / "flow-past-flow.png")
gif = args.out / "flow-past-transient.gif" if args.gif else None
encode(shots, frames_dir, args.out / "flow-past-transient.mp4", gif)
finally:
if ctl:
ctl.close()
app.terminate()
try:
app.wait(timeout=10)
except subprocess.TimeoutExpired:
app.kill()
app_log.close()
if not args.frames:
shutil.rmtree(work, ignore_errors=True)
if __name__ == "__main__":
main()