forked from 1b-agent/fieldcraft-upstream
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:
1 parent
6996fd71b0
commit
8201f9a954
7 files changed
+306
-2
No files matched your search
@@ -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.
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user