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