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