/ concept-collection / turing-surface-cache
Sign in
concept-collection / turing-surface-cache
turing-surface-cache / src / main.ts
1081 lines · 36.0 KBBlameHistoryRaw
1/**
2 * turing-surface-cache: reaction-diffusion solutions at a chosen end time,
3 * from a shared cloud cache when someone has computed them before, and from
4 * the local GPU when not.
5 *
6 * Every control is a choice from a short list (src/cache/options.ts), so the
7 * page's whole state is one small spec object. Get solution hashes that spec
8 * into a cache object name (src/cache/spec.ts) and fetches it; a 404 means
9 * nobody has computed it, so the solver runs here — live, watching the
10 * pattern form — and stops at exactly the requested time. A run to T passes
11 * exactly through every smaller listed end time, so those states are captured
12 * along the way; with an upload API key entered, all of them are contributed
13 * back to the cache.
14 *
15 * The solver is turing-surface's, unchanged: the model and geometry are
16 * MATLAB compiled (model) or interpreted (geometry) by numbl, the transforms
17 * are WGSL compute shaders. lmax, niter and the seed wavelength are fixed in
18 * this app (options.ts) — fewer knobs, same machinery.
19 */
20import { requestShtDevice, describeAdapter } from './sht/sht.ts';
21import type { ModelSession } from './mgpu/session.ts';
22import { mModels, mModelByKey, type MModel, type Params } from './mgpu/registry.ts';
23import { formatFailure } from './mgpu/errors.ts';
24import {
25 mGeometryByKey,
26 DEFAULT_GEOMETRY_KEY,
27 mGeometries,
28 type MGeometry,
29} from './geom/registry.ts';
30import {
31 buildTopology,
32 fillPositions,
33 fillFieldValues,
34 fillColors,
35 type SphereMeshTopology,
36} from './render/sphereMesh.ts';
37import { SphereScene } from './render/SphereScene.ts';
38import { Colorbar, floorRange } from './render/colorbar.ts';
39import { colormaps } from './render/colormaps.ts';
40import {
41 MODEL_CHOICES,
42 DEFAULT_MODEL_KEY,
43 GEOMETRY_CHOICES,
44 SEED_CHOICE,
45 T_END_CHOICE,
46 LMAX,
47 NITER,
48 LAM3,
49 AUTO_SEED,
50 AUTO_DT,
51 defaultChoiceParams,
52 fmtChoice,
53 type DiscreteChoice,
54} from './cache/options.ts';
55import { stepsFor, type CacheSpec, APP_NAME, FORMAT_VERSION } from './cache/spec.ts';
56import { lookupFor, fetchCached, headCached, type CacheLookup } from './cache/client.ts';
57import { autoOrder, type AutoTarget } from './cache/autoWalk.ts';
58import { decodeCacheFile } from './cache/h5file.ts';
59import { SolverSession } from './cache/solver.ts';
60import { runSpec, type RunEvents, type RunOutcome, type RunSummary } from './cache/runSpec.ts';
61import { fillWalk } from './cache/fillWalk.ts';
63const $ = <T extends HTMLElement>(id: string): T =>
64 document.getElementById(id) as T;
66const elModel = $<HTMLSelectElement>('model');
67const elParams = $('params');
68const elGeometry = $<HTMLSelectElement>('geometry');
69const elGeomParams = $('geomparams');
70const elSeed = $<HTMLSelectElement>('seed');
71const elTend = $<HTMLSelectElement>('tend');
72const elSolve = $<HTMLButtonElement>('solve');
73const elStop = $<HTMLButtonElement>('stop');
74const elReset = $<HTMLButtonElement>('reset');
75const elCacheNote = $('cachenote');
76const elStatus = $('status');
77const elPanels = $('panels');
78const elResetView = $<HTMLButtonElement>('resetview');
79const elDownload = $<HTMLAnchorElement>('download');
80const elStats = $('stats');
81const elApiKey = $<HTMLInputElement>('apikey');
82const elUploadNote = $('uploadnote');
83const elAutoBar = $('autobar');
84const elAuto = $<HTMLButtonElement>('auto');
85const elAutoNote = $('autonote');
86const elErr = $('err');
88/**
89 * Test/debug hook: `?tend=5,10` replaces the end-time list with the given
90 * values (still cached under their own honest specs — a test end time hashes
91 * to its own object). The headless checks use this to keep their computed
92 * runs short; it is not part of the normal UI.
93 */
95 const param = new URLSearchParams(location.search).get('tend');
96 if (param) {
97 const values = param
98 .split(',')
99 .map(Number)
100 .filter((v) => Number.isFinite(v) && v > 0);
101 if (values.length) {
102 T_END_CHOICE.values = values;
103 T_END_CHOICE.value = values[0];
104 }
105 }
108const API_KEY_STORAGE = `${APP_NAME}:apiKey`;
109const COLORMAP = colormaps.viridis;
110/** Render on a 2x finer grid than the solver's; exact interpolation. */
111const OVERSAMPLE = 2;
112/** How often the live view renders during a computation. */
113const RENDER_EVERY_MS = 250;
114/** How often the status line is rewritten during a computation. */
115const STATUS_EVERY_MS = 200;
117// ---------------------------------------------------------------- state
118let model: MModel = mModelByKey(DEFAULT_MODEL_KEY)!;
119let device: GPUDevice | null = null;
120/** The compiled solver and what it has applied (src/cache/solver.ts). */
121let solver: SolverSession | null = null;
122let adapterName = '';
123/** The live session, or null before the GPU is up. */
124function sess(): ModelSession | null {
125 return solver?.session ?? null;
128/** The discrete selections, always exactly values from options.ts. */
129let params: Params = defaultChoiceParams(MODEL_CHOICES[DEFAULT_MODEL_KEY]);
130let geometry: MGeometry = mGeometryByKey(DEFAULT_GEOMETRY_KEY)!;
131let geomParams: Params = Object.fromEntries(
132 GEOMETRY_CHOICES[DEFAULT_GEOMETRY_KEY].map((c) => [c.key, c.value]),
133);
134let seed = SEED_CHOICE.value;
135let tEnd = T_END_CHOICE.value;
137// The URL fragment carries the whole selection, so a reload comes back to it
138// and a shared link opens on the same spec (and, through refresh(), the same
139// cached solution). Read once at startup; rewritten on every change.
140readUrlState();
142let topo: SphereMeshTopology | null = null;
143let scenes: SphereScene[] = [];
144let colorbars: Colorbar[] = [];
145/** The colorbar containers, hidden while the windows are empty. */
146let colorbarEls: HTMLElement[] = [];
147let valueBufs: Float32Array[] = [];
148let colorBufs: Float32Array[] = [];
149let ranges: { lo: number; hi: number }[] = [];
150let resizeObs: ResizeObserver | null = null;
151let coords: Float32Array | null = null;
152let posBuf: Float32Array | null = null;
154let generation = 0;
155let busy = false;
156/** True while computeLocally is stepping/reading back. Every read shares one
157 * staging buffer (GpuModel#readback), so a new solve must drain the old
158 * loop before issuing reads of its own. */
159let pumping = false;
160let stopRequested = false;
161/** Set while the auto-fill walk owns the page (see autoRun). */
162let autoRunning = false;
163let autoComputed = 0;
164let autoSkipped = 0;
165let autoFailed = 0;
166/** Simulation time of the state on display (loadState resets session.t). */
167let shownT: number | null = null;
168let downloadUrl: string | null = null;
170const nextFrame = () => new Promise<number>(requestAnimationFrame);
172// ---------------------------------------------------------------- spec
173function currentSpec(): CacheSpec {
174 return {
175 app: APP_NAME,
176 formatVersion: FORMAT_VERSION,
177 model: model.key,
178 params: { ...params },
179 geometry: geometry.key,
180 geometryParams: { ...geomParams },
181 lmax: LMAX,
182 niter: NITER,
183 lam3: LAM3,
184 seed,
185 tEnd,
186 };
189// ---------------------------------------------------------------- URL state
190/**
191 * The selection lives in the URL fragment, every value written explicitly
192 * (`#a=0.1&b=0.9&…&geometry=ellipsoid&ax=1.5&…&seed=1&tend=100`), so a link
193 * keeps meaning the same spec even if a default changes later. The fragment
194 * is chosen over the query string to leave `?tend` to the test hook. Values
195 * are only accepted if they are exactly entries of the discrete lists;
196 * anything else keeps the default.
197 */
198function readUrlState(): void {
199 const hash = location.hash.replace(/^#/, '');
200 if (!hash) return;
201 const p = new URLSearchParams(hash);
202 // `name` is the key as it appears in the URL; it defaults to the choice's
203 // own key but is passed explicitly where the two differ (tEnd vs tend).
204 const pick = (choice: DiscreteChoice, current: number, name = choice.key): number => {
205 const raw = p.get(name);
206 if (raw === null) return current;
207 const v = Number(raw);
208 return choice.values.includes(v) ? v : current;
209 };
210 const m = p.get('model');
211 if (m && mModelByKey(m) && MODEL_CHOICES[m]) {
212 model = mModelByKey(m)!;
213 params = defaultChoiceParams(MODEL_CHOICES[m]);
214 }
215 const g = p.get('geometry');
216 if (g && mGeometryByKey(g) && GEOMETRY_CHOICES[g]) {
217 geometry = mGeometryByKey(g)!;
218 geomParams = defaultChoiceParams(GEOMETRY_CHOICES[g]);
219 }
220 for (const c of MODEL_CHOICES[model.key]) params[c.key] = pick(c, params[c.key]);
221 for (const c of GEOMETRY_CHOICES[geometry.key]) geomParams[c.key] = pick(c, geomParams[c.key]);
222 seed = pick(SEED_CHOICE, seed);
223 tEnd = pick(T_END_CHOICE, tEnd, 'tend');
226function writeUrlState(): void {
227 const p = new URLSearchParams();
228 p.set('model', model.key);
229 for (const c of MODEL_CHOICES[model.key]) p.set(c.key, fmtChoice(params[c.key]));
230 p.set('geometry', geometry.key);
231 for (const c of GEOMETRY_CHOICES[geometry.key]) p.set(c.key, fmtChoice(geomParams[c.key]));
232 p.set('seed', String(seed));
233 p.set('tend', fmtChoice(tEnd));
234 history.replaceState(null, '', `${location.pathname}${location.search}#${p.toString()}`);
237// ---------------------------------------------------------------- controls
238/** Every select made by makeSelect, so a reset can push new values into the
239 * ones still on the page. */
240const boundSelects: { el: HTMLSelectElement; get: () => number }[] = [];
242function syncSelects(): void {
243 // Pruned as it goes: auto mode rebuilds the parameter controls once per
244 // target, so entries for replaced selects would otherwise pile up.
245 for (let i = boundSelects.length - 1; i >= 0; i--) {
246 const b = boundSelects[i];
247 if (b.el.isConnected) b.el.value = String(b.get());
248 else boundSelects.splice(i, 1);
249 }
252function makeSelect(
253 choice: DiscreteChoice,
254 get: () => number,
255 set: (v: number) => void,
256): HTMLLabelElement {
257 const label = document.createElement('label');
258 label.textContent = `${choice.label} `;
259 const select = document.createElement('select');
260 for (const v of choice.values) {
261 const opt = document.createElement('option');
262 opt.value = String(v);
263 opt.textContent = fmtChoice(v);
264 select.append(opt);
265 }
266 select.value = String(get());
267 select.addEventListener('change', () => {
268 set(Number(select.value));
269 onSelectionChange();
270 });
271 label.append(select);
272 boundSelects.push({ el: select, get });
273 return label;
276/** Put every selection back to its default, without refreshing the display. */
277function applyDefaults(): void {
278 model = mModelByKey(DEFAULT_MODEL_KEY)!;
279 params = defaultChoiceParams(MODEL_CHOICES[DEFAULT_MODEL_KEY]);
280 geometry = mGeometryByKey(DEFAULT_GEOMETRY_KEY)!;
281 elModel.value = model.key;
282 buildModelParamControls();
283 geomParams = defaultChoiceParams(GEOMETRY_CHOICES[DEFAULT_GEOMETRY_KEY]);
284 seed = SEED_CHOICE.value;
285 tEnd = T_END_CHOICE.value;
286 elGeometry.value = geometry.key;
287 buildGeomParamControls();
288 elSeed.value = String(seed);
289 elTend.value = String(tEnd);
290 syncSelects();
291 writeUrlState();
294/** The Reset button: back to the defaults, and show what is there. */
295function resetDefaults(): void {
296 applyDefaults();
297 onSelectionChange();
300/** Point every control at one walk target (auto mode drives the same
301 * selection the user otherwise would, so the URL and the dropdowns always
302 * say what is being computed). */
303function setSelection(t: AutoTarget): void {
304 const nextModel = mModelByKey(t.model)!;
305 if (nextModel !== model) {
306 model = nextModel;
307 elModel.value = model.key;
308 buildModelParamControls();
309 }
310 params = { ...t.params };
311 const nextGeom = mGeometryByKey(t.geometry)!;
312 if (nextGeom !== geometry) {
313 geometry = nextGeom;
314 elGeometry.value = geometry.key;
315 buildGeomParamControls();
316 }
317 geomParams = { ...t.geometryParams };
318 seed = AUTO_SEED;
319 elSeed.value = String(seed);
320 tEnd = Math.max(...T_END_CHOICE.values);
321 elTend.value = String(tEnd);
322 syncSelects();
323 writeUrlState();
326function buildModelParamControls(): void {
327 elParams.replaceChildren();
328 for (const choice of MODEL_CHOICES[model.key]) {
329 elParams.append(
330 makeSelect(choice, () => params[choice.key], (v) => (params[choice.key] = v)),
331 );
332 }
335function buildControls(): void {
336 for (const m of mModels) {
337 const opt = document.createElement('option');
338 opt.value = m.key;
339 opt.textContent = m.label;
340 elModel.append(opt);
341 }
342 elModel.value = model.key;
343 elModel.addEventListener('change', () => {
344 model = mModelByKey(elModel.value)!;
345 params = defaultChoiceParams(MODEL_CHOICES[model.key]);
346 buildModelParamControls();
347 onSelectionChange();
348 });
349 buildModelParamControls();
350 for (const g of mGeometries) {
351 const opt = document.createElement('option');
352 opt.value = g.key;
353 opt.textContent = g.label.toLowerCase();
354 elGeometry.append(opt);
355 }
356 elGeometry.value = geometry.key;
357 elGeometry.addEventListener('change', () => {
358 geometry = mGeometryByKey(elGeometry.value)!;
359 geomParams = Object.fromEntries(
360 GEOMETRY_CHOICES[geometry.key].map((c) => [c.key, c.value]),
361 );
362 buildGeomParamControls();
363 onSelectionChange();
364 });
365 buildGeomParamControls();
367 for (const v of SEED_CHOICE.values) {
368 const opt = document.createElement('option');
369 opt.value = String(v);
370 opt.textContent = String(v);
371 elSeed.append(opt);
372 }
373 elSeed.value = String(seed);
374 elSeed.addEventListener('change', () => {
375 seed = Number(elSeed.value);
376 onSelectionChange();
377 });
379 for (const v of T_END_CHOICE.values) {
380 const opt = document.createElement('option');
381 opt.value = String(v);
382 opt.textContent = String(v);
383 elTend.append(opt);
384 }
385 elTend.value = String(tEnd);
386 elTend.addEventListener('change', () => {
387 tEnd = Number(elTend.value);
388 onSelectionChange();
389 });
392function buildGeomParamControls(): void {
393 elGeomParams.replaceChildren();
394 for (const choice of GEOMETRY_CHOICES[geometry.key]) {
395 elGeomParams.append(
396 makeSelect(choice, () => geomParams[choice.key], (v) => (geomParams[choice.key] = v)),
397 );
398 }
401/**
402 * A selection change refreshes the display: a cached solution loads and
403 * shows immediately, an uncached one shows empty surfaces until the user
404 * explicitly presses Compute solution. While a computation is running the
405 * change touches nothing — the run keeps going and only the is-it-cached
406 * note follows the dropdowns.
407 *
408 * Refreshes and button presses are chained so two flows never talk to the
409 * session at once.
410 */
411let flowChain: Promise<void> = Promise.resolve();
412function onSelectionChange(): void {
413 writeUrlState();
414 // During a computation the refresh is deferred until the run finishes; the
415 // is-it-cached note should follow the dropdowns right away regardless.
416 if (busy) void updateCacheNote();
417 flowChain = flowChain.then(() => refresh()).catch(() => undefined);
420// The note carries a token so a slow HEAD for a superseded selection never
421// overwrites the note for the current one.
422let cacheNoteToken = 0;
423async function updateCacheNote(): Promise<void> {
424 const token = ++cacheNoteToken;
425 elCacheNote.textContent = '';
426 let lookup: CacheLookup;
427 try {
428 lookup = await lookupFor(currentSpec());
429 } catch {
430 return;
431 }
432 const present = await headCached(lookup);
433 if (token !== cacheNoteToken) return;
434 setCacheNote(present);
437function setCacheNote(present: boolean | null): void {
438 if (present === true) {
439 elCacheNote.innerHTML = '<b>✓ in the cloud cache</b>';
440 } else if (present === false) {
441 elCacheNote.textContent = 'not cached yet';
442 } else {
443 elCacheNote.textContent = '';
444 }
447// ---------------------------------------------------------------- view
448function disposeView(): void {
449 for (const s of scenes) s.dispose();
450 scenes = [];
451 colorbars = [];
452 colorbarEls = [];
453 topo = null;
454 coords = null;
455 posBuf = null;
456 resizeObs?.disconnect();
457 resizeObs = null;
458 elPanels.replaceChildren();
461function buildView(surface: Float32Array): void {
462 const session = sess();
463 if (!session) return;
464 const view = session.viewSht;
465 const { nphi } = view.cfg;
466 const phi = new Float64Array(nphi);
467 for (let j = 0; j < nphi; j++) phi[j] = (2 * Math.PI * j) / nphi;
468 topo = buildTopology(view.cosTheta, phi);
469 coords = surface;
470 posBuf = new Float32Array(topo.numVertices * 3);
471 fillPositions(posBuf, coords, topo, 1);
473 const sphereBg = getComputedStyle(document.documentElement)
474 .getPropertyValue('--sphere-bg')
475 .trim();
476 for (let k = 0; k < model.species.length; k++) {
477 const panel = document.createElement('div');
478 panel.className = 'panel';
479 const box = document.createElement('div');
480 box.className = 'sphere-box';
481 const tag = document.createElement('div');
482 tag.className = 'species-tag';
483 tag.textContent = model.species[k];
484 box.append(tag);
485 const side = document.createElement('div');
486 panel.append(box, side);
487 elPanels.append(panel);
489 const scene = new SphereScene(
490 box,
491 topo.numVertices,
492 topo.indices,
493 Float32Array.from(posBuf),
494 sphereBg || undefined,
495 );
496 scene.fitCamera();
497 scenes.push(scene);
498 colorbars.push(new Colorbar(side));
499 colorbarEls.push(side);
500 valueBufs[k] = new Float32Array(topo.numVertices);
501 colorBufs[k] = new Float32Array(topo.numVertices * 3);
502 ranges[k] = { lo: NaN, hi: NaN };
503 }
504 for (let k = 1; k < scenes.length; k++) scenes[0].syncCamerasWith(scenes[k]);
506 resizeObs = new ResizeObserver(() => {
507 const boxes = elPanels.querySelectorAll<HTMLElement>('.sphere-box');
508 boxes.forEach((box, i) => {
509 scenes[i]?.resize(box.clientWidth, box.clientHeight);
510 });
511 });
512 elPanels
513 .querySelectorAll<HTMLElement>('.sphere-box')
514 .forEach((box) => resizeObs!.observe(box));
517async function draw(): Promise<void> {
518 const session = sess();
519 if (!session || !topo) return;
520 const gen = generation;
521 for (let k = 0; k < model.species.length; k++) {
522 let field: Float32Array;
523 try {
524 field = await session.readSpecies(k);
525 } catch (e) {
526 if (gen !== generation) return;
527 throw e;
528 }
529 if (gen !== generation || !topo) return;
530 fillFieldValues(valueBufs[k], field, topo);
531 let lo = Infinity;
532 let hi = -Infinity;
533 for (const v of valueBufs[k]) {
534 if (v < lo) lo = v;
535 if (v > hi) hi = v;
536 }
537 // Smooth the color range in both directions so the shading evolves gently
538 // as the pattern grows (out-of-range values clamp meanwhile).
539 const r = ranges[k];
540 if (!Number.isFinite(r.lo)) {
541 r.lo = lo;
542 r.hi = hi;
543 } else {
544 const a = 0.15;
545 r.lo += a * (lo - r.lo);
546 r.hi += a * (hi - r.hi);
547 }
548 const shown = floorRange(r.lo, r.hi);
549 fillColors(colorBufs[k], valueBufs[k], shown.lo, shown.hi, COLORMAP);
550 scenes[k]?.updateColors(colorBufs[k]);
551 colorbars[k]?.update(COLORMAP, shown.lo, shown.hi);
552 if (colorbarEls[k]) colorbarEls[k].style.visibility = '';
553 }
556/** Empty windows: the selected surface with no field on it. Shown when the
557 * selection has no cached solution and nothing has been computed yet. */
558function clearDisplay(): void {
559 shownT = null;
560 elDownload.hidden = true;
561 if (!topo) return;
562 for (let k = 0; k < model.species.length; k++) {
563 // NaN renders as neutral gray in fillColors — the shape without a field.
564 valueBufs[k].fill(NaN);
565 fillColors(colorBufs[k], valueBufs[k], 0, 1, COLORMAP);
566 scenes[k]?.updateColors(colorBufs[k]);
567 if (colorbarEls[k]) colorbarEls[k].style.visibility = 'hidden';
568 }
569 updateStats();
572function resetRanges(): void {
573 for (const r of ranges) {
574 r.lo = NaN;
575 r.hi = NaN;
576 }
579function updateStats(): void {
580 const session = sess();
581 if (!session) return;
582 const { nlat, nphi } = session.cfg;
583 const kind = `WebGPU fp32${adapterName ? ` — ${adapterName}` : ''}`;
584 const t = shownT !== null ? ` · showing t = <b>${fmtChoice(shownT)}</b>` : '';
585 elStats.innerHTML =
586 `<b>${kind}</b> · grid ${nlat}×${nphi} · lmax ${LMAX} · ` +
587 `solve iters ${NITER}${t}`;
590// ---------------------------------------------------------------- statuses
591function status(html: string): void {
592 elStatus.innerHTML = html;
595function setBusy(next: boolean): void {
596 busy = next;
597 elSolve.disabled = next;
598 elStop.hidden = !next;
601function offerDownload(bytes: Uint8Array, name: string): void {
602 if (downloadUrl) URL.revokeObjectURL(downloadUrl);
603 downloadUrl = URL.createObjectURL(new Blob([bytes as BlobPart], { type: 'application/x-hdf5' }));
604 elDownload.href = downloadUrl;
605 elDownload.download = name;
606 elDownload.hidden = false;
609// ---------------------------------------------------------------- solving
610/** Rebuild the mesh and panels from the session's current surface, keeping
611 * the camera. Fresh buffers render black until the first fill, so the bare
612 * surface is shown; the caller's draw or clearDisplay follows right behind. */
613async function rebuildViewFromSession(): Promise<void> {
614 const session = sess();
615 if (!session) return;
616 const surface = await session.renderPositions();
617 const cam = scenes[0]?.cameraState();
618 disposeView();
619 buildView(surface);
620 if (cam) for (const s of scenes) s.setCameraState(cam);
621 clearDisplay();
624/**
625 * Apply a selection to the solver. Which changes are cheap and which pay a
626 * recompile is the solver's business (src/cache/solver.ts); the page adds the
627 * compiling status and the rebuilt view through the events it installs in
628 * boot(), since the panel count follows the model's species (Allen–Cahn has
629 * one).
630 */
631async function applySelection(spec: CacheSpec): Promise<void> {
632 if (!solver) throw new Error('no GPU device');
633 await solver.apply(spec);
636/** Decode a fetched cache file and put it on screen. */
637async function displayCached(
638 bytes: Uint8Array,
639 lookup: CacheLookup,
640 spec: CacheSpec,
641 gen: number,
642): Promise<void> {
643 const session = sess();
644 if (!session) return;
645 const decoded = await decodeCacheFile(bytes, lookup.specJson, model.state);
646 if (gen !== generation) return;
647 session.loadState(decoded.final);
648 shownT = spec.tEnd;
649 resetRanges();
650 await draw();
651 updateStats();
652 const kb = (bytes.length / 1024).toFixed(0);
653 const from = decoded.adapter ? `, computed on ${decoded.adapter}` : '';
654 const when = decoded.created ? ` ${decoded.created.slice(0, 10)}` : '';
655 status(
656 `<b>t = ${fmtChoice(spec.tEnd)}</b> — from the <b>cloud cache</b> ` +
657 `(${kb} KB${from}${when}).`,
658 );
659 offerDownload(bytes, lookup.fileName.split('/').pop()!);
662/**
663 * Bring the display in line with the current selection, without ever
664 * starting a computation: a cached solution loads and shows, an uncached one
665 * shows empty surfaces and waits for the Compute solution button. Runs on
666 * startup and on every selection change; a no-op while a computation is
667 * running (the run is not disturbed — only the cache note follows).
668 */
669async function refresh(): Promise<void> {
670 // Before the GPU is up there is nothing to refresh; while a computation
671 // runs the note follows the dropdowns and the refresh waits its turn. A
672 // missing session is NOT a reason to bail: applySelection rebuilds it,
673 // which is also what recovers from a failed compile.
674 if (!device || busy) {
675 void updateCacheNote();
676 return;
677 }
678 generation++;
679 const gen = generation;
680 elErr.textContent = '';
681 const spec = currentSpec();
682 try {
683 const lookup = await lookupFor(spec);
684 status('checking the cloud cache…');
685 let bytes: Uint8Array | null = null;
686 let unreachable = false;
687 try {
688 bytes = await fetchCached(lookup);
689 } catch {
690 unreachable = true;
691 }
692 if (gen !== generation) return;
693 await applySelection(spec);
694 if (gen !== generation) return;
695 if (bytes) {
696 await displayCached(bytes, lookup, spec, gen);
697 setCacheNote(true);
698 return;
699 }
700 clearDisplay();
701 setCacheNote(unreachable ? null : false);
702 status(
703 unreachable
704 ? 'cloud cache unreachable — <b>Compute solution</b> runs it in your browser.'
705 : `not in the cloud cache — press <b>Compute solution</b> to run it in ` +
706 `your browser (up to ${stepsFor(spec).toLocaleString()} steps; a ` +
707 `cached shorter run of the same settings is picked up where it left off).`,
708 );
709 } catch (e) {
710 if (gen === generation) {
711 elErr.textContent = formatFailure(e, model.source);
712 status('failed.');
713 }
714 }
717/** The Compute solution button: cache lookup, then either load or compute. */
718async function solve(): Promise<void> {
719 if (!device || busy) return;
720 generation++;
721 const gen = generation;
722 setBusy(true);
723 // A stopped run may still be inside an await; let it see the generation
724 // bump and finish before touching the session.
725 while (pumping) await nextFrame();
726 if (gen !== generation) return;
727 stopRequested = false;
728 elErr.textContent = '';
729 elDownload.hidden = true;
730 const spec = currentSpec();
731 try {
732 const lookup = await lookupFor(spec);
733 status('checking the cloud cache…');
734 let bytes: Uint8Array | null = null;
735 try {
736 bytes = await fetchCached(lookup);
737 } catch (e) {
738 // An unreachable cache degrades to computing locally, and says so.
739 status(`cache unreachable (${e instanceof Error ? e.message : e}) — computing locally`);
740 }
741 if (gen !== generation) return;
742 await applySelection(spec);
743 if (gen !== generation) return;
745 if (bytes) {
746 await displayCached(bytes, lookup, spec, gen);
747 return;
748 }
749 await computeLocally(spec, gen);
750 } catch (e) {
751 if (gen === generation) {
752 elErr.textContent = formatFailure(e, model.source);
753 status('failed.');
754 }
755 } finally {
756 if (gen === generation) setBusy(false);
757 void updateCacheNote();
758 }
761/**
762 * How the page tells a run in progress: the status line, the live view, and
763 * when to give up. The same events drive the Compute solution button and the
764 * auto-fill walk, so the two report a run identically.
765 *
766 * `gen` is read afresh at every check rather than captured, so the events a
767 * walk installs once still speak for whichever target is current.
768 */
769function runEvents(gen: () => number): RunEvents {
770 let lastStatus = 0;
771 let lastDraw = 0;
772 return {
773 onPhase(phase) {
774 if (phase.kind === 'warm-search') {
775 status('not in the cache — looking for a shorter cached run…');
776 } else if (phase.kind === 'seeding') {
777 status('not in the cache — <b>computing locally</b>: seeding…');
778 } else if (phase.kind === 'encoding') {
779 status(`${doneLine(phase.run)} Writing the cache file…`);
780 } else {
781 status(
782 `${doneLine(phase.run)} Uploading to the cache ` +
783 `(${phase.uploaded}/${phase.started})…`,
784 );
785 }
786 },
787 onProgress(p) {
788 const now = performance.now();
789 if (now - lastStatus < STATUS_EVERY_MS) return;
790 lastStatus = now;
791 const from = p.warmFrom !== null ? `resumed from cached t = ${fmtChoice(p.warmFrom)} — ` : '';
792 const up = p.uploadsStarted
793 ? `, uploaded ${p.uploadsDone}/${p.uploadsStarted} snapshots`
794 : '';
795 status(
796 `not in the cache — <b>computing locally</b> (${from}` +
797 `t = ${p.t.toFixed(2)} / ${fmtChoice(p.tEnd)}, ${(100 * p.fraction).toFixed(0)}%, ` +
798 `${p.rate.toFixed(0)} steps/s${up})`,
799 );
800 },
801 onStepping() {
802 shownT = null;
803 resetRanges();
804 },
805 async onTick() {
806 // Rendering is skipped entirely while the page is hidden, and the loop
807 // never waits on an animation frame there: a backgrounded tab throttles
808 // or stops requestAnimationFrame, which would stall an unattended run.
809 // The GPU sync inside the run already yields to the event loop, so Stop
810 // stays responsive either way.
811 const now = performance.now();
812 if (document.hidden || now - lastDraw <= RENDER_EVERY_MS) return;
813 lastDraw = now;
814 await draw();
815 if (gen() !== generation) return;
816 await nextFrame();
817 },
818 async onFinal(tEnd) {
819 shownT = tEnd;
820 await draw();
821 updateStats();
822 },
823 onFile(bytes, name) {
824 offerDownload(bytes, name);
825 },
826 cancelled: () => gen() !== generation,
827 stopRequested: () => stopRequested,
828 };
831/** The first sentence of every finished run's status. */
832function doneLine(run: RunSummary): string {
833 return (
834 `<b>t = ${fmtChoice(run.tEnd)}</b> — computed locally in ${run.seconds.toFixed(1)} s` +
835 (run.warmFrom !== null ? ` (resumed from cached t = ${fmtChoice(run.warmFrom)})` : '') +
836 `.`
837 );
840/** Say how a finished run ended. Returns nothing; the caller counts. */
841async function reportOutcome(outcome: RunOutcome): Promise<void> {
842 if (outcome.kind === 'abandoned') return;
843 if (outcome.kind === 'diverged') {
844 elErr.textContent =
845 `the solution went non-finite at t = ${outcome.t.toFixed(2)} — nothing uploaded ` +
846 `(this combination is unstable at dt = ${fmtChoice(AUTO_DT)})`;
847 status('diverged.');
848 return;
849 }
850 if (outcome.kind === 'stopped') {
851 shownT = outcome.t;
852 await draw();
853 updateStats();
854 const n = outcome.uploaded.length;
855 const up = n ? ` ${n} snapshot${n > 1 ? 's' : ''} already uploaded.` : ' Nothing uploaded.';
856 status(`stopped at t = ${outcome.t.toFixed(2)}.${up}`);
857 return;
858 }
859 const line = doneLine(outcome);
860 if (outcome.uploadsStarted === 0) {
861 status(`${line} Not uploaded (no API key).`);
862 return;
863 }
864 if (outcome.uploadErrors.length) {
865 elErr.textContent = `upload: ${outcome.uploadErrors.join('; ')}`;
866 }
867 const n = outcome.uploaded.length;
868 if (n > 0) {
869 const times = [...outcome.uploaded].sort((a, b) => a - b).map(fmtChoice).join(', ');
870 const failed = outcome.uploadErrors.length
871 ? ` (${outcome.uploadErrors.length} failed)`
872 : '';
873 status(
874 `${line} <b>Uploaded ${n} solution${n > 1 ? 's' : ''}</b> ` +
875 `to the shared cache (t = ${times})${failed}.`,
876 );
877 } else {
878 status(`${line} Uploads failed.`);
879 }
882/**
883 * Run the solver to the spec's end time, watching the pattern form, and
884 * capture the state at every smaller listed end time on the way
885 * (src/cache/runSpec.ts). Everything the page adds is in runEvents and
886 * reportOutcome.
887 */
888async function computeLocally(spec: CacheSpec, gen: number): Promise<RunOutcome> {
889 if (!solver?.session) return { kind: 'abandoned' };
890 pumping = true;
891 try {
892 const outcome = await runSpec({
893 solver,
894 spec,
895 adapter: adapterName,
896 apiKey: () => elApiKey.value.trim(),
897 events: runEvents(() => gen),
898 });
899 if (gen === generation) await reportOutcome(outcome);
900 return outcome;
901 } finally {
902 pumping = false;
903 }
906// ---------------------------------------------------------------- auto-fill
907function autoNote(target: AutoTarget | null): void {
908 if (!autoRunning) {
909 elAutoNote.textContent = autoComputed || autoSkipped
910 ? `stopped — computed ${autoComputed}, skipped ${autoSkipped} already cached` +
911 (autoFailed ? `, ${autoFailed} failed` : '')
912 : '';
913 return;
914 }
915 const where = target
916 ? `${mModelByKey(target.model)!.label} on ${target.geometry}, ${target.distance} ` +
917 `knob${target.distance === 1 ? '' : 's'} from the defaults`
918 : '';
919 elAutoNote.textContent =
920 `auto-filling — computed ${autoComputed}, skipped ${autoSkipped}` +
921 (autoFailed ? `, ${autoFailed} failed` : '') + (where ? ` · ${where}` : '');
924function setAutoUi(on: boolean): void {
925 elAuto.textContent = on ? 'Auto-filling…' : 'Auto-fill the cache';
926 elAuto.disabled = on;
927 elReset.disabled = on;
930/**
931 * Walk the parameter space on this machine, computing and contributing
932 * whatever is not cached yet, nearest the defaults first and randomly within
933 * a distance (src/cache/autoWalk.ts, src/cache/fillWalk.ts). Runs until
934 * stopped.
935 *
936 * Every target is driven through the same selection the user would set by
937 * hand, so the dropdowns and the URL always say what is being computed, and
938 * the run itself is the ordinary local computation — including its background
939 * uploads, its warm start from a shorter cached run, and its divergence
940 * guard.
941 */
942async function autoRun(): Promise<void> {
943 if (!device || !solver || busy || autoRunning) return;
944 if (!elApiKey.value.trim()) return;
945 autoRunning = true;
946 autoComputed = autoSkipped = autoFailed = 0;
947 setAutoUi(true);
948 setBusy(true);
949 elErr.textContent = '';
950 // Start from a defined point — which is also the first target, since the
951 // defaults are the one combination at distance zero.
952 applyDefaults();
953 autoNote(null);
955 // The generation of the target being computed, read by the run events.
956 let walkGen = 0;
957 await fillWalk({
958 targets: autoOrder(),
959 solver,
960 adapter: adapterName,
961 apiKey: () => elApiKey.value.trim(),
962 beforeTarget(target) {
963 setSelection(target);
964 autoNote(target);
965 generation++;
966 walkGen = generation;
967 stopRequested = false;
968 return currentSpec();
969 },
970 events: {
971 ...runEvents(() => walkGen),
972 onTarget: () => status('checking the cloud cache…'),
973 onCached: (target) => {
974 autoSkipped++;
975 setCacheNote(true);
976 autoNote(target);
977 },
978 onComputing: () => setCacheNote(false),
979 onOutcome: (target, _spec, outcome) => {
980 if (outcome.kind === 'done') autoComputed++;
981 else if (outcome.kind === 'diverged') autoFailed++;
982 autoNote(target);
983 },
984 onFailure: (target, spec, e) => {
985 autoFailed++;
986 elErr.textContent =
987 `auto (${spec.model}, ${spec.geometry}): ${formatFailure(e, model.source)}`;
988 autoNote(target);
989 },
990 walkStopped: () => !autoRunning,
991 },
992 });
994 autoRunning = false;
995 setAutoUi(false);
996 setBusy(false);
997 autoNote(null);
1000// ---------------------------------------------------------------- boot
1001elAuto.addEventListener('click', () => {
1002 flowChain = flowChain.then(() => autoRun()).catch(() => undefined);
1003});
1004elSolve.addEventListener('click', () => {
1005 flowChain = flowChain.then(() => solve()).catch(() => undefined);
1006});
1007elStop.addEventListener('click', () => {
1008 stopRequested = true;
1009 autoRunning = false;
1010 setBusy(false);
1011});
1012elReset.addEventListener('click', () => resetDefaults());
1013elResetView.addEventListener('click', () => {
1014 for (const s of scenes) s.resetCamera();
1015});
1016// The view is not drawn while the page is hidden, so it is stale on return.
1017// Not while a run is reading back: every read shares one staging buffer.
1018document.addEventListener('visibilitychange', () => {
1019 if (!document.hidden && sess() && !pumping) void draw();
1020});
1021elApiKey.addEventListener('change', () => {
1022 const key = elApiKey.value.trim();
1023 if (key) localStorage.setItem(API_KEY_STORAGE, key);
1024 else localStorage.removeItem(API_KEY_STORAGE);
1025 updateUploadNote();
1026});
1028function updateUploadNote(): void {
1029 const hasKey = elApiKey.value.trim().length > 0;
1030 elUploadNote.textContent = hasKey
1031 ? 'uploads enabled — locally computed solutions will be contributed'
1032 : '';
1033 // Auto-fill exists to contribute, so it is offered only to those who can.
1034 elAutoBar.hidden = !hasKey;
1035 if (!hasKey && autoRunning) autoRunning = false;
1038async function boot(): Promise<void> {
1039 buildControls();
1040 // Written even before any change, so the address bar is always shareable.
1041 writeUrlState();
1042 elApiKey.value = localStorage.getItem(API_KEY_STORAGE) ?? '';
1043 updateUploadNote();
1044 void updateCacheNote();
1045 try {
1046 device = await requestShtDevice();
1047 // Before the adapter is even described, so a selection change during boot
1048 // finds a solver to apply itself to rather than an error.
1049 solver = new SolverSession(device, OVERSAMPLE, {
1050 onCompiling: (m) => status(`compiling ${m.label}…`),
1051 onSurface: () => rebuildViewFromSession(),
1052 });
1053 adapterName = await describeAdapter(device);
1054 } catch (e) {
1055 device = null;
1056 solver = null;
1057 elErr.textContent =
1058 `WebGPU is not available (${e instanceof Error ? e.message : e}). ` +
1059 `Use a WebGPU-capable browser such as Chrome or Edge.`;
1060 return;
1062 device.lost.then((info) => {
1063 if (info.reason !== 'destroyed') {
1064 elErr.textContent = `WebGPU device lost: ${info.message}`;
1066 });
1068 try {
1069 await applySelection(currentSpec());
1070 } catch (e) {
1071 elErr.textContent = formatFailure(e, model.source);
1072 status('failed to compile.');
1073 return;
1075 // Bring up the default selection if it is cached; otherwise show empty
1076 // surfaces. Nothing is ever computed without pressing the button.
1077 flowChain = flowChain.then(() => refresh()).catch(() => undefined);
1078 await flowChain;
1081void boot();
moveopenescclose