Files
Christian Svensson 8201f9a954 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.
2026-10-18 10:15:45 -04:00

246 lines
11 KiB
Python

#!/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()