diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 71d6ca9..57d21dd 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -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 | diff --git a/ROADMAP.md b/ROADMAP.md index 6933088..37a2ef9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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). diff --git a/docs/examples.md b/docs/examples.md index c37a9da..4079e02 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -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) diff --git a/docs/media/examples/flow-past-flow.png b/docs/media/examples/flow-past-flow.png new file mode 100644 index 0000000..538df26 Binary files /dev/null and b/docs/media/examples/flow-past-flow.png differ diff --git a/docs/media/examples/flow-past-transient.mp4 b/docs/media/examples/flow-past-transient.mp4 new file mode 100644 index 0000000..c62ed68 Binary files /dev/null and b/docs/media/examples/flow-past-transient.mp4 differ diff --git a/tools/flow-clip/README.md b/tools/flow-clip/README.md new file mode 100644 index 0000000..e76a86d --- /dev/null +++ b/tools/flow-clip/README.md @@ -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. diff --git a/tools/flow-clip/make.py b/tools/flow-clip/make.py new file mode 100644 index 0000000..72c3915 --- /dev/null +++ b/tools/flow-clip/make.py @@ -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()