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

+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()