1import { requestShtDevice, describeAdapter } from './sht/sht.ts';
2import { gridForLmax } from './sht/layout.ts';
3import { ModelSession } from './mgpu/session.ts';
4import { mModelByKey, presets, type MModel, type Params } from './mgpu/registry.ts';
5import { ModelCompileError, formatFailure } from './mgpu/errors.ts';
6import { EXTERNAL_OPS } from './mgpu/externals.ts';
7import { CodeEditor } from './editor/codeEditor.ts';
8import {
9 formatCommand,
10 resolvePreset,
11 DEFAULT_NITER,
12 DEFAULT_STEPS,
13 DEFAULT_WARMUP,
14 type RunSpec,
15} from './bench/runSpec.ts';
16import {
17 mGeometries,
18 mGeometryByKey,
19 defaultGeometryParams,
20 SPHERE_KEY,
21 DEFAULT_GEOMETRY_KEY,
22 type MGeometry,
23} from './geom/registry.ts';
24import {
25 buildTopology,
26 fillFieldValues,
27 fillPositions,
28 fillColors,
29 type SphereMeshTopology,
30} from './render/sphereMesh.ts';
31import { SphereScene } from './render/SphereScene.ts';
32import { Colorbar, fmtValue, floorRange } from './render/colorbar.ts';
33import { colormaps, colormapNames } from './render/colormaps.ts';
34import { MovieRecorder } from './render/movie.ts';
35import { CompareRun } from './compare/compareRun.ts';
36import {
37 crossProduct,
38 mostResolved,
39 variantKey,
40 variantLabel,
41 type Variant,
42} from './compare/variants.ts';
44const $ = <T extends HTMLElement>(id: string): T =>
45 document.getElementById(id) as T;
47const elModel = $<HTMLSelectElement>('model');
48const elGeometry = $<HTMLSelectElement>('geometry');
49const elMorph = $<HTMLInputElement>('morph');
50const elNiter = $<HTMLSelectElement>('niter');
51const elLmax = $<HTMLSelectElement>('lmax');
52const elOversample = $<HTMLSelectElement>('oversample');
53const elColormap = $<HTMLSelectElement>('colormap');
54const elRunPause = $<HTMLButtonElement>('runpause');
55const elBenchmark = $<HTMLButtonElement>('benchmark');
56const elReseed = $<HTMLButtonElement>('reseed');
57const elResetView = $<HTMLButtonElement>('resetview');
58const elMovieToggle = $<HTMLButtonElement>('movietoggle');
59const elMovieBar = $('moviebar');
60const elMovieSpeed = $<HTMLSelectElement>('moviespeed');
61const elMovieRes = $<HTMLSelectElement>('movieres');
62const elMovieRotate = $<HTMLInputElement>('movierotate');
63const elMovie = $<HTMLButtonElement>('movie');
64const elCompareToggle = $<HTMLButtonElement>('comparetoggle');
65const elCompareBar = $('comparebar');
66const elCmpNiter = $('cmp-niter');
67const elCmpLmax = $('cmp-lmax');
68const elCmpDt = $('cmp-dt');
69const elCmpRef = $<HTMLSelectElement>('cmp-ref');
70const elCmpStart = $<HTMLButtonElement>('cmp-start');
71const elCmpCount = $('cmp-count');
72const elParams = $('params');
73const elGeomParams = $('geomparams');
74const elGeomNote = $('geomnote');
75const elPanels = $('panels');
76const elStats = $('stats');
77const elBenchResult = $('benchresult');
78const elCmd = $('cmd');
79const elCopyCmd = $<HTMLButtonElement>('copycmd');
80const elBlurb = $('blurb');
81const elErr = $('err');
82const elSource = $<HTMLTextAreaElement>('source');
83const elHighlight = $('highlight');
84const elCompiled = $('compiled');
85const elEditorTitle = $('editor-title');
86const elEditorFile = $<HTMLSelectElement>('editor-file');
87const elRecompile = $<HTMLButtonElement>('recompile');
88const elRevert = $<HTMLButtonElement>('revert');
90for (const p of presets) {
91 const o = document.createElement('option');
92 o.value = p.key;
93 o.textContent = p.label;
94 elModel.append(o);
95}
96for (const g of mGeometries) {
97 const o = document.createElement('option');
98 o.value = g.key;
99 o.textContent = g.label;
100 elGeometry.append(o);
101}
102for (const [value, label] of [['model', 'the solver'], ['geometry', 'the surface']]) {
103 const o = document.createElement('option');
104 o.value = value;
105 o.textContent = label;
106 elEditorFile.append(o);
107}
108for (const name of colormapNames) {
109 const o = document.createElement('option');
110 o.value = name;
111 o.textContent = name;
112 elColormap.append(o);
113}
114elColormap.value = 'jet';
116/** Whichever .m is open: the solver or the surface. Both are MATLAB, compiled
117 * by the same backend, so one editor serves both. The host-provided operations
118 * are marked so the boundary between the file and what it is given is
119 * visible. */
120const editor = new CodeEditor({
121 textarea: elSource,
122 overlay: elHighlight,
123 external: EXTERNAL_OPS,
124 onInput: (value) => {
125 if (editing === 'geometry') editedGeomSource = value;
126 else editedSource = value;
127 elRecompile.textContent = 'Recompile *';
128 },
129});
131/**
132 * Timesteps submitted per rendered frame, at most. Nothing is read back
133 * between them, so the batch costs one submit and one readback regardless of
134 * size — but a compute pass is still real GPU work, and a browser's GPU
135 * process enforces a watchdog timeout a headless desktop run does not: a
136 * submission with enough dispatches in it can trip "device lost" outright,
137 * on weak-enough hardware, well before it would ever show up as merely slow.
138 * The `for k = 1:niter` correction loop makes a step's dispatch count scale
139 * with niter (each iteration is ~15 dispatches per species — see
140 * models/schnakenberg.m), so a fixed per-frame step count that was safe when
141 * every model's step was a handful of dispatches is not safe once niter is
142 * large. `stepsPerFrame`/`measureBurst` below scale it down — never up, so
143 * the common case does not change — to keep one submission's total dispatch
144 * count under DISPATCH_BUDGET regardless of how expensive the compiled step
145 * is.
146 */
147const STEPS_PER_FRAME_BASE = 4;
148/** See STEPS_PER_FRAME_BASE. Recomputed per rebuild in `rebuild()`. */
149let stepsPerFrame = STEPS_PER_FRAME_BASE;
151/**
152 * Steps in a solver-timing burst, and how often to run one.
153 *
154 * Timing the solver needs a `queue.onSubmittedWorkDone()` to know the work
155 * finished, and in a browser that is an IPC round trip into the GPU process — a
156 * fixed cost of a few milliseconds. Spread over one frame's four steps it would
157 * swamp them on a fast GPU and make the solver look far slower than it is. So the
158 * rate is measured in an occasional larger batch, where the single sync is
159 * amortized the way the desktop benchmark amortizes its own. The state is
160 * snapshotted and restored around the batch, so measuring never advances the
161 * simulation — otherwise the pattern would visibly lurch forward at every
162 * measurement.
163 */
164const MEASURE_BURST_BASE = 32;
165/** See STEPS_PER_FRAME_BASE — the measurement burst is one submission too,
166 * and a bigger one: 32 steps is the single largest batch this app ever
167 * submits, so it is the first thing to cross DISPATCH_BUDGET as niter grows. */
168let measureBurst = MEASURE_BURST_BASE;
169const MEASURE_EVERY_MS = 2000;
171/**
172 * Upper bound on dispatches in one submission — the frame batch and the
173 * measurement burst are both scaled down to stay under this, never up, so
174 * a cheap model's pacing is unchanged. Chosen well under what this project's
175 * own desktop benchmark measures as trivially fast (single-digit ms even at
176 * niter=8's ~450 dispatches/step), because the risk here is not GPU time on
177 * capable hardware — it is a browser's GPU-process watchdog on weak
178 * (integrated-graphics) hardware, which a headless desktop run never
179 * exercises and this project has no way to benchmark directly.
180 */
181const DISPATCH_BUDGET = 1000;
183/**
184 * 'auto' display oversampling targets this many render latitudes: the factor is
185 * the smallest power of two (up to 4) that reaches it. A solver grid already
186 * this fine gains nothing visually and is not oversampled.
187 */
188const AUTO_RENDER_NLAT = 256;
190/** The display oversampling factor the UI currently asks for. */
191function resolveOversample(): number {
192 if (elOversample.value !== 'auto') return Number(elOversample.value);
193 const { nlat } = gridForLmax(Number(elLmax.value), model.pdeg);
194 let os = 1;
195 while (os < 4 && os * nlat < AUTO_RENDER_NLAT) os *= 2;
196 return os;
197}
199/**
200 * Movie frame rate, and a cap on frames per movie. Playback speed comes from
201 * the UI, in simulation-time units per second of video; the movie's length is
202 * the run's t at that speed, and the frame count follows from it — capped by
203 * the run's own step count (a step is at most one frame) and by
204 * MOVIE_MAX_FRAMES to bound encode time and file size. Frame timestamps are
205 * derived from simulation time, so a capped movie keeps its duration and
206 * speed exactly, at a lower effective frame rate.
207 */
208const MOVIE_FPS = 30;
209const MOVIE_MAX_FRAMES = 3600;
211/** Movie auto-rotation: camera revolutions per second of video. Measured in
212 * video time, so the orbit pace on screen is the same at every export speed. */
213const MOVIE_ROTATE_RPS = 1 / 120;
215// ---------------------------------------------------------------- state
216let device: GPUDevice | null = null;
217let session: ModelSession | null = null;
218let topo: SphereMeshTopology | null = null;
219let scenes: SphereScene[] = [];
220let colorbars: Colorbar[] = [];
221let valueBufs: Float32Array[] = [];
222let colorBufs: Float32Array[] = [];
223let ranges: { lo: number; hi: number }[] = [];
224let resizeObs: ResizeObserver | null = null;
226const initial = resolvePreset(presets[0].key);
227let model: MModel = mModelByKey(initial.model.key)!;
228let params: Params = initial.params;
229let geometry: MGeometry = mGeometryByKey(DEFAULT_GEOMETRY_KEY)!;
230let geomParams: Params = defaultGeometryParams(geometry);
231/** Which file the editor is showing. */
232let editing: 'model' | 'geometry' = 'model';
233/** Each .m as edited in the page; `null` while it matches the file. */
234let editedSource: string | null = null;
235let editedGeomSource: string | null = null;
236/** Sphere (0) to surface (1). Display only; does not touch the solver. */
237let morph = 1;
238let seed = 1;
239let running = false;
240let adapterName = '';
241let pumping = false;
242let movieBusy = false;
243let movieCancel = false;
244let solverMs = 0;
245let frameMs = 0;
246let lastMeasure = 0;
247let generation = 0; // bumped on every rebuild to cancel stale pumps
248/** Surface coordinates on the render grid, interleaved xyz; null before the
249 * first build. Kept so the morph slider can re-fill positions without
250 * re-synthesizing. */
251let coords: Float32Array | null = null;
252let posBuf: Float32Array | null = null;
253/** The convergence study, when one is running; null in ordinary single-run
254 * mode. While it is non-null there is no `session`: the study owns one per
255 * variant, and the panels area is its grid. */
256let compareRun: CompareRun | null = null;
258const source = (): string => editedSource ?? model.source;
259const geomSource = (): string => editedGeomSource ?? geometry.source;
261// ---------------------------------------------------------------- UI wiring
262function buildParamInputs(): void {
263 elParams.replaceChildren();
264 for (const spec of model.params) {
265 const label = document.createElement('label');
266 label.textContent = `${spec.label} `;
267 const input = document.createElement('input');
268 input.type = 'number';
269 input.min = String(spec.min);
270 input.max = String(spec.max);
271 input.step = String(spec.step);
272 input.value = String(params[spec.key]);
273 input.addEventListener('change', () => {
274 const v = Number(input.value);
275 if (Number.isFinite(v)) params[spec.key] = v;
276 // Parameters are uniforms, not constants baked into the kernels, so a
277 // change costs an upload rather than a recompile. In compare mode `dt`
278 // is the *base* timestep each variant's divisor divides, so the study
279 // re-derives every variant's dt from it.
280 session?.setParams(params);
281 compareRun?.setParams(params);
282 updateCommand();
283 });
284 label.append(input);
285 elParams.append(label);
286 }
287}
289/**
290 * The shape's own parameters. Unlike the model's, these are NOT uniforms: the
291 * surface is evaluated once at build time and reduced to coefficients, so
292 * moving one rebuilds the geometry (and with it the mesh), though not the
293 * simulation's compiled step.
294 */
295function buildGeomParamInputs(): void {
296 elGeomParams.replaceChildren();
297 if (geometry.params.length === 0) return;
298 const tag = document.createElement('label');
299 tag.textContent = `${geometry.key}.m`;
300 elGeomParams.append(tag);
301 for (const spec of geometry.params) {
302 const label = document.createElement('label');
303 label.textContent = `${spec.label} `;
304 const input = document.createElement('input');
305 input.type = 'number';
306 input.min = String(spec.min);
307 input.max = String(spec.max);
308 input.step = String(spec.step);
309 input.value = String(geomParams[spec.key]);
310 input.addEventListener('change', () => {
311 const v = Number(input.value);
312 if (Number.isFinite(v)) geomParams[spec.key] = v;
313 viewChange = viewChange.then(() => applyGeometry());
314 });
315 label.append(input);
316 elGeomParams.append(label);
317 }
318}
320function applyPreset(presetKey: string): void {
321 const resolved = resolvePreset(presetKey);
322 const next = mModelByKey(resolved.model.key);
323 if (!next) {
324 elErr.textContent = `No .m model for '${resolved.model.key}'`;
325 return;
326 }
327 model = next;
328 params = resolved.params;
329 editedSource = null;
330 buildParamInputs();
331 elBlurb.textContent = model.blurb;
332 showEditorFile();
333 updateCommand();
334}
336function applyGeometryChoice(key: string): void {
337 const next = mGeometryByKey(key);
338 if (!next) {
339 elErr.textContent = `No .m geometry for '${key}'`;
340 return;
341 }
342 geometry = next;
343 geomParams = defaultGeometryParams(geometry);
344 editedGeomSource = null;
345 buildGeomParamInputs();
346 showEditorFile();
347}
349/** Load the chosen file into the editor, keeping any unsaved edit to it. */
350function showEditorFile(): void {
351 editing = elEditorFile.value === 'geometry' ? 'geometry' : 'model';
352 if (editing === 'geometry') {
353 editor.value = geomSource();
354 elEditorTitle.textContent = `geometries/${geometry.key}.m`;
355 } else {
356 editor.value = source();
357 elEditorTitle.textContent = `models/${model.key}.m`;
358 }
359}
361/**
362 * The run currently on screen, as the benchmark's RunSpec. While a study is
363 * running there is no single run, so this describes its *reference* variant —
364 * the one the other rows are measured against, and the only one of them whose
365 * numbers mean anything on their own.
366 */
367function currentSpec(): RunSpec {
368 const ref = compareRun?.variants[compareRefIndex()];
369 const dt = ref ? { dt: (params.dt ?? 0) / ref.dtDiv } : null;
370 return {
371 preset: elModel.value,
372 lmax: ref ? ref.lmax : Number(elLmax.value),
373 seed,
374 steps: DEFAULT_STEPS,
375 warmup: DEFAULT_WARMUP,
376 params: dt ? { ...params, ...dt } : params,
377 geometry: geometry.key,
378 geometryParams: geomParams,
379 niter: ref ? ref.niter : Number(elNiter.value),
380 };
381}
383function updateCommand(): void {
384 elCmd.textContent = formatCommand(currentSpec());
385}
387elModel.addEventListener('change', () => {
388 applyPreset(elModel.value);
389 void rebuild();
390});
391elLmax.addEventListener('change', () => void rebuild());
392// The solve iteration count is unrolled into the compiled step, so unlike a
393// parameter it cannot be changed without recompiling.
394elNiter.addEventListener('change', () => void rebuild());
395// Oversampling and geometry are display-or-data changes, not code ones, so
396// they swap things in place rather than rebuilding the run. Serialized through
397// one chain: a rapid second change waits its turn.
398let viewChange = Promise.resolve();
399elOversample.addEventListener('change', () => {
400 // The study picks its own display grid — one grid common to every variant is
401 // what makes their fields comparable — so this control is inert (and
402 // disabled) while one is running.
403 if (compareRun) return;
404 viewChange = viewChange.then(() => applyOversample());
405});
406elGeometry.addEventListener('change', () => {
407 applyGeometryChoice(elGeometry.value);
408 viewChange = viewChange.then(() => applyGeometry());
409});
410// Morph is pure rendering: no readback, no GPU work, just the vertex buffer.
411elMorph.addEventListener('input', () => {
412 morph = Number(elMorph.value);
413 if (compareRun) compareRun.setMorph(morph);
414 else applyMorph();
415});
416elColormap.addEventListener('change', () => {
417 if (compareRun) void compareRun.draw();
418 else void draw();
419});
420elEditorFile.addEventListener('change', () => showEditorFile());
422function setRunning(next: boolean): void {
423 running = next;
424 elRunPause.textContent = running ? 'Pause' : 'Run';
425 if (compareRun) {
426 compareRun.setRunning(next);
427 return;
428 }
429 if (running) void pump();
430}
432elRunPause.addEventListener('click', () => setRunning(!running));
433elBenchmark.addEventListener('click', () => void benchmark());
434elReseed.addEventListener('click', () => {
435 seed = (Math.random() * 2 ** 31) >>> 0;
436 setRunning(false);
437 updateCommand();
438 void reseed();
439});
440elResetView.addEventListener('click', () => {
441 compareRun?.resetView();
442 for (const s of scenes) s.resetCamera();
443});
444elMovieToggle.addEventListener('click', () => {
445 elMovieBar.hidden = !elMovieBar.hidden;
446});
447elMovie.addEventListener('click', () => {
448 if (movieBusy) movieCancel = true;
449 else void recordMovie();
450});
452elRecompile.addEventListener('click', () => {
453 if (editing === 'geometry') editedGeomSource = editor.value;
454 else editedSource = editor.value;
455 void rebuild();
456});
457elRevert.addEventListener('click', () => {
458 if (editing === 'geometry') editedGeomSource = null;
459 else editedSource = null;
460 showEditorFile();
461 void rebuild();
462});
464// The command reproduces this run's parameters on the desktop; keep it
465// selectable even where the clipboard API is unavailable.
466elCopyCmd.addEventListener('click', () => {
467 const text = elCmd.textContent ?? '';
468 const flash = (msg: string): void => {
469 elCopyCmd.textContent = msg;
470 setTimeout(() => (elCopyCmd.textContent = 'Copy'), 1200);
471 };
472 const selectCommand = (): void => {
473 const range = document.createRange();
474 range.selectNodeContents(elCmd);
475 const sel = getSelection();
476 sel?.removeAllRanges();
477 sel?.addRange(range);
478 flash('Selected');
479 };
480 if (!navigator.clipboard) return selectCommand();
481 navigator.clipboard.writeText(text).then(() => flash('Copied'), selectCommand);
482});
484// ---------------------------------------------------------------- setup
485function disposeView(): void {
486 for (const s of scenes) s.dispose();
487 scenes = [];
488 colorbars = [];
489 topo = null;
490 coords = null;
491 posBuf = null;
492 resizeObs?.disconnect();
493 resizeObs = null;
494 elPanels.replaceChildren();
495}
497/**
498 * Build the mesh, scenes, colorbars and per-species buffers on the current
499 * render grid, from surface coordinates already synthesized there. Call
500 * disposeView() first. The color ranges are kept if present, so a display-only
501 * rebuild (an oversampling change) does not pop the shading; a full rebuild
502 * clears `ranges` beforehand.
503 */
504function buildView(surface: Float32Array): void {
505 if (!session) return;
506 const view = session.viewSht;
507 const { nphi } = view.cfg;
508 const phi = new Float64Array(nphi);
509 for (let j = 0; j < nphi; j++) phi[j] = (2 * Math.PI * j) / nphi;
510 topo = buildTopology(view.cosTheta, phi);
511 coords = surface;
512 posBuf = new Float32Array(topo.numVertices * 3);
513 fillPositions(posBuf, coords, topo, morph);
515 const sphereBg = getComputedStyle(document.documentElement)
516 .getPropertyValue('--sphere-bg')
517 .trim();
518 for (let k = 0; k < model.species.length; k++) {
519 const panel = document.createElement('div');
520 panel.className = 'panel';
521 const box = document.createElement('div');
522 box.className = 'sphere-box';
523 const tag = document.createElement('div');
524 tag.className = 'species-tag';
525 tag.textContent = model.species[k];
526 box.append(tag);
527 const side = document.createElement('div');
528 panel.append(box, side);
529 elPanels.append(panel);
531 const scene = new SphereScene(
532 box,
533 topo.numVertices,
534 topo.indices,
535 // Each scene owns its position buffer: three.js uploads from it, and the
536 // morph rewrites all of them from the one shared `coords`.
537 Float32Array.from(posBuf),
538 sphereBg || undefined,
539 );
540 scene.fitCamera();
541 scenes.push(scene);
542 colorbars.push(new Colorbar(side));
543 valueBufs[k] = new Float32Array(topo.numVertices);
544 colorBufs[k] = new Float32Array(topo.numVertices * 3);
545 if (!ranges[k]) ranges[k] = { lo: NaN, hi: NaN };
546 }
547 for (let k = 1; k < scenes.length; k++) scenes[0].syncCamerasWith(scenes[k]);
549 resizeObs = new ResizeObserver(() => {
550 const boxes = elPanels.querySelectorAll<HTMLElement>('.sphere-box');
551 boxes.forEach((box, i) => {
552 scenes[i]?.resize(box.clientWidth, box.clientHeight);
553 });
554 });
555 elPanels
556 .querySelectorAll<HTMLElement>('.sphere-box')
557 .forEach((box) => resizeObs!.observe(box));
558}
560/**
561 * Apply the UI's oversampling choice to the running session. Display-only: the
562 * session and its state survive; only the display plan, mesh and scenes are
563 * rebuilt, keeping the camera pose and color ranges. The pump is drained first
564 * so no readback is in flight on the plan being replaced.
565 */
566async function applyOversample(): Promise<void> {
567 if (!session) return;
568 const gen = generation;
569 const os = resolveOversample();
570 if (os === session.oversample) return;
571 const wasRunning = running;
572 setRunning(false);
573 while (pumping) await nextFrame();
574 if (gen !== generation || !session) return;
575 await session.setOversample(os);
576 if (gen !== generation || !session) return;
577 const surface = await session.renderPositions();
578 if (gen !== generation || !session) return;
579 const cam = scenes[0]?.cameraState();
580 disposeView();
581 buildView(surface);
582 if (cam) for (const s of scenes) s.setCameraState(cam);
583 await draw();
584 updateStats();
585 if (wasRunning) setRunning(true);
586}
588/**
589 * Re-evaluate the surface and swap it in. Data, not code: the compiled step is
590 * untouched and the simulation keeps its state and its model time, so a shape
591 * can be changed mid-run. Only the mesh is rebuilt.
592 */
593async function applyGeometry(): Promise<void> {
594 // The in-place swap below is a single session's trick. Each variant carries
595 // the surface band-limited at its own lmax, and the study's meshes are built
596 // from those, so a shape change goes through the full rebuild instead.
597 if (compareRun) return rebuildCompare();
598 if (!session) return;
599 const gen = generation;
600 const wasRunning = running;
601 setRunning(false);
602 while (pumping) await nextFrame();
603 if (gen !== generation || !session) return;
604 try {
605 await session.setGeometry(geometry, geomParams, geomSource());
606 } catch (e) {
607 reportCompileError(e);
608 return;
609 }
610 if (gen !== generation || !session) return;
611 const surface = await session.renderPositions();
612 if (gen !== generation || !session) return;
613 const cam = scenes[0]?.cameraState();
614 disposeView();
615 buildView(surface);
616 if (cam) for (const s of scenes) s.setCameraState(cam);
617 elErr.textContent = '';
618 await draw();
619 updateGeomNote();
620 updateStats();
621 if (wasRunning) setRunning(true);
622}
624/** Re-place the vertices for the current morph. No GPU work and no readback —
625 * the surface is already on the CPU, so this is a buffer fill per panel. */
626function applyMorph(): void {
627 if (!topo || !coords || !posBuf) return;
628 fillPositions(posBuf, coords, topo, morph);
629 for (const s of scenes) s.updatePositions(posBuf);
630}
632/** What the surface is, and the standing caveat about where it is not. */
633function updateGeomNote(): void {
634 // In compare mode each variant carries the surface band-limited at its own
635 // lmax; the reference's is the one quoted, as everywhere else.
636 const s = session ?? compareRun?.referenceSession ?? null;
637 if (!s) {
638 elGeomNote.textContent = '';
639 return;
640 }
641 const { lo, hi } = s.geometry.radiusRange();
642 const isSphere = s.geometryModel.key === SPHERE_KEY;
643 elGeomNote.innerHTML =
644 `<b>${s.geometryModel.label}</b> — ${s.geometryModel.blurb} ` +
645 `Radius ${lo.toFixed(3)}–${hi.toFixed(3)}.` +
646 (isSphere ? '' : ' <b>Rendered only</b> — not yet in the operator.');
647}
649/** Report a compile failure, and select the offending text in the editor. */
650function reportCompileError(e: unknown): void {
651 elErr.textContent = formatFailure(e, source());
652 elCompiled.textContent = '';
653 if (e instanceof ModelCompileError && e.start !== undefined) {
654 editor.select(e.start, e.end ?? e.start);
655 }
656}
658async function rebuild(): Promise<void> {
659 // A study is several runs, so "rebuild the run" means rebuild all of them.
660 // Everything that recompiles — a model or preset change, an edit to either
661 // .m, a revert — arrives here, and none of it needs to know which mode is up.
662 if (compareRun) return rebuildCompare();
663 generation++;
664 const gen = generation;
665 setRunning(false);
666 disposeView();
667 session?.destroy();
668 session = null;
669 solverMs = 0;
670 frameMs = 0;
671 // Not 0: with a large niter's dispatch count not yet known (that needs the
672 // compiled plan below), the first measurement burst should wait for the
673 // ordinary per-frame batch — already sized to this model — to prove itself
674 // first, rather than firing a possibly-oversized burst before a single
675 // frame has run.
676 lastMeasure = performance.now();
677 elErr.textContent = '';
678 updateCommand();
679 if (!device) return;
681 try {
682 session = await ModelSession.create({
683 device,
684 model,
685 params,
686 lmax: Number(elLmax.value),
687 source: source(),
688 oversample: resolveOversample(),
689 geometry,
690 geometryParams: geomParams,
691 geometrySource: geomSource(),
692 niter: Number(elNiter.value),
693 });
694 } catch (e) {
695 reportCompileError(e);
696 return;
697 }
698 if (gen !== generation) return;
700 session.seed(seed);
702 const plan = session.describe();
703 elCompiled.textContent =
704 `one step compiled to ${plan.step.length} GPU operations:\n` +
705 plan.step.map((l) => ` ${l}`).join('\n');
706 elRecompile.textContent = 'Recompile';
708 // Scale the frame batch and the measurement burst down — never up — so
709 // neither submission's total dispatch count exceeds DISPATCH_BUDGET, no
710 // matter how expensive niter has made one step. See STEPS_PER_FRAME_BASE.
711 const opsPerStep = Math.max(1, plan.step.length);
712 stepsPerFrame = Math.max(1, Math.min(STEPS_PER_FRAME_BASE, Math.floor(DISPATCH_BUDGET / opsPerStep)));
713 measureBurst = Math.max(1, Math.min(MEASURE_BURST_BASE, Math.floor(DISPATCH_BUDGET / opsPerStep)));
715 const surface = await session.renderPositions();
716 if (gen !== generation) return;
718 ranges = [];
719 buildView(surface);
721 await draw();
722 updateGeomNote();
723 updateStats();
724 void pump();
725}
727async function reseed(): Promise<void> {
728 // One new perturbation for the whole study, band-limited at its coarsest
729 // variant and evaluated on each grid — see src/compare/sharedStart.ts.
730 if (compareRun) return compareRun.reseed(seed);
731 if (!session) return;
732 const gen = generation;
733 session.seed(seed);
734 if (gen !== generation) return;
735 for (const r of ranges) {
736 r.lo = NaN;
737 r.hi = NaN;
738 }
739 await draw();
740 updateStats();
741}
743// ---------------------------------------------------------------- drawing
744async function draw(): Promise<void> {
745 if (!session || !topo) return;
746 const gen = generation;
747 const cmap = colormaps[elColormap.value] ?? colormaps.viridis;
748 for (let k = 0; k < model.species.length; k++) {
749 // The one readback per frame — the loop is otherwise entirely on the GPU.
750 // A rebuild can land while this is in flight and destroy the buffer being
751 // mapped, which rejects the map; that result is stale anyway, so drop it.
752 let field: Float32Array;
753 try {
754 field = await session.readSpecies(k);
755 } catch (e) {
756 if (gen !== generation) return;
757 throw e;
758 }
759 if (gen !== generation || !topo) return;
760 fillFieldValues(valueBufs[k], field, topo);
761 let lo = Infinity;
762 let hi = -Infinity;
763 for (const v of valueBufs[k]) {
764 if (v < lo) lo = v;
765 if (v > hi) hi = v;
766 }
767 // smooth the color range in both directions so the shading evolves
768 // gently as the pattern grows (out-of-range values clamp meanwhile)
769 const r = ranges[k];
770 if (!Number.isFinite(r.lo)) {
771 r.lo = lo;
772 r.hi = hi;
773 } else {
774 const a = 0.15;
775 r.lo += a * (lo - r.lo);
776 r.hi += a * (hi - r.hi);
777 }
778 // A field that is uniform to fp32 precision — Schnakenberg's v at t = 0 is
779 // exactly constant — would otherwise have the colormap stretched across its
780 // roundoff and be drawn as vivid noise. See floorRange.
781 const shown = floorRange(r.lo, r.hi);
782 fillColors(colorBufs[k], valueBufs[k], shown.lo, shown.hi, cmap);
783 scenes[k]?.updateColors(colorBufs[k]);
784 colorbars[k]?.update(cmap, shown.lo, shown.hi);
785 }
786}
788function updateStats(): void {
789 if (!session) return;
790 const { nlat, nphi } = session.cfg;
791 const kind = `WebGPU fp32${adapterName ? ` — ${adapterName}` : ''}`;
792 const solver =
793 solverMs > 0
794 ? `<b>${solverMs.toFixed(2)} ms/step</b> (${(1000 / solverMs).toFixed(0)} steps/s)`
795 : '—';
796 const frame = frameMs > 0 ? `${frameMs.toFixed(1)} ms/frame` : '—';
797 const view = session.viewSht.cfg;
798 const render =
799 session.oversample > 1
800 ? ` (display ${view.nlat}×${view.nphi})`
801 : '';
802 elStats.innerHTML =
803 `<b>${kind}</b> · grid ${nlat}×${nphi}${render} · nlm ${session.sht.nlm.toLocaleString()} · ` +
804 `solver ${solver} · ${frame} · ` +
805 `t = <b>${session.t.toFixed(2)}</b> (${session.steps} steps)`;
806}
808// ---------------------------------------------------------------- sim loop
809const nextFrame = () => new Promise<number>(requestAnimationFrame);
811async function pump(): Promise<void> {
812 if (pumping) return;
813 pumping = true;
814 const gen = generation;
815 try {
816 while (running && session && gen === generation) {
817 // Occasionally, a burst purely to measure the solver rate: many steps,
818 // one sync, nothing read back — directly comparable to the desktop
819 // benchmark's throughput number. State-preserving: the display and
820 // model time are unaffected.
821 if (performance.now() - lastMeasure > MEASURE_EVERY_MS) {
822 const ms = await session.measure(measureBurst);
823 if (gen !== generation) break;
824 solverMs = ms;
825 lastMeasure = performance.now();
826 }
828 // The frame itself. No explicit sync here — draw()'s readback already
829 // waits for the steps, so asking twice would only add a round trip.
830 const t0 = performance.now();
831 session.step(stepsPerFrame);
832 await draw();
833 if (gen !== generation) break;
834 frameMs = frameMs === 0
835 ? performance.now() - t0
836 : frameMs + 0.05 * (performance.now() - t0 - frameMs);
837 updateStats();
838 await nextFrame();
839 }
840 if (gen === generation) {
841 await draw();
842 updateStats();
843 }
844 } finally {
845 pumping = false;
846 }
847}
849/**
850 * Sustained solver benchmark, in the page.
851 *
852 * The same measurement `npm run bench` makes: batches of steps submitted
853 * together, waited for, never read back, with no rendering and no animation
854 * pacing in between. That makes it directly comparable to the terminal number,
855 * which is the only way to tell a genuinely slower browser GPU stack apart from
856 * the costs the app adds on top.
857 *
858 * It also reports the ramp — the first third of the run against the last. GPUs
859 * downclock when idle, and an animation-paced loop leaves them idle most of every
860 * frame, so a large ramp means the app's steady-state number is limited by clocks
861 * rather than by the work.
862 *
863 * These are ordinary steps: the simulation advances by them.
864 */
865async function benchmark(): Promise<void> {
866 if (!session || movieBusy) return;
867 setRunning(false);
868 // Same base size and the same DISPATCH_BUDGET scaling as the automatic
869 // measurement burst (see STEPS_PER_FRAME_BASE) — this is a user-triggered
870 // 32-step submission, exactly the shape of thing that risks a browser's
871 // GPU-process watchdog on weak hardware once niter makes a step expensive.
872 const BATCH = measureBurst;
873 const DURATION_MS = 2000;
874 elBenchResult.textContent = 'benchmarking…';
875 // A movie started mid-benchmark would replay while this loop still steps.
876 elMovie.disabled = true;
877 try {
878 await nextFrame();
880 const gen = generation;
881 const perStep: number[] = [];
882 const t0 = performance.now();
883 while (performance.now() - t0 < DURATION_MS) {
884 const b0 = performance.now();
885 session.step(BATCH);
886 await session.sync();
887 if (gen !== generation) return;
888 perStep.push((performance.now() - b0) / BATCH);
889 }
891 const mean = (xs: number[]): number => xs.reduce((a, b) => a + b, 0) / xs.length;
892 const all = mean(perStep);
893 const best = Math.min(...perStep);
894 const third = Math.max(1, Math.floor(perStep.length / 3));
895 const first = mean(perStep.slice(0, third));
896 const last = mean(perStep.slice(-third));
897 const steps = perStep.length * BATCH;
899 elBenchResult.innerHTML =
900 `sustained solver: <b>${all.toFixed(2)} ms/step</b> ` +
901 `(${(1000 / all).toFixed(0)} steps/s) · best ${best.toFixed(2)} · ` +
902 `ramp ${(first / last).toFixed(2)}× · ${steps} steps · ` +
903 `compare with <code>npm run bench -- --lmax ${session.cfg.lmax}</code>`;
904 await draw();
905 updateStats();
906 } finally {
907 elMovie.disabled = false;
908 }
909}
911// ---------------------------------------------------------------- movie
912function saveBlob(blob: Blob, filename: string): void {
913 const url = URL.createObjectURL(blob);
914 const a = document.createElement('a');
915 a.href = url;
916 a.download = filename;
917 a.click();
918 setTimeout(() => URL.revokeObjectURL(url), 10_000);
919}
921/** Submit `n` steps in bounded command buffers — a single buffer encoding
922 * many thousands of steps can exhaust the encoder. */
923function submitSteps(n: number): void {
924 while (n > 0 && session) {
925 const chunk = Math.min(512, n);
926 session.step(chunk);
927 n -= chunk;
928 }
929}
931/** While recording, lock everything that could change the run mid-replay;
932 * the Movie button itself becomes the cancel button. */
933function setMovieUi(on: boolean): void {
934 const locked = [
935 elModel, elGeometry, elMorph, elNiter, elLmax, elOversample, elColormap,
936 elRunPause, elBenchmark, elReseed, elRecompile, elRevert, elEditorFile,
937 elMovieSpeed, elMovieRes, elMovieRotate, elMovieToggle,
938 ];
939 for (const el of locked) el.disabled = on;
940 elParams.querySelectorAll('input').forEach((input) => (input.disabled = on));
941 elGeomParams.querySelectorAll('input').forEach((input) => (input.disabled = on));
942 elMovie.textContent = on ? 'Cancel · 0%' : 'Export';
943}
945/**
946 * Recompute the run from t = 0 and download it as an MP4.
947 *
948 * The movie is not a recording of what already happened — it is the same
949 * trajectory recomputed: same seed, same source, and the *current* parameters
950 * and colormap throughout. Determinism makes this exact: after the replay the
951 * state is where it was, so the one session is reused and the app resumes as
952 * if nothing happened. Frames are composited from the live panels, so the
953 * movie shows the spheres at the current camera orientation — and the replay
954 * doubles as the progress display, since it is visible on screen.
955 */
956async function recordMovie(): Promise<void> {
957 if (!session || movieBusy) return;
958 if (session.steps === 0) {
959 elMovie.textContent = 'run first';
960 setTimeout(() => (elMovie.textContent = 'Export'), 1200);
961 return;
962 }
963 movieBusy = true;
964 movieCancel = false;
965 const gen = generation;
966 setMovieUi(true);
967 let wasRunning = false;
968 let total = 0;
969 let done = 0;
970 let seeded = false;
971 let camBefore: ReturnType<SphereScene['cameraState']> | undefined;
972 try {
973 // An in-flight display-grid swap replaces the scenes whose canvases the
974 // recorder captures, and resumes the run when it lands — let it finish.
975 await viewChange;
976 if (gen !== generation || !session) return;
977 wasRunning = running;
978 setRunning(false);
979 while (pumping) await nextFrame(); // let an in-flight live frame drain
980 if (gen !== generation || !session) return;
981 total = session.steps;
982 const speed = Number(elMovieSpeed.value) || 10;
983 const sphere = Number(elMovieRes.value) || 768;
984 const rotate = elMovieRotate.checked;
985 if (rotate) camBefore = scenes[0]?.cameraState();
986 // Render the scenes at exactly the chosen resolution for the recording —
987 // independent of the window size — and restore afterwards.
988 for (const s of scenes) s.captureSize(sphere);
989 const durationS = Math.max(session.t / speed, 2 / MOVIE_FPS);
990 const frames = Math.max(
991 2,
992 Math.min(Math.round(durationS * MOVIE_FPS) + 1, total + 1, MOVIE_MAX_FRAMES),
993 );
994 /** The step index captured as frame `i`; both endpoints land exactly. */
995 const stepAt = (i: number): number => Math.round((i * total) / (frames - 1));
997 const title =
998 (presets.find((p) => p.key === elModel.value)?.label ?? model.label) +
999 ` on ${geometry.label.toLowerCase()}` +
1000 (editedSource !== null || editedGeomSource !== null ? ' (edited)' : '');
1001 const subtitle = model.params
1002 .map((spec) => `${spec.label} ${fmtValue(params[spec.key])}`)
1003 .join(' · ');
1004 const rec = await MovieRecorder.create({
1005 panels: model.species.map((label, k) => ({ canvas: scenes[k].canvas, label })),
1006 title,
1007 subtitle,
1008 speed,
1009 fps: (frames - 1) / durationS,
1010 sphere,
1011 });
1013 let finished = false;
1014 try {
1015 // Reset the color-range smoothing as a re-seed does, so the shading
1016 // evolves in the movie the way it did live.
1017 session.seed(seed);
1018 seeded = true;
1019 for (const r of ranges) {
1020 r.lo = NaN;
1021 r.hi = NaN;
1022 }
1023 const cmap = colormaps[elColormap.value] ?? colormaps.viridis;
1024 let lastVideoS = 0;
1025 for (let frame = 0; ; ) {
1026 await draw();
1027 if (gen !== generation) return;
1028 if (movieCancel) break;
1029 if (rotate) {
1030 // Advance the orbit by this frame's share of video time; siblings
1031 // follow scenes[0] through the usual camera sync.
1032 const videoS = session.t / speed;
1033 scenes[0]?.orbitBy(2 * Math.PI * MOVIE_ROTATE_RPS * (videoS - lastVideoS));
1034 lastVideoS = videoS;
1035 }
1036 for (const s of scenes) s.renderNow();
1037 await rec.addFrame(
1038 session.t,
1039 model.species.map((_, k) => ({ cmap, lo: ranges[k].lo, hi: ranges[k].hi })),
1040 );
1041 if (++frame >= frames) {
1042 finished = true;
1043 break;
1044 }
1045 const target = stepAt(frame);
1046 submitSteps(target - done);
1047 done = target;
1048 elMovie.textContent = `Cancel · ${Math.round((100 * done) / total)}%`;
1049 }
1050 if (finished) {
1051 const blob = await rec.finish();
1052 saveBlob(
1053 blob,
1054 `turing-surface-${model.key}-${geometry.key}-` +
1055 `t${session.t.toFixed(2)}-${speed}x.mp4`,
1056 );
1057 }
1058 } finally {
1059 if (!finished) rec.cancel();
1060 }
1061 } catch (e) {
1062 elErr.textContent = `movie: ${e instanceof Error ? e.message : e}`;
1063 } finally {
1064 // A cancelled replay stopped short of where the run was; step the
1065 // remainder — determinism makes this land exactly there.
1066 if (seeded && gen === generation && session) {
1067 while (done < total && gen === generation && session) {
1068 const n = Math.min(4096, total - done);
1069 submitSteps(n);
1070 done += n;
1071 elMovie.textContent = `restoring · ${Math.round((100 * done) / total)}%`;
1072 await session.sync();
1073 }
1074 await draw();
1075 updateStats();
1076 }
1077 if (gen === generation) {
1078 for (const s of scenes) s.restoreSize();
1079 }
1080 if (camBefore && gen === generation) {
1081 for (const s of scenes) s.setCameraState(camBefore);
1082 }
1083 movieBusy = false;
1084 setMovieUi(false);
1085 if (gen === generation) setRunning(wasRunning);
1086 }
1087}
1089// ---------------------------------------------------------------- compare
1090/**
1091 * Comparing several solver settings at once.
1092 *
1093 * Deliberately a mode rather than a widening of the ordinary controls: the
1094 * single-run path above is untouched, and with the bar closed nothing about
1095 * using this page has changed. Opening it and pressing Compare tears down the
1096 * one session and hands the panels area to a CompareRun, which owns a session
1097 * per variant; pressing it again puts the single run back.
1098 *
1099 * The ceilings below are not arbitrary. Each variant compiles its whole
1100 * unrolled step with no pipeline cache between sessions (a solve iteration is
1101 * ~15 kernels per species), so the variant count is what you wait for; and
1102 * each panel is a WebGL context and a full mesh, so the panel count is what
1103 * the browser has to keep alive at once.
1104 */
1105const MAX_VARIANTS = 6;
1106const MAX_PANELS = 12;
1107/** dt divisors. Powers of two so that dtBase/K is exact in binary and every
1108 * variant lands on the same model time with no accumulated drift. */
1109const DT_DIVISORS = [1, 2, 4, 8];
1111/**
1112 * What the bar opens on: the default iteration count against the next step up,
1113 * at the default band. Two variants, so the first study is quick to compile,
1114 * and it asks the question the control exists for — is the default already
1115 * converged? A flat, low curve says yes; one that climbs says the answer is
1116 * still moving at niter 8 and the default is not enough for this shape.
1117 */
1118const cmpSelected = {
1119 niter: new Set<number>([DEFAULT_NITER, 2 * DEFAULT_NITER]),
1120 lmax: new Set<number>([63]),
1121 dt: new Set<number>([1]),
1122};
1124/** A row of toggle chips backed by a Set. At least one stays selected — an
1125 * empty axis has no meaning here, and silently falling back to a default
1126 * would hide which values are actually being run. */
1127function buildChips(host: HTMLElement, values: number[], selected: Set<number>, label: (v: number) => string): void {
1128 host.replaceChildren();
1129 for (const value of values) {
1130 const chip = document.createElement('button');
1131 chip.type = 'button';
1132 chip.className = 'chip';
1133 chip.textContent = label(value);
1134 const paint = (): void => chip.setAttribute('aria-pressed', String(selected.has(value)));
1135 paint();
1136 chip.addEventListener('click', () => {
1137 if (selected.has(value)) {
1138 if (selected.size === 1) return;
1139 selected.delete(value);
1140 } else {
1141 selected.add(value);
1142 }
1143 paint();
1144 refreshVariants();
1145 });
1146 host.append(chip);
1147 }
1148}
1150const cmpVariants = (): Variant[] =>
1151 crossProduct([...cmpSelected.niter], [...cmpSelected.lmax], [...cmpSelected.dt]);
1153/** The reference the user picked, clamped to the current variant list. */
1154let cmpRefKey = '';
1156/** Index of the reference in the current variant list, never negative. */
1157function compareRefIndex(): number {
1158 const i = cmpVariants().map(variantKey).indexOf(cmpRefKey);
1159 return i < 0 ? 0 : i;
1160}
1162function refreshVariants(): void {
1163 const variants = cmpVariants();
1164 const showDt = cmpSelected.dt.size > 1;
1165 const panels = variants.length * model.species.length;
1167 const prev = cmpRefKey;
1168 elCmpRef.replaceChildren();
1169 for (const v of variants) {
1170 const o = document.createElement('option');
1171 o.value = variantKey(v);
1172 o.textContent = variantLabel(v, showDt);
1173 elCmpRef.append(o);
1174 }
1175 const keys = variants.map(variantKey);
1176 cmpRefKey = keys.includes(prev) ? prev : keys[mostResolved(variants)];
1177 elCmpRef.value = cmpRefKey;
1179 const tooMany =
1180 variants.length > MAX_VARIANTS
1181 ? `${variants.length} variants — at most ${MAX_VARIANTS}`
1182 : panels > MAX_PANELS
1183 ? `${panels} panels — at most ${MAX_PANELS}`
1184 : '';
1185 elCmpCount.textContent = tooMany
1186 ? `too many: ${tooMany}`
1187 : `${variants.length} variants × ${model.species.length} species = ${panels} panels`;
1188 elCmpCount.style.color = tooMany ? '#b35900' : '';
1189 elCmpStart.disabled = tooMany !== '' && compareRun === null;
1190}
1192buildChips(
1193 elCmpNiter,
1194 [...elNiter.options].map((o) => Number(o.value)),
1195 cmpSelected.niter,
1196 String,
1197);
1198buildChips(
1199 elCmpLmax,
1200 [...elLmax.options].map((o) => Number(o.value)),
1201 cmpSelected.lmax,
1202 String,
1203);
1204buildChips(elCmpDt, DT_DIVISORS, cmpSelected.dt, (v) => (v === 1 ? 'dt' : `dt/${v}`));
1205refreshVariants();
1207elCmpRef.addEventListener('change', () => {
1208 cmpRefKey = elCmpRef.value;
1209 if (compareRun) void rebuildCompare();
1210});
1212elCompareToggle.addEventListener('click', () => {
1213 elCompareBar.hidden = !elCompareBar.hidden;
1214});
1216elCmpStart.addEventListener('click', () => {
1217 if (compareRun) void stopCompare();
1218 else void startCompare();
1219});
1221/** Controls the study supersedes or cannot honour while it is running. */
1222function setCompareUi(on: boolean): void {
1223 for (const el of [elNiter, elLmax, elOversample, elBenchmark, elMovieToggle]) {
1224 el.disabled = on;
1225 }
1226 elCmpNiter.querySelectorAll('button').forEach((b) => (b.disabled = on));
1227 elCmpLmax.querySelectorAll('button').forEach((b) => (b.disabled = on));
1228 elCmpDt.querySelectorAll('button').forEach((b) => (b.disabled = on));
1229 elCmpStart.textContent = on ? 'Stop comparing' : 'Compare';
1230 elCompareToggle.textContent = on ? 'Comparing' : 'Compare';
1231 if (on) elMovieBar.hidden = true;
1232}
1234async function startCompare(): Promise<void> {
1235 if (compareRun || !device) return;
1236 const variants = cmpVariants();
1237 if (variants.length > MAX_VARIANTS || variants.length * model.species.length > MAX_PANELS) {
1238 return;
1239 }
1240 // Take down the single run first: its pump, its scenes, its session. The
1241 // generation bump makes any readback already in flight drop its result.
1242 generation++;
1243 setRunning(false);
1244 while (pumping) await nextFrame();
1245 disposeView();
1246 session?.destroy();
1247 session = null;
1248 elBenchResult.textContent = '';
1249 elErr.textContent = '';
1250 setCompareUi(true);
1252 try {
1253 compareRun = await CompareRun.create({
1254 device,
1255 model,
1256 params,
1257 source: source(),
1258 geometry,
1259 geometryParams: geomParams,
1260 geometrySource: geomSource(),
1261 variants,
1262 reference: compareRefIndex(),
1263 seed,
1264 morph,
1265 colormapName: () => elColormap.value,
1266 container: elPanels,
1267 onStatus: (html) => (elStats.innerHTML = html),
1268 });
1269 } catch (e) {
1270 compareRun = null;
1271 setCompareUi(false);
1272 refreshVariants();
1273 reportCompileError(e);
1274 await rebuild();
1275 return;
1276 }
1277 updateGeomNote();
1278 // The command describes the reference variant, which only exists now.
1279 updateCommand();
1280 elRunPause.textContent = 'Run';
1281}
1283async function stopCompare(): Promise<void> {
1284 if (!compareRun) return;
1285 compareRun.dispose();
1286 compareRun = null;
1287 setCompareUi(false);
1288 refreshVariants();
1289 elStats.textContent = '';
1290 await rebuild();
1291}
1293/** Rebuild the study in place — after a model, geometry, source or reference
1294 * change. Same teardown as stopping, without leaving the mode. */
1295async function rebuildCompare(): Promise<void> {
1296 if (!compareRun) return;
1297 compareRun.dispose();
1298 compareRun = null;
1299 setCompareUi(false);
1300 await startCompare();
1301}
1303// ---------------------------------------------------------------- boot
1304async function boot(): Promise<void> {
1305 elModel.value = presets[0].key;
1306 // The iteration count is one default shared with the benchmark, like the
1307 // rest of the RunSpec's — take it from there rather than from the markup, so
1308 // the page and `npm run bench` cannot start out disagreeing about it.
1309 elNiter.value = String(DEFAULT_NITER);
1310 elGeometry.value = DEFAULT_GEOMETRY_KEY;
1311 elMorph.value = String(morph);
1312 applyGeometryChoice(DEFAULT_GEOMETRY_KEY);
1313 applyPreset(presets[0].key);
1314 try {
1315 device = await requestShtDevice();
1316 adapterName = await describeAdapter(device);
1317 } catch (e) {
1318 device = null;
1319 elErr.textContent =
1320 `WebGPU is not available (${e instanceof Error ? e.message : e}). ` +
1321 `Use a WebGPU-capable browser such as Chrome or Edge.`;
1322 return;
1323 }
1324 device.lost.then((info) => {
1325 if (info.reason !== 'destroyed') {
1326 elErr.textContent = `WebGPU device lost: ${info.message}`;
1327 }
1328 });
1329 await rebuild();
1330}
1332void boot();