1// Runs the simulation in a headless Chromium through Playwright: a small static
2// server serves the built app (dist/), the page is opened with the parameters
3// in the URL, and the audio is read back through the app's test hook.
5import { createServer, type Server } from "node:http";
6import { existsSync, readFileSync, statSync } from "node:fs";
7import { extname, join, normalize } from "node:path";
8import { defaultParams, type PhysParams } from "../params.ts";
9import type { RunResult } from "./run.ts";
11const MIME: Record<string, string> = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".wasm": "application/wasm", ".json": "application/json" };
13function serveStatic(root: string): Promise<{ server: Server; port: number }> {
14 const server = createServer((req, res) => {
15 const url = new URL(req.url ?? "/", "http://localhost");
16 let path = normalize(decodeURIComponent(url.pathname));
17 if (path.endsWith("/")) path += "index.html";
18 const file = join(root, path);
19 if (!file.startsWith(root) || !existsSync(file) || !statSync(file).isFile()) {
20 res.writeHead(404);
21 res.end();
22 return;
23 }
24 res.writeHead(200, { "content-type": MIME[extname(file)] ?? "application/octet-stream" });
25 res.end(readFileSync(file));
26 });
27 return new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve({ server, port: (server.address() as { port: number }).port })));
28}
30type Browser = { newPage(): Promise<Page>; close(): Promise<void> };
31type Page = {
32 goto(url: string): Promise<unknown>;
33 evaluate<T>(fn: string | ((...a: never[]) => T)): Promise<T>;
34 waitForTimeout(ms: number): Promise<void>;
35 on(event: string, cb: (arg: { message: string }) => void): void;
36 close(): Promise<void>;
37};
39let browser: Browser | null = null;
40let served: { server: Server; port: number } | null = null;
42async function getBrowser(chromePath: string | undefined): Promise<Browser> {
43 if (browser) return browser;
44 // Playwright is imported lazily so that the Dawn engine does not need it.
45 const mod = (await import("playwright")) as unknown as { chromium: { launch(o: object): Promise<Browser> } };
46 browser = await mod.chromium.launch({
47 headless: true,
48 executablePath: chromePath || undefined,
49 args: ["--enable-unsafe-webgpu", "--enable-features=Vulkan", "--use-angle=vulkan", "--ignore-gpu-blocklist", "--no-sandbox"],
50 });
51 return browser;
52}
54export interface ChromeOptions {
55 /** Path of the Chrome or Chromium executable; Playwright's bundled Chromium when omitted. */
56 chromePath?: string;
57 /** Directory holding the built app (index.html); default dist/. */
58 distDir?: string;
59}
61export async function runSimulationChrome(overrides: Partial<PhysParams>, log: (s: string) => void, options: ChromeOptions = {}): Promise<RunResult> {
62 const dist = options.distDir ?? join(process.cwd(), "dist");
63 if (!existsSync(join(dist, "index.html"))) throw new Error(`Built app not found at ${dist}; run "npm run build" first`);
64 if (!served) served = await serveStatic(dist);
65 const b = await getBrowser(options.chromePath);
66 const page = await b.newPage();
67 page.on("pageerror", (e) => log(` page error: ${e.message}`));
68 const q = new URLSearchParams();
69 for (const [k, v] of Object.entries(overrides)) q.set(k, String(v));
70 q.set("autostart", "1");
71 await page.goto(`http://127.0.0.1:${served.port}/?${q.toString()}`);
72 const t0 = performance.now();
73 let lastLog = t0;
74 type Status = { phase: string; t: number; stepsPerSec: number; dt: number; nx: number; ny: number; totalSteps: number; stick: { maxForce: number } | null } | null;
75 let st: Status = null;
76 let maxContactForce = 0;
77 for (;;) {
78 await page.waitForTimeout(500);
79 const noGpu = await page.evaluate<string | null>("(() => { const e = document.getElementById('nogpu'); return e && !e.hidden ? e.textContent : null; })()");
80 if (noGpu) throw new Error(`Chrome has no WebGPU: ${noGpu}`);
81 st = await page.evaluate<Status>("window.steelsim ? window.steelsim.status() : null");
82 if (st?.stick) maxContactForce = Math.max(maxContactForce, st.stick.maxForce);
83 if (st && st.phase === "done") break;
84 const now = performance.now();
85 if (now - lastLog > 5000 && st) {
86 lastLog = now;
87 log(` ${st.phase} t=${st.t.toFixed(2)} s, ${(st.stepsPerSec / 1e3).toFixed(0)} k steps/s`);
88 }
89 if (now - t0 > 3600e3) throw new Error("simulation timed out");
90 }
91 const audio = new Float32Array(await page.evaluate<number[]>("window.steelsim.audio()"));
92 const seconds = (performance.now() - t0) / 1000;
93 await page.close();
94 return {
95 params: { ...defaultParams, ...overrides },
96 audio,
97 fs: ({ ...defaultParams, ...overrides }).fs,
98 steps: st!.totalSteps,
99 seconds,
100 grid: { nx: st!.nx, ny: st!.ny },
101 dt: st!.dt,
102 maxContactForce,
103 };
104}
106export async function closeChrome(): Promise<void> {
107 await browser?.close();
108 browser = null;
109 served?.server.close();
110 served = null;
111}