/ concept-collection / turing-surface
Sign in
concept-collection / turing-surface
1634 lines · 59.9 KBBlameHistoryRaw
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 DEFAULT_GEOMETRY_KEY,
21 type MGeometry,
22} from './geom/registry.ts';
23import {
24 buildTopology,
25 fillFieldValues,
26 fillPositions,
27 fillColors,
28 type SphereMeshTopology,
29} from './render/sphereMesh.ts';
30import { SphereScene } from './render/SphereScene.ts';
31import { Colorbar, fmtValue, floorRange } from './render/colorbar.ts';
32import { colormaps, colormapNames } from './render/colormaps.ts';
33import { MovieRecorder } from './render/movie.ts';
34import { CompareRun } from './compare/compareRun.ts';
35import {
36 crossProduct,
37 mostResolved,
38 variantKey,
39 variantLabel,
40 type Variant,
41} from './compare/variants.ts';
42import { loadReferenceFile } from './compare/referenceFile.ts';
43import type { ReferenceCase } from './compare/referenceCase.ts';
45const $ = <T extends HTMLElement>(id: string): T =>
46 document.getElementById(id) as T;
48const elModel = $<HTMLSelectElement>('model');
49const elGeometry = $<HTMLSelectElement>('geometry');
50const elMorph = $<HTMLInputElement>('morph');
51const elNiter = $<HTMLSelectElement>('niter');
52const elLmax = $<HTMLSelectElement>('lmax');
53const elOversample = $<HTMLSelectElement>('oversample');
54const elColormap = $<HTMLSelectElement>('colormap');
55const elRunPause = $<HTMLButtonElement>('runpause');
56const elBenchmark = $<HTMLButtonElement>('benchmark');
57const elReseed = $<HTMLButtonElement>('reseed');
58const elLam3 = $<HTMLInputElement>('lam3');
59const elResetView = $<HTMLButtonElement>('resetview');
60const elMovieToggle = $<HTMLButtonElement>('movietoggle');
61const elMovieBar = $('moviebar');
62const elMovieSpeed = $<HTMLSelectElement>('moviespeed');
63const elMovieRes = $<HTMLSelectElement>('movieres');
64const elMovieRotate = $<HTMLInputElement>('movierotate');
65const elMovie = $<HTMLButtonElement>('movie');
66const elModeSimulate = $<HTMLButtonElement>('mode-simulate');
67const elModeEffort = $<HTMLButtonElement>('mode-effort');
68const elModeVsUpload = $<HTMLButtonElement>('mode-vs-upload');
69const elCompareBar = $('comparebar');
70const elCmpNiter = $('cmp-niter');
71const elCmpLmax = $('cmp-lmax');
72const elCmpDt = $('cmp-dt');
73const elCmpRef = $<HTMLSelectElement>('cmp-ref');
74const elCmpFile = $<HTMLInputElement>('cmp-file');
75const elCmpFileInfo = $('cmp-fileinfo');
76const elCmpFileClear = $<HTMLButtonElement>('cmp-fileclear');
77const elCmpStart = $<HTMLButtonElement>('cmp-start');
78const elCmpCount = $('cmp-count');
79const elParams = $('params');
80const elGeomParams = $('geomparams');
81const elGeomNote = $('geomnote');
82const elPanels = $('panels');
83const elStats = $('stats');
84const elBenchResult = $('benchresult');
85const elCmd = $('cmd');
86const elCopyCmd = $<HTMLButtonElement>('copycmd');
87const elBlurb = $('blurb');
88const elErr = $('err');
89const elSource = $<HTMLTextAreaElement>('source');
90const elHighlight = $('highlight');
91const elCompiled = $('compiled');
92const elEditorTitle = $('editor-title');
93const elEditorFile = $<HTMLSelectElement>('editor-file');
94const elRecompile = $<HTMLButtonElement>('recompile');
95const elRevert = $<HTMLButtonElement>('revert');
97/** The named groups the control area is organized into (index.html's
98 * `.ctrl-group[data-group]` wrappers). Each mode shows a declared subset of
99 * these — see MODE_GROUPS and applyModeVisibility below. */
100const GROUP_NAMES = [
101 'surface', 'surface-params', 'solver', 'display',
102 'playback', 'benchmark', 'seed', 'movie',
103] as const;
104type GroupName = (typeof GROUP_NAMES)[number];
105const groupEls: Record<GroupName, HTMLElement> = Object.fromEntries(
106 GROUP_NAMES.map((name) => [
107 name,
108 document.querySelector(`.ctrl-group[data-group="${name}"]`) as HTMLElement,
109 ]),
110) as Record<GroupName, HTMLElement>;
112for (const p of presets) {
113 const o = document.createElement('option');
114 o.value = p.key;
115 o.textContent = p.label;
116 elModel.append(o);
118for (const g of mGeometries) {
119 const o = document.createElement('option');
120 o.value = g.key;
121 o.textContent = g.label;
122 elGeometry.append(o);
124for (const [value, label] of [['model', 'the solver'], ['geometry', 'the surface']]) {
125 const o = document.createElement('option');
126 o.value = value;
127 o.textContent = label;
128 elEditorFile.append(o);
130for (const name of colormapNames) {
131 const o = document.createElement('option');
132 o.value = name;
133 o.textContent = name;
134 elColormap.append(o);
136elColormap.value = 'jet';
138/** Whichever .m is open: the solver or the surface. Both are MATLAB, compiled
139 * by the same backend, so one editor serves both. The host-provided operations
140 * are marked so the boundary between the file and what it is given is
141 * visible. */
142const editor = new CodeEditor({
143 textarea: elSource,
144 overlay: elHighlight,
145 external: EXTERNAL_OPS,
146 onInput: (value) => {
147 if (editing === 'geometry') editedGeomSource = value;
148 else editedSource = value;
149 elRecompile.textContent = 'Recompile *';
150 },
151});
153/**
154 * Timesteps submitted per rendered frame, at most. Nothing is read back
155 * between them, so the batch costs one submit and one readback regardless of
156 * size — but a compute pass is still real GPU work, and a browser's GPU
157 * process enforces a watchdog timeout a headless desktop run does not: a
158 * submission with enough dispatches in it can trip "device lost" outright,
159 * on weak-enough hardware, well before it would ever show up as merely slow.
160 * The `for k = 1:niter` correction loop makes a step's dispatch count scale
161 * with niter (each iteration is ~15 dispatches per species — see
162 * models/schnakenberg.m), so a fixed per-frame step count that was safe when
163 * every model's step was a handful of dispatches is not safe once niter is
164 * large. `stepsPerFrame`/`measureBurst` below scale it down — never up, so
165 * the common case does not change — to keep one submission's total dispatch
166 * count under DISPATCH_BUDGET regardless of how expensive the compiled step
167 * is.
168 */
169const STEPS_PER_FRAME_BASE = 4;
170/** See STEPS_PER_FRAME_BASE. Recomputed per rebuild in `rebuild()`. */
171let stepsPerFrame = STEPS_PER_FRAME_BASE;
173/**
174 * Steps in a solver-timing burst, and how often to run one.
175 *
176 * Timing the solver needs a `queue.onSubmittedWorkDone()` to know the work
177 * finished, and in a browser that is an IPC round trip into the GPU process — a
178 * fixed cost of a few milliseconds. Spread over one frame's four steps it would
179 * swamp them on a fast GPU and make the solver look far slower than it is. So the
180 * rate is measured in an occasional larger batch, where the single sync is
181 * amortized the way the desktop benchmark amortizes its own. The state is
182 * snapshotted and restored around the batch, so measuring never advances the
183 * simulation — otherwise the pattern would visibly lurch forward at every
184 * measurement.
185 */
186const MEASURE_BURST_BASE = 32;
187/** See STEPS_PER_FRAME_BASE — the measurement burst is one submission too,
188 * and a bigger one: 32 steps is the single largest batch this app ever
189 * submits, so it is the first thing to cross DISPATCH_BUDGET as niter grows. */
190let measureBurst = MEASURE_BURST_BASE;
191const MEASURE_EVERY_MS = 2000;
193/**
194 * Upper bound on dispatches in one submission — the frame batch and the
195 * measurement burst are both scaled down to stay under this, never up, so
196 * a cheap model's pacing is unchanged. Chosen well under what this project's
197 * own desktop benchmark measures as trivially fast (single-digit ms even at
198 * niter=8's ~450 dispatches/step), because the risk here is not GPU time on
199 * capable hardware — it is a browser's GPU-process watchdog on weak
200 * (integrated-graphics) hardware, which a headless desktop run never
201 * exercises and this project has no way to benchmark directly.
202 */
203const DISPATCH_BUDGET = 1000;
205/**
206 * 'auto' display oversampling targets this many render latitudes: the factor is
207 * the smallest power of two (up to 4) that reaches it. A solver grid already
208 * this fine gains nothing visually and is not oversampled.
209 */
210const AUTO_RENDER_NLAT = 256;
212/** The display oversampling factor the UI currently asks for. */
213function resolveOversample(): number {
214 if (elOversample.value !== 'auto') return Number(elOversample.value);
215 const { nlat } = gridForLmax(Number(elLmax.value), model.pdeg);
216 let os = 1;
217 while (os < 4 && os * nlat < AUTO_RENDER_NLAT) os *= 2;
218 return os;
221/**
222 * Movie frame rate, and a cap on frames per movie. Playback speed comes from
223 * the UI, in simulation-time units per second of video; the movie's length is
224 * the run's t at that speed, and the frame count follows from it — capped by
225 * the run's own step count (a step is at most one frame) and by
226 * MOVIE_MAX_FRAMES to bound encode time and file size. Frame timestamps are
227 * derived from simulation time, so a capped movie keeps its duration and
228 * speed exactly, at a lower effective frame rate.
229 */
230const MOVIE_FPS = 30;
231const MOVIE_MAX_FRAMES = 3600;
233/** Movie auto-rotation: camera revolutions per second of video. Measured in
234 * video time, so the orbit pace on screen is the same at every export speed. */
235const MOVIE_ROTATE_RPS = 1 / 120;
237// ---------------------------------------------------------------- state
238let device: GPUDevice | null = null;
239let session: ModelSession | null = null;
240let topo: SphereMeshTopology | null = null;
241let scenes: SphereScene[] = [];
242let colorbars: Colorbar[] = [];
243let valueBufs: Float32Array[] = [];
244let colorBufs: Float32Array[] = [];
245let ranges: { lo: number; hi: number }[] = [];
246let resizeObs: ResizeObserver | null = null;
248const initial = resolvePreset(presets[0].key);
249let model: MModel = mModelByKey(initial.model.key)!;
250let params: Params = initial.params;
251let geometry: MGeometry = mGeometryByKey(DEFAULT_GEOMETRY_KEY)!;
252let geomParams: Params = defaultGeometryParams(geometry);
253/** Which file the editor is showing. */
254let editing: 'model' | 'geometry' = 'model';
255/** Each .m as edited in the page; `null` while it matches the file. */
256let editedSource: string | null = null;
257let editedGeomSource: string | null = null;
258/** Sphere (0) to surface (1). Display only; does not touch the solver. */
259let morph = 1;
260let seed = 1;
261let running = false;
262let adapterName = '';
263let pumping = false;
264let movieBusy = false;
265let movieCancel = false;
266let solverMs = 0;
267let frameMs = 0;
268let lastMeasure = 0;
269let generation = 0; // bumped on every rebuild to cancel stale pumps
270/** Surface coordinates on the render grid, interleaved xyz; null before the
271 * first build. Kept so the morph slider can re-fill positions without
272 * re-synthesizing. */
273let coords: Float32Array | null = null;
274let posBuf: Float32Array | null = null;
275/** The convergence study, when one is running; null in ordinary single-run
276 * mode. While it is non-null there is no `session`: the study owns one per
277 * variant, and the panels area is its grid. */
278let compareRun: CompareRun | null = null;
280const source = (): string => editedSource ?? model.source;
281const geomSource = (): string => editedGeomSource ?? geometry.source;
283// ---------------------------------------------------------------- UI wiring
284function buildParamInputs(): void {
285 elParams.replaceChildren();
286 if (model.params.length === 0) return;
287 const tag = document.createElement('label');
288 tag.textContent = 'model parameters';
289 elParams.append(tag);
290 for (const spec of model.params) {
291 const label = document.createElement('label');
292 label.textContent = `${spec.label} `;
293 const input = document.createElement('input');
294 input.type = 'number';
295 input.min = String(spec.min);
296 input.max = String(spec.max);
297 input.step = String(spec.step);
298 input.value = String(params[spec.key]);
299 input.addEventListener('change', () => {
300 const v = Number(input.value);
301 if (Number.isFinite(v)) params[spec.key] = v;
302 // Parameters are uniforms, not constants baked into the kernels, so a
303 // change costs an upload rather than a recompile. In compare mode `dt`
304 // is the *base* timestep each variant's divisor divides, so the study
305 // re-derives every variant's dt from it.
306 session?.setParams(params);
307 compareRun?.setParams(params);
308 updateCommand();
309 });
310 label.append(input);
311 elParams.append(label);
312 }
315/**
316 * The shape's own parameters. Unlike the model's, these are NOT uniforms: the
317 * surface is evaluated once at build time and reduced to coefficients, so
318 * moving one rebuilds the geometry (and with it the mesh), though not the
319 * simulation's compiled step.
320 */
321function buildGeomParamInputs(): void {
322 elGeomParams.replaceChildren();
323 if (geometry.params.length === 0) return;
324 const tag = document.createElement('label');
325 tag.textContent = 'geometry parameters';
326 elGeomParams.append(tag);
327 for (const spec of geometry.params) {
328 // A random seed picks a draw and means nothing on its own, so it gets a
329 // button to the next one rather than a box to type a number into. The
330 // shape changes; the simulation running on it does not restart.
331 if (spec.reseed) {
332 const button = document.createElement('button');
333 button.textContent = 'Re-seed shape';
334 button.title =
335 `Draw another ${geometry.label.toLowerCase()} — a new random surface, ` +
336 `leaving the pattern running on it alone.`;
337 button.addEventListener('click', () => {
338 const span = spec.max - spec.min;
339 let next = geomParams[spec.key];
340 // Never hand back the shape that is already on screen.
341 while (next === geomParams[spec.key]) {
342 next = spec.min + Math.floor(Math.random() * (span + 1));
343 }
344 geomParams[spec.key] = next;
345 viewChange = viewChange.then(() => applyGeometry());
346 });
347 elGeomParams.append(button);
348 continue;
349 }
350 const label = document.createElement('label');
351 label.textContent = `${spec.label} `;
352 const input = document.createElement('input');
353 input.type = 'number';
354 input.min = String(spec.min);
355 input.max = String(spec.max);
356 input.step = String(spec.step);
357 input.value = String(geomParams[spec.key]);
358 input.addEventListener('change', () => {
359 const v = Number(input.value);
360 if (Number.isFinite(v)) geomParams[spec.key] = v;
361 viewChange = viewChange.then(() => applyGeometry());
362 });
363 label.append(input);
364 elGeomParams.append(label);
365 }
368function applyPreset(presetKey: string): void {
369 const resolved = resolvePreset(presetKey);
370 const next = mModelByKey(resolved.model.key);
371 if (!next) {
372 elErr.textContent = `No .m model for '${resolved.model.key}'`;
373 return;
374 }
375 model = next;
376 params = resolved.params;
377 editedSource = null;
378 buildParamInputs();
379 elBlurb.textContent = model.blurb;
380 showEditorFile();
381 updateCommand();
384function applyGeometryChoice(key: string): void {
385 const next = mGeometryByKey(key);
386 if (!next) {
387 elErr.textContent = `No .m geometry for '${key}'`;
388 return;
389 }
390 geometry = next;
391 geomParams = defaultGeometryParams(geometry);
392 editedGeomSource = null;
393 buildGeomParamInputs();
394 showEditorFile();
397/** Load the chosen file into the editor, keeping any unsaved edit to it. */
398function showEditorFile(): void {
399 editing = elEditorFile.value === 'geometry' ? 'geometry' : 'model';
400 if (editing === 'geometry') {
401 editor.value = geomSource();
402 elEditorTitle.textContent = `geometries/${geometry.key}.m`;
403 } else {
404 editor.value = source();
405 elEditorTitle.textContent = `models/${model.key}.m`;
406 }
409/**
410 * The run currently on screen, as the benchmark's RunSpec. While a study is
411 * running there is no single run, so this describes its *reference* variant —
412 * the one the other rows are measured against, and the only one of them whose
413 * numbers mean anything on their own.
414 */
415function currentSpec(): RunSpec {
416 const ref = compareRun?.variants[compareRefIndex()];
417 const dt = ref ? { dt: (params.dt ?? 0) / ref.dtDiv } : null;
418 return {
419 preset: elModel.value,
420 lmax: ref ? ref.lmax : Number(elLmax.value),
421 seed,
422 steps: DEFAULT_STEPS,
423 warmup: DEFAULT_WARMUP,
424 params: dt ? { ...params, ...dt } : params,
425 geometry: geometry.key,
426 geometryParams: geomParams,
427 niter: ref ? ref.niter : Number(elNiter.value),
428 };
431function updateCommand(): void {
432 // A study against a reference file replays the file, so its desktop
433 // equivalent is the ref checker, not the benchmark.
434 if (compareRun?.refFile) {
435 elCmd.textContent = `npm run ref -- --in ${compareRun.refFile.label}`;
436 return;
437 }
438 elCmd.textContent = formatCommand(currentSpec());
441elModel.addEventListener('change', () => {
442 applyPreset(elModel.value);
443 void rebuild();
444});
445elLmax.addEventListener('change', () => void rebuild());
446// The solve iteration count is unrolled into the compiled step, so unlike a
447// parameter it cannot be changed without recompiling.
448elNiter.addEventListener('change', () => void rebuild());
449// Oversampling and geometry are display-or-data changes, not code ones, so
450// they swap things in place rather than rebuilding the run. Serialized through
451// one chain: a rapid second change waits its turn.
452let viewChange = Promise.resolve();
453elOversample.addEventListener('change', () => {
454 // The study picks its own display grid — one grid common to every variant is
455 // what makes their fields comparable — so this control is inert (and
456 // disabled) while one is running.
457 if (compareRun) return;
458 viewChange = viewChange.then(() => applyOversample());
459});
460elGeometry.addEventListener('change', () => {
461 applyGeometryChoice(elGeometry.value);
462 viewChange = viewChange.then(() => applyGeometry());
463});
464// The seed field's wavelength: a uniform plus a host-side redraw, so it
465// reseeds the run in place rather than recompiling it. Too small a value asks
466// for more Fourier modes than the table holds, which `drawModes` refuses —
467// report that like any other failure instead of leaving the run half-seeded.
468elLam3.addEventListener('change', () => {
469 const v = Number(elLam3.value);
470 if (!Number.isFinite(v) || v <= 0) return;
471 // Changing the wavelength redraws the field, which restarts the run — so
472 // pause first, exactly as the Re-seed button does. Without it the reseed's
473 // readback races the pump's own, and the two collide on the staging buffer.
474 setRunning(false);
475 viewChange = viewChange.then(async () => {
476 // A study seeds every variant from one field at one wavelength, so this is
477 // the same control there — set on each variant, redrawn by the one reseed.
478 const target = compareRun ?? session;
479 if (!target) return;
480 const previous = target.lam3;
481 try {
482 target.setLam3(v);
483 await reseed();
484 elErr.textContent = '';
485 } catch (e) {
486 // Too fine a wavelength asks for more Fourier modes than the table
487 // holds. Put the working value back rather than leaving the run seeded
488 // from a field that was never drawn.
489 elErr.textContent = e instanceof Error ? e.message : String(e);
490 target.setLam3(previous);
491 elLam3.value = String(previous);
492 await reseed();
493 }
494 });
495});
496// Morph is pure rendering: no readback, no GPU work, just the vertex buffer.
497elMorph.addEventListener('input', () => {
498 morph = Number(elMorph.value);
499 if (compareRun) compareRun.setMorph(morph);
500 else applyMorph();
501});
502elColormap.addEventListener('change', () => {
503 if (compareRun) void compareRun.draw();
504 else void draw();
505});
506elEditorFile.addEventListener('change', () => showEditorFile());
508function setRunning(next: boolean): void {
509 running = next;
510 elRunPause.textContent = running ? 'Pause' : 'Run';
511 if (compareRun) {
512 compareRun.setRunning(next);
513 return;
514 }
515 if (running) void pump();
518elRunPause.addEventListener('click', () => setRunning(!running));
519elBenchmark.addEventListener('click', () => void benchmark());
520elReseed.addEventListener('click', () => {
521 seed = (Math.random() * 2 ** 31) >>> 0;
522 setRunning(false);
523 updateCommand();
524 void reseed();
525});
526elResetView.addEventListener('click', () => {
527 compareRun?.resetView();
528 for (const s of scenes) s.resetCamera();
529});
530elMovieToggle.addEventListener('click', () => {
531 elMovieBar.hidden = !elMovieBar.hidden;
532});
533elMovie.addEventListener('click', () => {
534 if (movieBusy) movieCancel = true;
535 else void recordMovie();
536});
538elRecompile.addEventListener('click', () => {
539 if (editing === 'geometry') editedGeomSource = editor.value;
540 else editedSource = editor.value;
541 void rebuild();
542});
543elRevert.addEventListener('click', () => {
544 if (editing === 'geometry') editedGeomSource = null;
545 else editedSource = null;
546 showEditorFile();
547 void rebuild();
548});
550// The command reproduces this run's parameters on the desktop; keep it
551// selectable even where the clipboard API is unavailable.
552elCopyCmd.addEventListener('click', () => {
553 const text = elCmd.textContent ?? '';
554 const flash = (msg: string): void => {
555 elCopyCmd.textContent = msg;
556 setTimeout(() => (elCopyCmd.textContent = 'Copy'), 1200);
557 };
558 const selectCommand = (): void => {
559 const range = document.createRange();
560 range.selectNodeContents(elCmd);
561 const sel = getSelection();
562 sel?.removeAllRanges();
563 sel?.addRange(range);
564 flash('Selected');
565 };
566 if (!navigator.clipboard) return selectCommand();
567 navigator.clipboard.writeText(text).then(() => flash('Copied'), selectCommand);
568});
570// ---------------------------------------------------------------- setup
571function disposeView(): void {
572 for (const s of scenes) s.dispose();
573 scenes = [];
574 colorbars = [];
575 topo = null;
576 coords = null;
577 posBuf = null;
578 resizeObs?.disconnect();
579 resizeObs = null;
580 elPanels.replaceChildren();
583/**
584 * Build the mesh, scenes, colorbars and per-species buffers on the current
585 * render grid, from surface coordinates already synthesized there. Call
586 * disposeView() first. The color ranges are kept if present, so a display-only
587 * rebuild (an oversampling change) does not pop the shading; a full rebuild
588 * clears `ranges` beforehand.
589 */
590function buildView(surface: Float32Array): void {
591 if (!session) return;
592 const view = session.viewSht;
593 const { nphi } = view.cfg;
594 const phi = new Float64Array(nphi);
595 for (let j = 0; j < nphi; j++) phi[j] = (2 * Math.PI * j) / nphi;
596 topo = buildTopology(view.cosTheta, phi);
597 coords = surface;
598 posBuf = new Float32Array(topo.numVertices * 3);
599 fillPositions(posBuf, coords, topo, morph);
601 const sphereBg = getComputedStyle(document.documentElement)
602 .getPropertyValue('--sphere-bg')
603 .trim();
604 for (let k = 0; k < model.species.length; k++) {
605 const panel = document.createElement('div');
606 panel.className = 'panel';
607 const box = document.createElement('div');
608 box.className = 'sphere-box';
609 const tag = document.createElement('div');
610 tag.className = 'species-tag';
611 tag.textContent = model.species[k];
612 box.append(tag);
613 const side = document.createElement('div');
614 panel.append(box, side);
615 elPanels.append(panel);
617 const scene = new SphereScene(
618 box,
619 topo.numVertices,
620 topo.indices,
621 // Each scene owns its position buffer: three.js uploads from it, and the
622 // morph rewrites all of them from the one shared `coords`.
623 Float32Array.from(posBuf),
624 sphereBg || undefined,
625 );
626 scene.fitCamera();
627 scenes.push(scene);
628 colorbars.push(new Colorbar(side));
629 valueBufs[k] = new Float32Array(topo.numVertices);
630 colorBufs[k] = new Float32Array(topo.numVertices * 3);
631 if (!ranges[k]) ranges[k] = { lo: NaN, hi: NaN };
632 }
633 for (let k = 1; k < scenes.length; k++) scenes[0].syncCamerasWith(scenes[k]);
635 resizeObs = new ResizeObserver(() => {
636 const boxes = elPanels.querySelectorAll<HTMLElement>('.sphere-box');
637 boxes.forEach((box, i) => {
638 scenes[i]?.resize(box.clientWidth, box.clientHeight);
639 });
640 });
641 elPanels
642 .querySelectorAll<HTMLElement>('.sphere-box')
643 .forEach((box) => resizeObs!.observe(box));
646/**
647 * Apply the UI's oversampling choice to the running session. Display-only: the
648 * session and its state survive; only the display plan, mesh and scenes are
649 * rebuilt, keeping the camera pose and color ranges. The pump is drained first
650 * so no readback is in flight on the plan being replaced.
651 */
652async function applyOversample(): Promise<void> {
653 if (!session) return;
654 const gen = generation;
655 const os = resolveOversample();
656 if (os === session.oversample) return;
657 const wasRunning = running;
658 setRunning(false);
659 while (pumping) await nextFrame();
660 if (gen !== generation || !session) return;
661 await session.setOversample(os);
662 if (gen !== generation || !session) return;
663 const surface = await session.renderPositions();
664 if (gen !== generation || !session) return;
665 const cam = scenes[0]?.cameraState();
666 disposeView();
667 buildView(surface);
668 if (cam) for (const s of scenes) s.setCameraState(cam);
669 await draw();
670 updateStats();
671 if (wasRunning) setRunning(true);
674/**
675 * Re-evaluate the surface and swap it in. Data, not code: the compiled step is
676 * untouched and the simulation keeps its state and its model time, so a shape
677 * can be changed mid-run. Only the mesh is rebuilt.
678 */
679async function applyGeometry(): Promise<void> {
680 // The in-place swap below is a single session's trick. Each variant carries
681 // the surface band-limited at its own lmax, and the study's meshes are built
682 // from those, so a shape change goes through the full rebuild instead.
683 if (compareRun) return rebuildCompare();
684 if (!session) return;
685 const gen = generation;
686 const wasRunning = running;
687 setRunning(false);
688 while (pumping) await nextFrame();
689 if (gen !== generation || !session) return;
690 try {
691 await session.setGeometry(geometry, geomParams, geomSource());
692 } catch (e) {
693 reportCompileError(e);
694 return;
695 }
696 if (gen !== generation || !session) return;
697 const surface = await session.renderPositions();
698 if (gen !== generation || !session) return;
699 const cam = scenes[0]?.cameraState();
700 disposeView();
701 buildView(surface);
702 if (cam) for (const s of scenes) s.setCameraState(cam);
703 elErr.textContent = '';
704 await draw();
705 updateGeomNote();
706 updateStats();
707 if (wasRunning) setRunning(true);
710/** Re-place the vertices for the current morph. No GPU work and no readback —
711 * the surface is already on the CPU, so this is a buffer fill per panel. */
712function applyMorph(): void {
713 if (!topo || !coords || !posBuf) return;
714 fillPositions(posBuf, coords, topo, morph);
715 for (const s of scenes) s.updatePositions(posBuf);
718/** What the surface is, and how far it departs from the sphere. */
719function updateGeomNote(): void {
720 // In compare mode each variant carries the surface band-limited at its own
721 // lmax; the reference's is the one quoted, as everywhere else.
722 const s = session ?? compareRun?.referenceSession ?? null;
723 if (!s) {
724 elGeomNote.textContent = '';
725 return;
726 }
727 const { lo, hi } = s.geometry.radiusRange();
728 elGeomNote.innerHTML =
729 `<b>${s.geometryModel.label}</b> — ${s.geometryModel.blurb} ` +
730 `Radius ${lo.toFixed(3)}${hi.toFixed(3)}.`;
733/** Report a compile failure, and select the offending text in the editor. */
734function reportCompileError(e: unknown): void {
735 elErr.textContent = formatFailure(e, source());
736 elCompiled.textContent = '';
737 if (e instanceof ModelCompileError && e.start !== undefined) {
738 editor.select(e.start, e.end ?? e.start);
739 }
742async function rebuild(): Promise<void> {
743 // A study is several runs, so "rebuild the run" means rebuild all of them.
744 // Everything that recompiles — a model or preset change, an edit to either
745 // .m, a revert — arrives here, and none of it needs to know which mode is up.
746 if (compareRun) return rebuildCompare();
747 generation++;
748 const gen = generation;
749 setRunning(false);
750 disposeView();
751 session?.destroy();
752 session = null;
753 solverMs = 0;
754 frameMs = 0;
755 // Not 0: with a large niter's dispatch count not yet known (that needs the
756 // compiled plan below), the first measurement burst should wait for the
757 // ordinary per-frame batch — already sized to this model — to prove itself
758 // first, rather than firing a possibly-oversized burst before a single
759 // frame has run.
760 lastMeasure = performance.now();
761 elErr.textContent = '';
762 updateCommand();
763 if (!device) return;
765 try {
766 session = await ModelSession.create({
767 device,
768 model,
769 params,
770 lmax: Number(elLmax.value),
771 source: source(),
772 oversample: resolveOversample(),
773 geometry,
774 geometryParams: geomParams,
775 geometrySource: geomSource(),
776 niter: Number(elNiter.value),
777 lam3: Number(elLam3.value),
778 });
779 } catch (e) {
780 reportCompileError(e);
781 return;
782 }
783 if (gen !== generation) return;
785 await session.seed(seed);
787 const plan = session.describe();
788 elCompiled.textContent =
789 `one step compiled to ${plan.step.length} GPU operations:\n` +
790 plan.step.map((l) => ` ${l}`).join('\n');
791 elRecompile.textContent = 'Recompile';
793 // Scale the frame batch and the measurement burst down — never up — so
794 // neither submission's total dispatch count exceeds DISPATCH_BUDGET, no
795 // matter how expensive niter has made one step. See STEPS_PER_FRAME_BASE.
796 const opsPerStep = Math.max(1, plan.step.length);
797 stepsPerFrame = Math.max(1, Math.min(STEPS_PER_FRAME_BASE, Math.floor(DISPATCH_BUDGET / opsPerStep)));
798 measureBurst = Math.max(1, Math.min(MEASURE_BURST_BASE, Math.floor(DISPATCH_BUDGET / opsPerStep)));
800 const surface = await session.renderPositions();
801 if (gen !== generation) return;
803 ranges = [];
804 buildView(surface);
806 await draw();
807 updateGeomNote();
808 updateStats();
809 void pump();
812async function reseed(): Promise<void> {
813 // One new perturbation for the whole study, band-limited at its coarsest
814 // variant and evaluated on each grid — see src/compare/sharedStart.ts.
815 if (compareRun) return compareRun.reseed(seed);
816 if (!session) return;
817 const gen = generation;
818 await session.seed(seed);
819 if (gen !== generation) return;
820 for (const r of ranges) {
821 r.lo = NaN;
822 r.hi = NaN;
823 }
824 await draw();
825 updateStats();
828// ---------------------------------------------------------------- drawing
829async function draw(): Promise<void> {
830 if (!session || !topo) return;
831 const gen = generation;
832 const cmap = colormaps[elColormap.value] ?? colormaps.viridis;
833 for (let k = 0; k < model.species.length; k++) {
834 // The one readback per frame — the loop is otherwise entirely on the GPU.
835 // A rebuild can land while this is in flight and destroy the buffer being
836 // mapped, which rejects the map; that result is stale anyway, so drop it.
837 let field: Float32Array;
838 try {
839 field = await session.readSpecies(k);
840 } catch (e) {
841 if (gen !== generation) return;
842 throw e;
843 }
844 if (gen !== generation || !topo) return;
845 fillFieldValues(valueBufs[k], field, topo);
846 let lo = Infinity;
847 let hi = -Infinity;
848 for (const v of valueBufs[k]) {
849 if (v < lo) lo = v;
850 if (v > hi) hi = v;
851 }
852 // smooth the color range in both directions so the shading evolves
853 // gently as the pattern grows (out-of-range values clamp meanwhile)
854 const r = ranges[k];
855 if (!Number.isFinite(r.lo)) {
856 r.lo = lo;
857 r.hi = hi;
858 } else {
859 const a = 0.15;
860 r.lo += a * (lo - r.lo);
861 r.hi += a * (hi - r.hi);
862 }
863 // A field that is uniform to fp32 precision — Schnakenberg's v at t = 0 is
864 // exactly constant — would otherwise have the colormap stretched across its
865 // roundoff and be drawn as vivid noise. See floorRange.
866 const shown = floorRange(r.lo, r.hi);
867 fillColors(colorBufs[k], valueBufs[k], shown.lo, shown.hi, cmap);
868 scenes[k]?.updateColors(colorBufs[k]);
869 colorbars[k]?.update(cmap, shown.lo, shown.hi);
870 }
873function updateStats(): void {
874 if (!session) return;
875 const { nlat, nphi } = session.cfg;
876 const kind = `WebGPU fp32${adapterName ? ` — ${adapterName}` : ''}`;
877 const solver =
878 solverMs > 0
879 ? `<b>${solverMs.toFixed(2)} ms/step</b> (${(1000 / solverMs).toFixed(0)} steps/s)`
880 : '—';
881 const frame = frameMs > 0 ? `${frameMs.toFixed(1)} ms/frame` : '—';
882 const view = session.viewSht.cfg;
883 const render =
884 session.oversample > 1
885 ? ` (display ${view.nlat}×${view.nphi})`
886 : '';
887 elStats.innerHTML =
888 `<b>${kind}</b> · grid ${nlat}×${nphi}${render} · nlm ${session.sht.nlm.toLocaleString()} · ` +
889 `solver ${solver} · ${frame} · ` +
890 `t = <b>${session.t.toFixed(2)}</b> (${session.steps} steps)`;
893// ---------------------------------------------------------------- sim loop
894const nextFrame = () => new Promise<number>(requestAnimationFrame);
896async function pump(): Promise<void> {
897 if (pumping) return;
898 pumping = true;
899 const gen = generation;
900 try {
901 while (running && session && gen === generation) {
902 // Occasionally, a burst purely to measure the solver rate: many steps,
903 // one sync, nothing read back — directly comparable to the desktop
904 // benchmark's throughput number. State-preserving: the display and
905 // model time are unaffected.
906 if (performance.now() - lastMeasure > MEASURE_EVERY_MS) {
907 const ms = await session.measure(measureBurst);
908 if (gen !== generation) break;
909 solverMs = ms;
910 lastMeasure = performance.now();
911 }
913 // The frame itself. No explicit sync here — draw()'s readback already
914 // waits for the steps, so asking twice would only add a round trip.
915 const t0 = performance.now();
916 session.step(stepsPerFrame);
917 await draw();
918 if (gen !== generation) break;
919 frameMs = frameMs === 0
920 ? performance.now() - t0
921 : frameMs + 0.05 * (performance.now() - t0 - frameMs);
922 updateStats();
923 await nextFrame();
924 }
925 if (gen === generation) {
926 await draw();
927 updateStats();
928 }
929 } finally {
930 pumping = false;
931 }
934/**
935 * Sustained solver benchmark, in the page.
936 *
937 * The same measurement `npm run bench` makes: batches of steps submitted
938 * together, waited for, never read back, with no rendering and no animation
939 * pacing in between. That makes it directly comparable to the terminal number,
940 * which is the only way to tell a genuinely slower browser GPU stack apart from
941 * the costs the app adds on top.
942 *
943 * It also reports the ramp — the first third of the run against the last. GPUs
944 * downclock when idle, and an animation-paced loop leaves them idle most of every
945 * frame, so a large ramp means the app's steady-state number is limited by clocks
946 * rather than by the work.
947 *
948 * These are ordinary steps: the simulation advances by them.
949 */
950async function benchmark(): Promise<void> {
951 if (!session || movieBusy) return;
952 setRunning(false);
953 // Same base size and the same DISPATCH_BUDGET scaling as the automatic
954 // measurement burst (see STEPS_PER_FRAME_BASE) — this is a user-triggered
955 // 32-step submission, exactly the shape of thing that risks a browser's
956 // GPU-process watchdog on weak hardware once niter makes a step expensive.
957 const BATCH = measureBurst;
958 const DURATION_MS = 2000;
959 elBenchResult.textContent = 'benchmarking…';
960 // A movie started mid-benchmark would replay while this loop still steps.
961 elMovie.disabled = true;
962 try {
963 await nextFrame();
965 const gen = generation;
966 const perStep: number[] = [];
967 const t0 = performance.now();
968 while (performance.now() - t0 < DURATION_MS) {
969 const b0 = performance.now();
970 session.step(BATCH);
971 await session.sync();
972 if (gen !== generation) return;
973 perStep.push((performance.now() - b0) / BATCH);
974 }
976 const mean = (xs: number[]): number => xs.reduce((a, b) => a + b, 0) / xs.length;
977 const all = mean(perStep);
978 const best = Math.min(...perStep);
979 const third = Math.max(1, Math.floor(perStep.length / 3));
980 const first = mean(perStep.slice(0, third));
981 const last = mean(perStep.slice(-third));
982 const steps = perStep.length * BATCH;
984 elBenchResult.innerHTML =
985 `sustained solver: <b>${all.toFixed(2)} ms/step</b> ` +
986 `(${(1000 / all).toFixed(0)} steps/s) · best ${best.toFixed(2)} · ` +
987 `ramp ${(first / last).toFixed(2)}× · ${steps} steps · ` +
988 `compare with <code>npm run bench -- --lmax ${session.cfg.lmax}</code>`;
989 await draw();
990 updateStats();
991 } finally {
992 elMovie.disabled = false;
993 }
996// ---------------------------------------------------------------- movie
997function saveBlob(blob: Blob, filename: string): void {
998 const url = URL.createObjectURL(blob);
999 const a = document.createElement('a');
1000 a.href = url;
1001 a.download = filename;
1002 a.click();
1003 setTimeout(() => URL.revokeObjectURL(url), 10_000);
1006/** Submit `n` steps in bounded command buffers — a single buffer encoding
1007 * many thousands of steps can exhaust the encoder. */
1008function submitSteps(n: number): void {
1009 while (n > 0 && session) {
1010 const chunk = Math.min(512, n);
1011 session.step(chunk);
1012 n -= chunk;
1016/** While recording, lock everything that could change the run mid-replay;
1017 * the Movie button itself becomes the cancel button. */
1018function setMovieUi(on: boolean): void {
1019 const locked = [
1020 elModel, elGeometry, elMorph, elNiter, elLmax, elOversample, elColormap,
1021 elRunPause, elBenchmark, elReseed, elRecompile, elRevert, elEditorFile,
1022 elMovieSpeed, elMovieRes, elMovieRotate, elMovieToggle,
1023 ];
1024 for (const el of locked) el.disabled = on;
1025 elParams.querySelectorAll('input').forEach((input) => (input.disabled = on));
1026 elGeomParams.querySelectorAll('input').forEach((input) => (input.disabled = on));
1027 elMovie.textContent = on ? 'Cancel · 0%' : 'Export';
1030/**
1031 * Recompute the run from t = 0 and download it as an MP4.
1033 * The movie is not a recording of what already happened — it is the same
1034 * trajectory recomputed: same seed, same source, and the *current* parameters
1035 * and colormap throughout. Determinism makes this exact: after the replay the
1036 * state is where it was, so the one session is reused and the app resumes as
1037 * if nothing happened. Frames are composited from the live panels, so the
1038 * movie shows the spheres at the current camera orientation — and the replay
1039 * doubles as the progress display, since it is visible on screen.
1040 */
1041async function recordMovie(): Promise<void> {
1042 if (!session || movieBusy) return;
1043 if (session.steps === 0) {
1044 elMovie.textContent = 'run first';
1045 setTimeout(() => (elMovie.textContent = 'Export'), 1200);
1046 return;
1048 movieBusy = true;
1049 movieCancel = false;
1050 const gen = generation;
1051 setMovieUi(true);
1052 let wasRunning = false;
1053 let total = 0;
1054 let done = 0;
1055 let seeded = false;
1056 let camBefore: ReturnType<SphereScene['cameraState']> | undefined;
1057 try {
1058 // An in-flight display-grid swap replaces the scenes whose canvases the
1059 // recorder captures, and resumes the run when it lands — let it finish.
1060 await viewChange;
1061 if (gen !== generation || !session) return;
1062 wasRunning = running;
1063 setRunning(false);
1064 while (pumping) await nextFrame(); // let an in-flight live frame drain
1065 if (gen !== generation || !session) return;
1066 total = session.steps;
1067 const speed = Number(elMovieSpeed.value) || 10;
1068 const sphere = Number(elMovieRes.value) || 768;
1069 const rotate = elMovieRotate.checked;
1070 if (rotate) camBefore = scenes[0]?.cameraState();
1071 // Render the scenes at exactly the chosen resolution for the recording —
1072 // independent of the window size — and restore afterwards.
1073 for (const s of scenes) s.captureSize(sphere);
1074 const durationS = Math.max(session.t / speed, 2 / MOVIE_FPS);
1075 const frames = Math.max(
1076 2,
1077 Math.min(Math.round(durationS * MOVIE_FPS) + 1, total + 1, MOVIE_MAX_FRAMES),
1078 );
1079 /** The step index captured as frame `i`; both endpoints land exactly. */
1080 const stepAt = (i: number): number => Math.round((i * total) / (frames - 1));
1082 const title =
1083 (presets.find((p) => p.key === elModel.value)?.label ?? model.label) +
1084 ` on ${geometry.label.toLowerCase()}` +
1085 (editedSource !== null || editedGeomSource !== null ? ' (edited)' : '');
1086 const subtitle = model.params
1087 .map((spec) => `${spec.label} ${fmtValue(params[spec.key])}`)
1088 .join(' · ');
1089 const rec = await MovieRecorder.create({
1090 panels: model.species.map((label, k) => ({ canvas: scenes[k].canvas, label })),
1091 title,
1092 subtitle,
1093 speed,
1094 fps: (frames - 1) / durationS,
1095 sphere,
1096 });
1098 let finished = false;
1099 try {
1100 // Reset the color-range smoothing as a re-seed does, so the shading
1101 // evolves in the movie the way it did live.
1102 await session.seed(seed);
1103 seeded = true;
1104 for (const r of ranges) {
1105 r.lo = NaN;
1106 r.hi = NaN;
1108 const cmap = colormaps[elColormap.value] ?? colormaps.viridis;
1109 let lastVideoS = 0;
1110 for (let frame = 0; ; ) {
1111 await draw();
1112 if (gen !== generation) return;
1113 if (movieCancel) break;
1114 if (rotate) {
1115 // Advance the orbit by this frame's share of video time; siblings
1116 // follow scenes[0] through the usual camera sync.
1117 const videoS = session.t / speed;
1118 scenes[0]?.orbitBy(2 * Math.PI * MOVIE_ROTATE_RPS * (videoS - lastVideoS));
1119 lastVideoS = videoS;
1121 for (const s of scenes) s.renderNow();
1122 await rec.addFrame(
1123 session.t,
1124 model.species.map((_, k) => ({ cmap, lo: ranges[k].lo, hi: ranges[k].hi })),
1125 );
1126 if (++frame >= frames) {
1127 finished = true;
1128 break;
1130 const target = stepAt(frame);
1131 submitSteps(target - done);
1132 done = target;
1133 elMovie.textContent = `Cancel · ${Math.round((100 * done) / total)}%`;
1135 if (finished) {
1136 const blob = await rec.finish();
1137 saveBlob(
1138 blob,
1139 `turing-surface-${model.key}-${geometry.key}-` +
1140 `t${session.t.toFixed(2)}-${speed}x.mp4`,
1141 );
1143 } finally {
1144 if (!finished) rec.cancel();
1146 } catch (e) {
1147 elErr.textContent = `movie: ${e instanceof Error ? e.message : e}`;
1148 } finally {
1149 // A cancelled replay stopped short of where the run was; step the
1150 // remainder — determinism makes this land exactly there.
1151 if (seeded && gen === generation && session) {
1152 while (done < total && gen === generation && session) {
1153 const n = Math.min(4096, total - done);
1154 submitSteps(n);
1155 done += n;
1156 elMovie.textContent = `restoring · ${Math.round((100 * done) / total)}%`;
1157 await session.sync();
1159 await draw();
1160 updateStats();
1162 if (gen === generation) {
1163 for (const s of scenes) s.restoreSize();
1165 if (camBefore && gen === generation) {
1166 for (const s of scenes) s.setCameraState(camBefore);
1168 movieBusy = false;
1169 setMovieUi(false);
1170 if (gen === generation) setRunning(wasRunning);
1174// ---------------------------------------------------------------- compare
1175/**
1176 * Comparing several solver settings at once.
1178 * Deliberately a mode rather than a widening of the ordinary controls: the
1179 * single-run path above is untouched, and with the bar closed nothing about
1180 * using this page has changed. Opening it and pressing Compare tears down the
1181 * one session and hands the panels area to a CompareRun, which owns a session
1182 * per variant; pressing it again puts the single run back.
1184 * The ceilings below are not arbitrary. Each variant compiles its whole
1185 * unrolled step with no pipeline cache between sessions (a solve iteration is
1186 * ~15 kernels per species), so the variant count is what you wait for; and
1187 * each panel is a WebGL context and a full mesh, so the panel count is what
1188 * the browser has to keep alive at once.
1189 */
1190const MAX_VARIANTS = 6;
1191const MAX_PANELS = 12;
1192/** dt divisors. Powers of two so that dtBase/K is exact in binary and every
1193 * variant lands on the same model time with no accumulated drift. */
1194const DT_DIVISORS = [1, 2, 4, 8];
1196/**
1197 * What the bar opens on: the default iteration count against the next step up,
1198 * at the default band. Two variants, so the first study is quick to compile,
1199 * and it asks the question the control exists for — is the default already
1200 * converged? A flat, low curve says yes; one that climbs says the answer is
1201 * still moving at niter 8 and the default is not enough for this shape.
1202 */
1203const cmpSelected = {
1204 niter: new Set<number>([DEFAULT_NITER, 2 * DEFAULT_NITER]),
1205 lmax: new Set<number>([63]),
1206 dt: new Set<number>([1]),
1209/** A row of toggle chips backed by a Set. At least one stays selected — an
1210 * empty axis has no meaning here, and silently falling back to a default
1211 * would hide which values are actually being run. */
1212function buildChips(host: HTMLElement, values: number[], selected: Set<number>, label: (v: number) => string): void {
1213 host.replaceChildren();
1214 for (const value of values) {
1215 const chip = document.createElement('button');
1216 chip.type = 'button';
1217 chip.className = 'chip';
1218 chip.textContent = label(value);
1219 const paint = (): void => chip.setAttribute('aria-pressed', String(selected.has(value)));
1220 paint();
1221 chip.addEventListener('click', () => {
1222 if (selected.has(value)) {
1223 if (selected.size === 1) return;
1224 selected.delete(value);
1225 } else {
1226 selected.add(value);
1228 paint();
1229 refreshVariants();
1230 });
1231 host.append(chip);
1235const cmpVariants = (): Variant[] =>
1236 crossProduct([...cmpSelected.niter], [...cmpSelected.lmax], [...cmpSelected.dt]);
1238/**
1239 * A loaded reference file, or null. While one is loaded the study checks the
1240 * variants against it instead of against each other: the file defines the
1241 * whole problem (model, parameters, geometry, initial state, end time), so
1242 * the page's own model and geometry choices do not enter the study at all —
1243 * only the solver knobs above do.
1244 */
1245let refCase: ReferenceCase | null = null;
1247/** The reference the user picked, clamped to the current variant list. */
1248let cmpRefKey = '';
1250/** Index of the reference in the current variant list, never negative. */
1251function compareRefIndex(): number {
1252 const i = cmpVariants().map(variantKey).indexOf(cmpRefKey);
1253 return i < 0 ? 0 : i;
1256function refreshVariants(): void {
1257 const variants = cmpVariants();
1258 const showDt = cmpSelected.dt.size > 1;
1259 // With a file loaded the study's model is the file's, and its final state
1260 // is one more row of panels.
1261 const cmpModel = refCase?.model ?? model;
1262 const rowCount = variants.length + (refCase ? 1 : 0);
1263 const panels = rowCount * cmpModel.species.length;
1265 const prev = cmpRefKey;
1266 elCmpRef.replaceChildren();
1267 if (refCase) {
1268 // The file is the reference; the pick among variants means nothing here.
1269 const o = document.createElement('option');
1270 o.textContent = `the file's final state`;
1271 elCmpRef.append(o);
1272 elCmpRef.disabled = true;
1273 } else {
1274 elCmpRef.disabled = false;
1275 for (const v of variants) {
1276 const o = document.createElement('option');
1277 o.value = variantKey(v);
1278 o.textContent = variantLabel(v, showDt);
1279 elCmpRef.append(o);
1281 const keys = variants.map(variantKey);
1282 cmpRefKey = keys.includes(prev) ? prev : keys[mostResolved(variants)];
1283 elCmpRef.value = cmpRefKey;
1286 const tooMany =
1287 variants.length > MAX_VARIANTS
1288 ? `${variants.length} variants — at most ${MAX_VARIANTS}`
1289 : panels > MAX_PANELS
1290 ? `${panels} panels — at most ${MAX_PANELS}`
1291 : '';
1292 elCmpCount.textContent = tooMany
1293 ? `too many: ${tooMany}`
1294 : `${variants.length} variant${variants.length === 1 ? '' : 's'}` +
1295 `${refCase ? ' + the file' : ''} × ` +
1296 `${cmpModel.species.length} species = ${panels} panels`;
1297 elCmpCount.style.color = tooMany ? '#b35900' : '';
1298 elCmpStart.disabled = tooMany !== '' && compareRun === null;
1301/**
1302 * The niter chips on offer. A loaded reference file adds its own recorded
1303 * iteration count if the standard list lacks it, so the file's settings are
1304 * always selectable; clearing the file drops any selection outside the
1305 * standard list again.
1306 */
1307function rebuildNiterChips(): void {
1308 const all = [...elNiter.options].map((o) => Number(o.value));
1309 let values = all;
1310 if (refCase && !all.includes(refCase.niter)) {
1311 values = [...all, refCase.niter].sort((a, b) => a - b);
1313 if (!refCase) {
1314 for (const v of [...cmpSelected.niter]) if (!values.includes(v)) cmpSelected.niter.delete(v);
1315 if (cmpSelected.niter.size === 0) cmpSelected.niter.add(DEFAULT_NITER);
1317 buildChips(elCmpNiter, values, cmpSelected.niter, String);
1320/**
1321 * The lmax chips on offer. A loaded reference file floors them at its own
1322 * band: a variant below it could not even hold the file's initial state
1323 * (prolongation only widens), so those values are not offered rather than
1324 * offered and refused.
1325 */
1326function rebuildLmaxChips(): void {
1327 const all = [...elLmax.options].map((o) => Number(o.value));
1328 let values = all;
1329 if (refCase) {
1330 const floor = refCase.lmax;
1331 values = all.filter((v) => v >= floor);
1332 if (!values.includes(floor)) values = [floor, ...values];
1333 for (const v of [...cmpSelected.lmax]) if (!values.includes(v)) cmpSelected.lmax.delete(v);
1334 if (cmpSelected.lmax.size === 0) cmpSelected.lmax.add(floor);
1336 buildChips(elCmpLmax, values, cmpSelected.lmax, String);
1339rebuildNiterChips();
1340rebuildLmaxChips();
1341buildChips(elCmpDt, DT_DIVISORS, cmpSelected.dt, (v) => (v === 1 ? 'dt' : `dt/${v}`));
1342refreshVariants();
1344elCmpRef.addEventListener('change', () => {
1345 cmpRefKey = elCmpRef.value;
1346 if (compareRun) void rebuildCompare();
1347});
1349/** Reflect the loaded (or cleared) reference file in the compare bar. */
1350function applyRefUi(): void {
1351 elCmpFileInfo.hidden = elCmpFileClear.hidden = refCase === null;
1352 if (refCase) {
1353 const rc = refCase;
1354 const geomParamText = rc.geometry.params
1355 .map((p) => `${p.key}=${rc.geometryParams[p.key]}`)
1356 .join(' ');
1357 const name = document.createElement('b');
1358 name.textContent = rc.label;
1359 const info = document.createElement('span');
1360 info.textContent =
1361 ` — ${rc.model.label} on ${rc.geometry.label.toLowerCase()}` +
1362 (geomParamText ? ` (${geomParamText})` : '') +
1363 `, lmax ${rc.lmax}, T = ${(rc.steps * (rc.params.dt ?? 0)).toFixed(2)}` +
1364 ` (${rc.steps} × dt ${rc.params.dt})`;
1365 elCmpFileInfo.replaceChildren(name, info);
1367 rebuildNiterChips();
1368 rebuildLmaxChips();
1369 refreshVariants();
1372/**
1373 * The four top-level modes and which control groups each shows (see
1374 * GROUP_NAMES/groupEls above; `.ctrl-group` wrappers in index.html).
1375 * `currentMode` tracks which configuration is on screen — the compare bar
1376 * being open, and in which flavor — not whether a study has actually been
1377 * started inside it. That match matters: without it, opening the bar
1378 * (which already shows the right groups) leaves its top-row button
1379 * unhighlighted until a study happens to start, which is inconsistent with
1380 * `vs-upload`'s one-click flow and reads as broken.
1381 */
1382type Mode = 'simulate' | 'compute-effort' | 'vs-sphere' | 'vs-upload';
1383let currentMode: Mode = 'simulate';
1385const MODE_GROUPS: Record<Mode, readonly GroupName[]> = {
1386 simulate: ['surface', 'surface-params', 'solver', 'display', 'playback', 'benchmark', 'seed', 'movie'],
1387 'compute-effort': ['surface', 'surface-params', 'display', 'playback', 'seed'],
1388 'vs-sphere': [], // unreachable — the button is disabled, no listener ever calls setMode with this
1389 'vs-upload': ['display', 'playback', 'seed'],
1392function setModeButtons(mode: Mode): void {
1393 elModeSimulate.setAttribute('aria-pressed', String(mode === 'simulate'));
1394 elModeEffort.setAttribute('aria-pressed', String(mode === 'compute-effort'));
1395 elModeVsUpload.setAttribute('aria-pressed', String(mode === 'vs-upload'));
1398/** Show exactly the groups `mode` declares; hide the rest. */
1399function applyModeVisibility(mode: Mode): void {
1400 currentMode = mode;
1401 const shown = new Set<GroupName>(MODE_GROUPS[mode]);
1402 for (const name of GROUP_NAMES) groupEls[name].hidden = !shown.has(name);
1403 setModeButtons(mode);
1406/**
1407 * Enter `mode`: groups, top-row buttons, and the compare bar's own
1408 * visibility (open for the two compare flavors, closed for Simulate).
1409 * Doesn't touch `compareRun`/`refCase` or start/stop a study — callers
1410 * decide that; this only decides what's on screen, and it decides it
1411 * immediately, so the button you clicked lights up right away rather than
1412 * waiting on a study that may not exist yet (or may never start, if the
1413 * bar's own Compare is never pressed).
1414 */
1415function enterMode(mode: Mode): void {
1416 applyModeVisibility(mode);
1417 elCompareBar.hidden = mode === 'simulate';
1420/** Entering a mode from the top row. */
1421function setMode(mode: Mode): void {
1422 if (mode === 'vs-sphere') return; // unreachable — button is disabled
1423 if (mode === 'simulate') {
1424 if (compareRun) void stopCompare();
1425 enterMode('simulate');
1426 return;
1428 if (mode === 'compute-effort') {
1429 // Tear down whatever study is running first (mirrors Simulate above) —
1430 // stopCompare's synchronous prefix disposes it and nulls `compareRun`
1431 // before its first `await`, so `refCase` is safe to drop right after.
1432 if (compareRun) void stopCompare();
1433 if (refCase) {
1434 refCase = null;
1435 applyRefUi();
1437 enterMode('compute-effort');
1438 return;
1440 // vs-upload: opens the file picker; entering the mode itself happens once
1441 // a file is actually chosen (elCmpFile's change handler below) — not here,
1442 // since cancelling the dialog must leave the current mode untouched.
1443 elCmpFile.click();
1446elModeSimulate.addEventListener('click', () => setMode('simulate'));
1447elModeEffort.addEventListener('click', () => setMode('compute-effort'));
1448elModeVsUpload.addEventListener('click', () => setMode('vs-upload'));
1450elCmpFile.addEventListener('change', () => {
1451 const file = elCmpFile.files?.[0];
1452 // Cleared so picking the same file again still fires a change event.
1453 elCmpFile.value = '';
1454 if (!file) return;
1455 void (async () => {
1456 try {
1457 refCase = await loadReferenceFile(file);
1458 elErr.textContent = '';
1459 } catch (e) {
1460 refCase = null;
1461 elErr.textContent = `reference file ${file.name}: ${e instanceof Error ? e.message : e}`;
1462 applyRefUi();
1463 return;
1465 // One click, one study: the file's own settings become the single
1466 // variant — its recorded niter, its band, its dt undivided — and the
1467 // comparison opens on them, paused at the initial state so what runs is
1468 // the user's choice. (Widening it is: teardown the comparison, pick more
1469 // chips, compile it again — the file stays loaded.)
1470 cmpSelected.niter.clear();
1471 cmpSelected.niter.add(refCase.niter);
1472 cmpSelected.lmax.clear();
1473 cmpSelected.lmax.add(refCase.lmax);
1474 cmpSelected.dt.clear();
1475 cmpSelected.dt.add(1);
1476 applyRefUi();
1477 enterMode('vs-upload');
1478 if (compareRun) {
1479 // A study is already up (this one loaded over it): same teardown as
1480 // rebuildCompare, then the new file's study takes its place.
1481 compareRun.dispose();
1482 compareRun = null;
1483 setCompareUi(false);
1485 await startCompare();
1486 })();
1487});
1488elCmpFileClear.addEventListener('click', () => {
1489 refCase = null;
1490 applyRefUi();
1491 // The bar stays open — this only drops back to the plain chip comparison.
1492 // Only reachable while idle (elCmpFileClear is disabled during a study).
1493 enterMode('compute-effort');
1494});
1496elCmpStart.addEventListener('click', () => {
1497 if (compareRun) void stopCompare();
1498 else void startCompare();
1499});
1501/**
1502 * Controls the study supersedes or cannot honour while it is running.
1503 * Mode/group/button state is not this function's job — that's set the
1504 * moment a mode is entered (enterMode, above), independent of whether a
1505 * study inside it has actually started or stopped.
1506 */
1507function setCompareUi(on: boolean): void {
1508 // A study picks its own display grid, so oversample stays individually
1509 // disabled inside the still-visible display group; and clearing a loaded
1510 // file out from under a running study would leave it checking against one
1511 // that no longer exists.
1512 elOversample.disabled = on;
1513 elCmpFileClear.disabled = on;
1514 elCmpNiter.querySelectorAll('button').forEach((b) => (b.disabled = on));
1515 elCmpLmax.querySelectorAll('button').forEach((b) => (b.disabled = on));
1516 elCmpDt.querySelectorAll('button').forEach((b) => (b.disabled = on));
1517 elCmpStart.textContent = on ? 'Teardown comparison' : 'Compile comparison';
1518 // The movie bar's own hidden flag is independent of the movie *group's* —
1519 // force it closed so it doesn't reappear open once the group is shown
1520 // again on returning to Simulate.
1521 if (on) elMovieBar.hidden = true;
1524async function startCompare(): Promise<void> {
1525 if (compareRun || !device) return;
1526 // Snapshotted for the whole study: `refCase` only changes with no study up
1527 // (clearing is disabled during one, and loading tears it down first).
1528 const rc = refCase;
1529 const cmpModel = rc?.model ?? model;
1530 const variants = cmpVariants();
1531 const rowCount = variants.length + (rc ? 1 : 0);
1532 if (variants.length > MAX_VARIANTS || rowCount * cmpModel.species.length > MAX_PANELS) {
1533 return;
1535 // Take down the single run first: its pump, its scenes, its session. The
1536 // generation bump makes any readback already in flight drop its result.
1537 generation++;
1538 setRunning(false);
1539 while (pumping) await nextFrame();
1540 disposeView();
1541 session?.destroy();
1542 session = null;
1543 elBenchResult.textContent = '';
1544 elErr.textContent = '';
1545 setCompareUi(true);
1547 try {
1548 // Against a reference file, the problem is the file's — its model,
1549 // parameters and geometry, from the registry sources (the editor's
1550 // working copies describe the page's run, not the file's).
1551 compareRun = await CompareRun.create({
1552 device,
1553 model: cmpModel,
1554 params: rc ? rc.params : params,
1555 source: rc ? rc.model.source : source(),
1556 geometry: rc ? rc.geometry : geometry,
1557 geometryParams: rc ? rc.geometryParams : geomParams,
1558 geometrySource: rc ? rc.geometry.source : geomSource(),
1559 variants,
1560 reference: rc ? 0 : compareRefIndex(),
1561 refFile: rc ?? undefined,
1562 onFinished: () => setRunning(false),
1563 seed,
1564 lam3: rc ? undefined : Number(elLam3.value),
1565 morph,
1566 colormapName: () => elColormap.value,
1567 container: elPanels,
1568 onStatus: (html) => (elStats.innerHTML = html),
1569 });
1570 } catch (e) {
1571 compareRun = null;
1572 setCompareUi(false);
1573 refreshVariants();
1574 reportCompileError(e);
1575 await rebuild();
1576 return;
1578 updateGeomNote();
1579 // The command describes the reference variant, which only exists now.
1580 updateCommand();
1581 elRunPause.textContent = 'Run';
1584async function stopCompare(): Promise<void> {
1585 if (!compareRun) return;
1586 compareRun.dispose();
1587 compareRun = null;
1588 setCompareUi(false);
1589 refreshVariants();
1590 elStats.textContent = '';
1591 await rebuild();
1594/** Rebuild the study in place — after a model, geometry, source or reference
1595 * change. Same teardown as stopping, without leaving the mode. */
1596async function rebuildCompare(): Promise<void> {
1597 if (!compareRun) return;
1598 compareRun.dispose();
1599 compareRun = null;
1600 setCompareUi(false);
1601 await startCompare();
1604// ---------------------------------------------------------------- boot
1605async function boot(): Promise<void> {
1606 enterMode('simulate');
1607 elModel.value = presets[0].key;
1608 // The iteration count is one default shared with the benchmark, like the
1609 // rest of the RunSpec's — take it from there rather than from the markup, so
1610 // the page and `npm run bench` cannot start out disagreeing about it.
1611 elNiter.value = String(DEFAULT_NITER);
1612 elGeometry.value = DEFAULT_GEOMETRY_KEY;
1613 elMorph.value = String(morph);
1614 applyGeometryChoice(DEFAULT_GEOMETRY_KEY);
1615 applyPreset(presets[0].key);
1616 try {
1617 device = await requestShtDevice();
1618 adapterName = await describeAdapter(device);
1619 } catch (e) {
1620 device = null;
1621 elErr.textContent =
1622 `WebGPU is not available (${e instanceof Error ? e.message : e}). ` +
1623 `Use a WebGPU-capable browser such as Chrome or Edge.`;
1624 return;
1626 device.lost.then((info) => {
1627 if (info.reason !== 'destroyed') {
1628 elErr.textContent = `WebGPU device lost: ${info.message}`;
1630 });
1631 await rebuild();
1634void boot();
moveopenescclose