forked from 1b-agent/fieldcraft-upstream
The demo used the corpus I-beam, which is extruded along z and so stood upright. A new corpus part, cantilever-i-beam, is the same section on the YZ plane extruded along x, web upright: a sweep along a horizontal axis for the meshers, and a cantilever that bends downward on screen. The i-beam part is unchanged (its corpus row and the meshing tests keep it). The demo clamps it at x = 0 and loads the free end with 1000 N in -z through a kinematic coupling, and now checks the solution against beam theory and stops when it is off: tip deflection (bending plus shear) within 3 %, root bending stress (stress_xx along the top flange, extrapolated to the root, nodes from the Abaqus deck) within 2 %. docs/meshing/corpus.md regenerated with the new part. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
356 lines
15 KiB
Python
Executable File
356 lines
15 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Regenerate the README demo video: an I-beam cantilever analysed by an agent over MCP.
|
|
|
|
Every analysis step goes through the MCP server (`fieldcraft-cli mcp --connect`), bridged to
|
|
the desktop app, and every frame is the app window captured offscreen through the MCP
|
|
`screenshot` tool (`source: window`). See README.md beside this file.
|
|
|
|
Python standard library only; ffmpeg encodes the frames.
|
|
"""
|
|
|
|
import argparse
|
|
import base64
|
|
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]
|
|
|
|
# The analysis: a cantilever I-beam lying along x (tests/corpus/cantilever-i-beam: 40 x 40 mm,
|
|
# 3 mm flanges, 2 mm web upright, 200 mm long), clamped at x = 0 with 1000 N down (-z) on its free
|
|
# end, so it bends downward on screen (z is up).
|
|
PART = "cantilever-i-beam"
|
|
BODY = "Beam"
|
|
ROOT_FACE = "F2:start"
|
|
TIP_FACE = "F2:top"
|
|
LOAD_N = 1000.0
|
|
LENGTH_MM = 200.0
|
|
DEPTH_MM = 40.0 # overall; the flanges are 40 wide and 3 thick, the web 2 thick
|
|
FLANGE_MM = 3.0
|
|
WEB_MM = 2.0
|
|
YOUNG_MPA = 210000.0
|
|
POISSON = 0.3
|
|
SIZE_MM = 4.0
|
|
STEP = "Load"
|
|
|
|
# Frames per second of the video; each stage holds its picture for a number of frames.
|
|
FPS = 10
|
|
HOLD = 14 # a workflow stage (1.4 s)
|
|
HOLD_SHORT = 10 # one of the model definition steps
|
|
HOLD_LONG = 20 # the stress result
|
|
SWING = 12 # pictures from undeformed to the largest exaggeration
|
|
MAX_SCALE = 150.0 # the largest displacement (0.18 mm) drawn about 26 mm on the 200 mm beam
|
|
|
|
# The captured window, in points (the app's own pixels-per-point scale on top).
|
|
WINDOW = (1280, 800)
|
|
|
|
|
|
def log(msg):
|
|
print(f"demo-video: {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")
|
|
for args in (["-p", "fieldcraft", "-p", "fieldcraft-cli"], ["-p", "fieldcraft-testkit", "--bin", "corpus"]):
|
|
subprocess.run([cargo, "build", "--release", *args], cwd=ROOT, check=True)
|
|
|
|
|
|
def free_port():
|
|
with socket.socket() as s:
|
|
s.bind(("127.0.0.1", 0))
|
|
return s.getsockname()[1]
|
|
|
|
|
|
def wait_for_port(port, app, timeout=180.0):
|
|
end = time.monotonic() + timeout
|
|
while time.monotonic() < end:
|
|
if app.poll() is not None:
|
|
raise SystemExit(f"the app exited with {app.returncode} before its control channel opened")
|
|
try:
|
|
with socket.create_connection(("127.0.0.1", port), timeout=1.0):
|
|
return
|
|
except OSError:
|
|
time.sleep(0.5)
|
|
raise SystemExit(f"the app's control channel on port {port} did not open within {timeout:.0f} s")
|
|
|
|
|
|
class Mcp:
|
|
"""A JSON-RPC client of `fieldcraft-cli mcp` over stdio."""
|
|
|
|
def __init__(self, cli, port):
|
|
self.proc = subprocess.Popen(
|
|
[str(cli), "mcp", "--connect", f"127.0.0.1:{port}"],
|
|
stdin=subprocess.PIPE,
|
|
stdout=subprocess.PIPE,
|
|
text=True,
|
|
)
|
|
self.next_id = 0
|
|
info = self.request("initialize", {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "demo-video", "version": "1"}})
|
|
self.notify("notifications/initialized")
|
|
log(f"MCP server: {info.get('serverInfo', {})}")
|
|
|
|
def notify(self, method):
|
|
self.proc.stdin.write(json.dumps({"jsonrpc": "2.0", "method": method}) + "\n")
|
|
self.proc.stdin.flush()
|
|
|
|
def request(self, method, params):
|
|
self.next_id += 1
|
|
self.proc.stdin.write(json.dumps({"jsonrpc": "2.0", "id": self.next_id, "method": method, "params": params}) + "\n")
|
|
self.proc.stdin.flush()
|
|
line = self.proc.stdout.readline()
|
|
if not line:
|
|
raise SystemExit(f"the MCP server closed its output ({method})")
|
|
reply = json.loads(line)
|
|
if "error" in reply:
|
|
raise SystemExit(f"{method}: {reply['error']}")
|
|
return reply["result"]
|
|
|
|
def tool(self, name, arguments=None):
|
|
"""Call a tool; return its content blocks (an error result stops the run)."""
|
|
result = self.request("tools/call", {"name": name, "arguments": arguments or {}})
|
|
content = result.get("content", [])
|
|
if result.get("isError"):
|
|
text = " ".join(c.get("text", "") for c in content)
|
|
raise SystemExit(f"{name} failed: {text}")
|
|
return content
|
|
|
|
def call(self, name, arguments=None):
|
|
"""Call a tool whose reply is JSON text."""
|
|
content = self.tool(name, arguments)
|
|
return json.loads(content[0]["text"]) if content else None
|
|
|
|
def close(self):
|
|
self.proc.stdin.close()
|
|
self.proc.wait(timeout=30)
|
|
|
|
|
|
class Film:
|
|
"""The frames of the video: each screenshot is written once and held for a number of frames."""
|
|
|
|
def __init__(self, mcp, frames_dir):
|
|
self.mcp = mcp
|
|
self.dir = frames_dir
|
|
self.shots = [] # (file, frames)
|
|
|
|
def shoot(self, frames, view=None):
|
|
args = {"source": "window", "width": WINDOW[0], "height": WINDOW[1]}
|
|
if view:
|
|
args["view"] = view
|
|
content = self.mcp.tool("screenshot", args)
|
|
png = next((c["data"] for c in content if c.get("type") == "image"), None)
|
|
if png is None:
|
|
raise SystemExit("screenshot: no image in the reply")
|
|
path = self.dir / f"shot{len(self.shots):04d}.png"
|
|
path.write_bytes(base64.b64decode(png))
|
|
self.shots.append((path, frames))
|
|
|
|
def sequence(self, folder):
|
|
"""Lay the video out as numbered frames (links to the shots); return their pattern and count."""
|
|
shutil.rmtree(folder, ignore_errors=True)
|
|
folder.mkdir(parents=True)
|
|
n = 0
|
|
for shot, frames in self.shots:
|
|
for _ in range(frames):
|
|
os.link(shot, folder / f"{n:05d}.png")
|
|
n += 1
|
|
return str(folder / "%05d.png"), n
|
|
|
|
|
|
def hand_calculation():
|
|
"""Beam theory for the cantilever: tip deflection in bending (P L^3 / 3 E I) plus shear
|
|
(P L / G A_web, the web carrying the shear) and the bending stress at the root (P L c / I)."""
|
|
b, h, tf, tw = 40.0, DEPTH_MM, FLANGE_MM, WEB_MM
|
|
inertia = (b * h**3 - (b - tw) * (h - 2 * tf) ** 3) / 12.0
|
|
shear_modulus = YOUNG_MPA / (2.0 * (1.0 + POISSON))
|
|
bending = LOAD_N * LENGTH_MM**3 / (3.0 * YOUNG_MPA * inertia)
|
|
shear = LOAD_N * LENGTH_MM / (shear_modulus * (h - tf) * tw)
|
|
return {"bending_mm": bending, "shear_mm": shear, "tip_mm": bending + shear, "root_stress_mpa": LOAD_N * LENGTH_MM * (h / 2) / inertia}
|
|
|
|
|
|
def root_bending_stress(mcp, work):
|
|
"""The bending stress at the root from the solution: stress_xx along the top flange's upper
|
|
face, averaged across it at each station, fitted by a line between one beam depth from either
|
|
end (where beam theory holds) and extrapolated to x = 0. At the root itself the clamp, which
|
|
stops the section's Poisson contraction, adds a local peak beam theory does not have.
|
|
|
|
The node coordinates come from the Abaqus deck (`write_deck`); the stresses from `probe`."""
|
|
deck = work / "ibeam.inp"
|
|
mcp.call("write_deck", {"format": "abaqus", "path": str(deck)})
|
|
stations = {}
|
|
in_nodes = False
|
|
for line in deck.read_text().splitlines():
|
|
if line.startswith("*"):
|
|
in_nodes = line.upper().split(",")[0].strip() == "*NODE"
|
|
continue
|
|
fields = [f.strip() for f in line.split(",")]
|
|
if in_nodes and len(fields) >= 4:
|
|
label, x, z = int(fields[0]), float(fields[1]), float(fields[3])
|
|
if abs(z - DEPTH_MM) < 1e-6 and DEPTH_MM <= x <= LENGTH_MM - DEPTH_MM:
|
|
stations.setdefault(x, []).append(label)
|
|
if len(stations) < 2:
|
|
raise SystemExit(f"root stress: {len(stations)} stations on the top flange in {deck}")
|
|
probed = mcp.call("probe", {"step": STEP, "nodes": [n for nodes in stations.values() for n in nodes]})
|
|
sxx = {r["node"]: r["stress"][0] for r in probed["nodes"]}
|
|
xs = sorted(stations)
|
|
ys = [sum(sxx[n] for n in stations[x]) / len(stations[x]) for x in xs]
|
|
mx, my = sum(xs) / len(xs), sum(ys) / len(ys)
|
|
slope = sum((x - mx) * (y - my) for x, y in zip(xs, ys)) / sum((x - mx) ** 2 for x in xs)
|
|
return my - slope * mx
|
|
|
|
|
|
def against_beam_theory(result):
|
|
"""The solution against beam theory: tip deflection within 3 %, root stress within 2 %."""
|
|
hand = hand_calculation()
|
|
result["hand_calculation"] = hand
|
|
for got, want, tol, what in (
|
|
(result["tip_deflection_mm"], hand["tip_mm"], 0.03, "tip deflection (mm)"),
|
|
(result["root_stress_mpa"], hand["root_stress_mpa"], 0.02, "root bending stress (MPa)"),
|
|
):
|
|
log(f"{what}: {got:.4f}, beam theory {want:.4f} ({(got - want) / want:+.1%})")
|
|
if abs(got - want) > tol * want:
|
|
raise SystemExit(f"{what} {got:.4f} is more than {tol:.0%} from beam theory's {want:.4f}")
|
|
return result
|
|
|
|
|
|
def batch(mcp, commands):
|
|
reply = mcp.call("batch", {"commands": commands})
|
|
if not reply.get("ok"):
|
|
raise SystemExit(f"batch failed: {reply.get('failed')}")
|
|
|
|
|
|
def analyse(mcp, film, design, work):
|
|
"""The analysis, every step an MCP tool call, with the pictures a viewer follows."""
|
|
# 1. Open the part.
|
|
mcp.call("open", {"path": str(design)})
|
|
film.shoot(HOLD, view="iso")
|
|
|
|
# 2. Hex mesh: the extruded section sweeps into structured hexahedra.
|
|
mesh = mcp.call("mesh", {"method": "hex_sweep", "params": {"body": BODY, "size": SIZE_MM}})
|
|
log(f"mesh: {mesh['mesh']['elements']} elements, {mesh['mesh']['nodes']} nodes")
|
|
film.shoot(HOLD)
|
|
|
|
# 3. Material and section, then supports, then the load and the step: one picture each, so
|
|
# the browser's counts grow as the model is defined.
|
|
batch(mcp, [
|
|
{"command": "model.units", "params": {"system": "mm-t-s-N-MPa"}},
|
|
{"command": "model.set.create", "params": {"name": "beam", "geometry": [{"body": BODY}]}},
|
|
{"command": "model.material.create", "params": {"name": "Steel", "elastic": {"type": "isotropic", "young": YOUNG_MPA, "poisson": POISSON}, "density": 7.85e-9}},
|
|
{"command": "model.section.create", "params": {"name": "solid", "set": "beam", "material": "Steel", "kind": {"type": "solid"}, "formulation": {"incompatible_modes": True}}},
|
|
])
|
|
film.shoot(HOLD_SHORT)
|
|
batch(mcp, [
|
|
{"command": "model.set.create", "params": {"name": "root", "geometry": [{"body": BODY, "face": ROOT_FACE}]}},
|
|
{"command": "model.boundary_condition.create", "params": {"name": "clamp", "type": "fixed", "target": {"set": "root"}}},
|
|
])
|
|
film.shoot(HOLD_SHORT)
|
|
batch(mcp, [
|
|
# The end section stays plane: a kinematic coupling to a point on the beam's axis carries
|
|
# the load, so no load-introduction peak hides the root stress.
|
|
{"command": "model.set.create", "params": {"name": "tip", "geometry": [{"body": BODY, "face": TIP_FACE}]}},
|
|
{"command": "model.reference_point.create", "params": {"name": "end", "xyz": [LENGTH_MM, 0, DEPTH_MM / 2]}},
|
|
{"command": "model.coupling.create", "params": {"name": "end_plate", "reference_point": "end", "set": "tip", "kind": "kinematic"}},
|
|
{"command": "model.load.create", "params": {"name": "tip_load", "type": "force", "target": {"reference_point": "end"}, "vector": [0, 0, -LOAD_N]}},
|
|
{"command": "model.step.create", "params": {"name": STEP, "procedure": {"type": "static"}, "boundary_conditions": ["clamp"], "loads": ["tip_load"]}},
|
|
])
|
|
check = mcp.call("check_model")
|
|
if not check.get("ready"):
|
|
raise SystemExit(f"the model is not ready: {check}")
|
|
film.shoot(HOLD_SHORT)
|
|
|
|
# 4. Solve.
|
|
solve = mcp.call("solve", {"analysis": "static", "step": STEP})
|
|
log(f"solve: {solve.get('equations')} equations")
|
|
|
|
# 5. Results: the tip deflection, then von Mises contours on the true (scale 1) shape.
|
|
disp = mcp.call("show_results", {"step": STEP, "field": "displacement"})
|
|
tip = disp["contour"]["range"]["max"]["value"]
|
|
root_stress = root_bending_stress(mcp, work)
|
|
vm = mcp.call("show_results", {"step": STEP, "field": "von_mises", "bands": 12, "deformed": True, "scale": 1})
|
|
peak = vm["contour"]["range"]["max"]["value"]
|
|
log(f"tip deflection {tip:.4f} mm, peak von Mises {peak:.1f} MPa")
|
|
film.shoot(HOLD_LONG, view="iso")
|
|
|
|
# 6. The deformed shape, swung from the true shape to an exaggeration and back, in the iso
|
|
# view and then from the side, ending held at the exaggeration.
|
|
ramp = [max(1.0, MAX_SCALE * i / SWING) for i in range(SWING + 1)]
|
|
for view, scales in (("iso", ramp[1:] + ramp[-2::-1]), ("front", ramp[1:])):
|
|
for i, scale in enumerate(scales):
|
|
mcp.call("show_results", {"step": STEP, "deformed": True, "scale": scale})
|
|
film.shoot(1, view=view if i == 0 else None)
|
|
film.shoot(HOLD_LONG)
|
|
return against_beam_theory({"tip_deflection_mm": tip, "root_stress_mpa": root_stress, "peak_von_mises_mpa": peak, "elements": mesh["mesh"]["elements"], "nodes": mesh["mesh"]["nodes"]})
|
|
|
|
|
|
def encode(film, frames_dir, mp4, gif):
|
|
ffmpeg = os.environ.get("FFMPEG", shutil.which("ffmpeg") or "ffmpeg")
|
|
pattern, count = film.sequence(frames_dir / "sequence")
|
|
log(f"encoding {len(film.shots)} pictures as {count} frames, {count / FPS:.1f} s")
|
|
src = ["-framerate", str(FPS), "-i", pattern]
|
|
common = [ffmpeg, "-hide_banner", "-loglevel", "error", "-y"]
|
|
mp4.parent.mkdir(parents=True, exist_ok=True)
|
|
subprocess.run(
|
|
[*common, *src, "-vf", "scale=1280:-2:flags=lanczos", "-c:v", "libx264", "-preset", "veryslow", "-crf", "26",
|
|
"-pix_fmt", "yuv420p", "-movflags", "+faststart", str(mp4)],
|
|
check=True,
|
|
)
|
|
palette = frames_dir / "palette.png"
|
|
subprocess.run([*common, *src, "-vf", "scale=960:-1:flags=lanczos,palettegen=stats_mode=diff", str(palette)], check=True)
|
|
subprocess.run(
|
|
[*common, *src, "-i", str(palette), "-lavfi",
|
|
"scale=960:-1:flags=lanczos[v];[v][1:v]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle", str(gif)],
|
|
check=True,
|
|
)
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
ap.add_argument("--out", type=Path, default=ROOT / "docs" / "media", help="folder for ibeam-analysis.mp4 and .gif")
|
|
ap.add_argument("--frames", type=Path, help="keep the captured pictures in this folder")
|
|
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-demo-"))
|
|
frames_dir = args.frames.resolve() if args.frames else work / "frames"
|
|
frames_dir.mkdir(parents=True, exist_ok=True)
|
|
design = work / "ibeam.solidcraft"
|
|
subprocess.run([str(bin_dir / "corpus"), "--part", PART, "--design", str(design)], check=True)
|
|
|
|
port = free_port()
|
|
app_log = open(work / "app.log", "w")
|
|
app = subprocess.Popen([str(bin_dir / "fieldcraft"), "--control", str(port)], stdout=app_log, stderr=subprocess.STDOUT)
|
|
mcp = None
|
|
try:
|
|
wait_for_port(port, app)
|
|
mcp = Mcp(bin_dir / "fieldcraft-cli", port)
|
|
film = Film(mcp, frames_dir)
|
|
result = analyse(mcp, film, design, work)
|
|
encode(film, frames_dir, args.out / "ibeam-analysis.mp4", args.out / "ibeam-analysis.gif")
|
|
print(json.dumps(result, indent=2))
|
|
finally:
|
|
if mcp:
|
|
mcp.close()
|
|
app.terminate()
|
|
try:
|
|
app.wait(timeout=10)
|
|
except subprocess.TimeoutExpired:
|
|
app.kill()
|
|
app_log.close()
|
|
shutil.rmtree(work, ignore_errors=True)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|