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