/ concept-collection / turing-surface
Sign in
concept-collection / turing-surface
1763 lines · 66.2 KBCodeBlameHistory
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,
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 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,
cf4af12WIP: comparison with unit sphereOwen Melia 21 SPHERE_KEY,
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 22 type MGeometry,
23} from './geom/registry.ts';
24import {
25 buildTopology,
26 fillFieldValues,
27 fillPositions,
28 fillColors,
29 type SphereMeshTopology,
30} from './render/sphereMesh.ts';
31import { SphereScene } from './render/SphereScene.ts';
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';
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');
2bb4f78Add short descriptions for each modeOwen Melia 72const elModeDesc = $('mode-desc');
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 73const elCompareBar = $('comparebar');
cf4af12WIP: comparison with unit sphereOwen Melia 74const elCmpAxes = $('cmp-axes');
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 75const elCmpNiter = $('cmp-niter');
76const elCmpLmax = $('cmp-lmax');
77const elCmpDt = $('cmp-dt');
cf4af12WIP: comparison with unit sphereOwen Melia 78const elCmpRefLabel = $('cmp-reflabel');
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');
02e7a36Update comparison UIOwen Melia 89const elCmpChart = $('cmp-chart');
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);
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);
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);
137for (const name of colormapNames) {
138 const o = document.createElement('option');
139 o.value = name;
140 o.textContent = name;
141 elColormap.append(o);
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});
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;
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 */
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;
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;
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;
292const source = (): string => editedSource ?? model.source;
293const geomSource = (): string => editedGeomSource ?? geometry.source;
295// ---------------------------------------------------------------- UI wiring
296function buildParamInputs(): void {
297 elParams.replaceChildren();
c8e230aFixing some bugs in the UIOwen Melia 298 if (model.params.length === 0) return;
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);
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 320 updateCommand();
321 });
322 label.append(input);
323 elParams.append(label);
324 }
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');
c8e230aFixing some bugs in the UIOwen Melia 337 tag.textContent = 'geometry parameters';
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 }
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();
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();
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`;
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,
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 437 geometry: geometry.key,
438 geometryParams: geomParams,
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 439 niter: ref ? ref.niter : Number(elNiter.value),
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());
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;
beac00aMerge main into random-fieldsJeremy Magland 494 target.setLam3(v);
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);
beac00aMerge main into random-fieldsJeremy Magland 502 target.setLam3(previous);
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';
524 compareRun.setRunning(next);
525 return;
526 }
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 527 if (running) void pump();
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();
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));
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);
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();
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 700 if (!session) return;
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);
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);
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();
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 744 elGeomNote.innerHTML =
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 }
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();
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 763 generation++;
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();
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 777 elErr.textContent = '';
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;
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();
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);
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 835 if (!session) return;
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();
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();
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);
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)`;
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();
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 954 await draw();
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 }
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);
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;
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);
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;
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';
1067/**
1068 * Recompute the run from t = 0 and download it as an MP4.
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;
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);
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 1140 seeded = true;
1141 for (const r of ranges) {
1142 r.lo = NaN;
1143 r.hi = NaN;
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;
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;
1167 const target = stepAt(frame);
1168 submitSteps(target - done);
1169 done = target;
1170 elMovie.textContent = `Cancel · ${Math.round((100 * done) / total)}%`;
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 );
1180 } finally {
1181 if (!finished) rec.cancel();
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();
1196 await draw();
1197 updateStats();
1199 if (gen === generation) {
1200 for (const s of scenes) s.restoreSize();
1202 if (camBefore && gen === generation) {
1203 for (const s of scenes) s.setCameraState(camBefore);
1205 movieBusy = false;
1206 setMovieUi(false);
1207 if (gen === generation) setRunning(wasRunning);
f467ffaCompare several solver settings side by side, on one clockJeremy Magland 1211// ---------------------------------------------------------------- compare
1212/**
1213 * Comparing several solver settings at once.
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.
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]),
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);
1265 paint();
1266 refreshVariants();
1267 });
1268 host.append(chip);
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;
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;
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);
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' : ''} × ` +
02e7a36Update comparison UIOwen Melia 1346 `${cmpModel.species.length} species` +
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;
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);
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);
1369 buildChips(elCmpNiter, values, cmpSelected.niter, String);
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);
1388 buildChips(elCmpLmax, values, cmpSelected.lmax, String);
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.
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';
3210e7dOpen the reference comparison in one clickJeremy Magland 1410rebuildNiterChips();
c90d0e2Check reference files in the browser's compare modeJeremy Magland 1411rebuildLmaxChips();
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);
3210e7dOpen the reference comparison in one clickJeremy Magland 1438 rebuildNiterChips();
c90d0e2Check reference files in the browser's compare modeJeremy Magland 1439 rebuildLmaxChips();
1440 refreshVariants();
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.',
cf4af12WIP: comparison with unit sphereOwen Melia 1464 'vs-sphere':
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.',
2bb4f78Add short descriptions for each modeOwen Melia 1469 'vs-upload':
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.',
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);
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;
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.
c8e230aFixing some bugs in the UIOwen Melia 1525 if (compareRun) void stopCompare();
1527 refCase = null;
1528 applyRefUi();
cf4af12WIP: comparison with unit sphereOwen Melia 1530 enterMode(mode);
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();
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}`;
3210e7dOpen the reference comparison in one clickJeremy Magland 1556 applyRefUi();
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.)
3210e7dOpen the reference comparison in one clickJeremy Magland 1564 cmpSelected.niter.clear();
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);
3210e7dOpen the reference comparison in one clickJeremy Magland 1572 if (compareRun) {
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);
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;
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;
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();
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)) {
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,
c90d0e2Check reference files in the browser's compare modeJeremy Magland 1678 model: cmpModel,
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(),
cf4af12WIP: comparison with unit sphereOwen Melia 1685 reference,
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,
02e7a36Update comparison UIOwen Melia 1696 chartContainer: elCmpChart,
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;
1707 updateGeomNote();
1708 // The command describes the reference variant, which only exists now.
1709 updateCommand();
1710 elRunPause.textContent = 'Run';
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();
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();
0af3386Reaction-diffusion on spherical-harmonic surfacesJeremy Magland 1733// ---------------------------------------------------------------- boot
1734async function boot(): Promise<void> {
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.`;
1755 device.lost.then((info) => {
1756 if (info.reason !== 'destroyed') {
1757 elErr.textContent = `WebGPU device lost: ${info.message}`;
1759 });
1760 await rebuild();
1763void boot();
moveopenescclose