/ proof-of-concept / toy-instruments
Sign in
proof-of-concept / toy-instruments
3D model view: setup (supports, pins, excitation, pickup) and replay of saved frames on a shared timeline
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zsyegMfQnzJEzgMiFLzew
Jeremy Magland <jeremy.magland@gmail.com> committed commit d2bfcd7a7627 parent 4a8abcf Browse files
9 changed files+796−94
README.mdmodified+2−1View file
@@ -17,6 +17,7 @@ npm run build # static site in dist/
1717 - Damping from two decay times: `sigma(omega) = sigma0 + sigma1 omega^2`, fitted to T60 at two frequencies for transverse modes. `sigma0` is mass-proportional; `sigma1` is a dashpot on each spring's relative velocity.
1818 - Audio: pickup acceleration (forces / mass, at the full-step velocity), from one bead or summed over all beads, along y, z or x. The `.wav` is normalized to -1 dBFS.
1919 - Solver: velocity Verlet (explicit, symplectic without damping). `dt = cfl * 2 / omega_max` (Gershgorin bound with axial `k` and transverse `T/l`), rounded down to a whole number of steps per audio sample.
20+- 3D model view (three.js): Setup shows the beads at rest with supports (wall = clamped, translucent plane + ring = sliding), pinned beads (cubes), excitation (pluck shape, strike velocity arrows, hammer force arrows) and pickup (bead + axis arrow). Replay animates the saved frames ("Replay frames" per second, default 2000) with adjustable exaggeration, on a timeline shared with the 2D side/top views and space-time image.
2021 - Checks shown per run: energy balance (energy + dissipated - work in - initial) relative to peak energy, largest strain. Linear transverse and longitudinal modes (Sturm bisection on the tridiagonal stiffness) are marked on the spectrum.
2122
2223 Tests (`test/string.test.ts`): modes vs the discrete-string formula; small mode-1 motion vs `cos(omega_1 t)`; energy balance with and without damping/forcing; decay at the requested T60.
@@ -25,4 +26,4 @@ Tests (`test/string.test.ts`): modes vs the discrete-string formula; small mode-
2526
2627 - `src/sim/` generic 3D bead-spring system, solver, worker.
2728 - `src/instruments/<id>/` model (parameters to system), presets, page. Register new instruments in `src/instruments/registry.ts`.
28-- `src/ui/` reusable controls form, line plot, spectrogram, chain displacement viewer, FFT, WAV.
29+- `src/ui/` reusable controls form, line plot, spectrogram, chain displacement viewer, 3D scene, shared playback timeline, FFT, WAV.
package-lock.jsonmodified+75−0View file
@@ -7,12 +7,23 @@
77 "": {
88 "name": "toy-instruments",
99 "version": "0.1.0",
10+ "dependencies": {
11+ "three": "^0.180.0"
12+ },
1013 "devDependencies": {
14+ "@types/three": "^0.180.0",
1115 "typescript": "^5.9.0",
1216 "vite": "^7.1.0",
1317 "vitest": "^3.2.0"
1418 }
1519 },
20+ "node_modules/@dimforge/rapier3d-compat": {
21+ "version": "0.12.0",
22+ "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
23+ "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
24+ "dev": true,
25+ "license": "Apache-2.0"
26+ },
1627 "node_modules/@esbuild/aix-ppc64": {
1728 "version": "0.28.2",
1829 "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
@@ -829,6 +840,13 @@
829840 "win32"
830841 ]
831842 },
843+ "node_modules/@tweenjs/tween.js": {
844+ "version": "23.1.3",
845+ "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
846+ "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
847+ "dev": true,
848+ "license": "MIT"
849+ },
832850 "node_modules/@types/chai": {
833851 "version": "5.2.3",
834852 "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
@@ -854,6 +872,36 @@
854872 "dev": true,
855873 "license": "MIT"
856874 },
875+ "node_modules/@types/stats.js": {
876+ "version": "0.17.4",
877+ "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
878+ "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
879+ "dev": true,
880+ "license": "MIT"
881+ },
882+ "node_modules/@types/three": {
883+ "version": "0.180.0",
884+ "resolved": "https://registry.npmjs.org/@types/three/-/three-0.180.0.tgz",
885+ "integrity": "sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg==",
886+ "dev": true,
887+ "license": "MIT",
888+ "dependencies": {
889+ "@dimforge/rapier3d-compat": "~0.12.0",
890+ "@tweenjs/tween.js": "~23.1.3",
891+ "@types/stats.js": "*",
892+ "@types/webxr": "*",
893+ "@webgpu/types": "*",
894+ "fflate": "~0.8.2",
895+ "meshoptimizer": "~0.22.0"
896+ }
897+ },
898+ "node_modules/@types/webxr": {
899+ "version": "0.5.24",
900+ "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
901+ "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
902+ "dev": true,
903+ "license": "MIT"
904+ },
857905 "node_modules/@vitest/expect": {
858906 "version": "3.2.7",
859907 "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz",
@@ -969,6 +1017,13 @@
9691017 "url": "https://opencollective.com/vitest"
9701018 }
9711019 },
1020+ "node_modules/@webgpu/types": {
1021+ "version": "0.1.74",
1022+ "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.74.tgz",
1023+ "integrity": "sha512-lgiI4hbuLcI9unnm2cL/tvCaQU45dp0xcLWJh5uB/9MBGvIA4F8XIk7nSiBeg+K4xWGYwgZKn80LmERI5CxoTA==",
1024+ "dev": true,
1025+ "license": "BSD-3-Clause"
1026+ },
9721027 "node_modules/assertion-error": {
9731028 "version": "2.0.1",
9741029 "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
@@ -1131,6 +1186,13 @@
11311186 }
11321187 }
11331188 },
1189+ "node_modules/fflate": {
1190+ "version": "0.8.3",
1191+ "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
1192+ "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
1193+ "dev": true,
1194+ "license": "MIT"
1195+ },
11341196 "node_modules/fsevents": {
11351197 "version": "2.3.3",
11361198 "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -1170,6 +1232,13 @@
11701232 "@jridgewell/sourcemap-codec": "^1.5.5"
11711233 }
11721234 },
1235+ "node_modules/meshoptimizer": {
1236+ "version": "0.22.0",
1237+ "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.22.0.tgz",
1238+ "integrity": "sha512-IebiK79sqIy+E4EgOr+CAw+Ke8hAspXKzBd0JdgEmPHiAwmvEj2S4h1rfvo+o/BnfEYd/jAOg5IeeIjzlzSnDg==",
1239+ "dev": true,
1240+ "license": "MIT"
1241+ },
11731242 "node_modules/ms": {
11741243 "version": "2.1.3",
11751244 "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -1352,6 +1421,12 @@
13521421 "url": "https://github.com/sponsors/antfu"
13531422 }
13541423 },
1424+ "node_modules/three": {
1425+ "version": "0.180.0",
1426+ "resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz",
1427+ "integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==",
1428+ "license": "MIT"
1429+ },
13551430 "node_modules/tinybench": {
13561431 "version": "2.9.0",
13571432 "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
package.jsonmodified+4−0View file
@@ -11,8 +11,12 @@
1111 "typecheck": "tsc --noEmit"
1212 },
1313 "devDependencies": {
14+ "@types/three": "^0.180.0",
1415 "typescript": "^5.9.0",
1516 "vite": "^7.1.0",
1617 "vitest": "^3.2.0"
18+ },
19+ "dependencies": {
20+ "three": "^0.180.0"
1721 }
1822 }
src/instruments/bead-string/page.tsmodified+22−7View file
@@ -2,11 +2,13 @@ import { renderControls, type Section } from '../../ui/controls';
22 import { LinePlot, css, fmtNum } from '../../ui/plot';
33 import { Spectrogram } from '../../ui/spectrogram';
44 import { ChainView } from '../../ui/chainview';
5+import { BeadScene, type SceneSpec } from '../../ui/scene3d';
6+import { Timeline } from '../../ui/timeline';
57 import { spectrum } from '../../ui/fft';
68 import { encodeWav } from '../../ui/wav';
79 import { omegaMaxBound } from '../../sim/solver';
810 import { runInWorker, type Running } from '../../sim/run';
9-import type { SimResult } from '../../sim/types';
11+import type { BeadSystem, Excitation, SimResult } from '../../sim/types';
1012 import { buildExcitation, buildPickup, buildSystem, derive, parsePins, type StringParams } from './model';
1113 import { PRESETS } from './presets';
1214
@@ -86,7 +88,7 @@ const SECTIONS: Section<StringParams>[] = [
8688 { key: 'duration', label: 'Duration', kind: 'number', unit: 's' },
8789 { key: 'fs', label: 'Sample rate', kind: 'select', options: [['44100', '44.1 kHz'], ['48000', '48 kHz'], ['96000', '96 kHz']] },
8890 { key: 'cfl', label: 'Step safety', kind: 'number', help: 'Fraction of the leapfrog stability limit dt = 2/ω_max. 0.5 is a safe default; large-amplitude motion raises ω_max.' },
89- { key: 'frameRate', label: 'Frame rate', kind: 'number', unit: 'Hz', help: 'How often displacements of all beads are saved for the viewer.' },
91+ { key: 'frameRate', label: 'Replay frames', kind: 'number', unit: 'per s', help: 'How often displacements of all beads are saved for replay (much coarser than the solver time step and the audio rate).' },
9092 ],
9193 },
9294 ];
@@ -107,6 +109,7 @@ export function mountBeadString(root: HTMLElement) {
107109 <progress data-r="progress" max="1" value="0" hidden></progress>
108110 </aside>
109111 <main class="results">
112+ <section class="card"><h2>Model</h2><div data-r="scene"></div></section>
110113 <section class="card"><h2>Setup</h2><div data-r="derived" class="stats"></div><ul data-r="warnings" class="warnings"></ul></section>
111114 <section class="card" data-r="out" hidden>
112115 <h2>Run</h2><div data-r="summary" class="stats"></div>
@@ -128,7 +131,7 @@ export function mountBeadString(root: HTMLElement) {
128131 <div class="legend"><span><i class="sw" style="background:var(--series-1)"></i>pickup signal</span><span><i class="dash" style="border-color:var(--series-1)"></i>linear transverse modes</span><span><i class="dash" style="border-color:var(--series-2)"></i>linear longitudinal modes</span></div></div>
129132 <div class="canvas-wrap"><canvas data-r="spectrum"></canvas></div>
130133 </section>
131- <section class="card" data-r="out" hidden><h2>Displacements</h2><div data-r="viewer"></div></section>
134+ <section class="card" data-r="out" hidden><h2>Displacements (2D)</h2><div data-r="viewer"></div></section>
132135 <section class="card" data-r="out" hidden>
133136 <h2>Energy</h2>
134137 <div class="canvas-wrap short"><canvas data-r="energy"></canvas></div>
@@ -156,12 +159,22 @@ export function mountBeadString(root: HTMLElement) {
156159 const spec = new Spectrogram(q('spec'));
157160 const specPlot = new LinePlot(q('spectrum'));
158161 const energyPlot = new LinePlot(q('energy'));
159- const viewer = new ChainView(q('viewer'));
162+ const timeline = new Timeline();
163+ const viewer = new ChainView(q('viewer'), timeline);
164+ const scene = new BeadScene(q('scene'), timeline);
160165 let waveRange = 0;
161166 let last: { p: StringParams; r: SimResult } | null = null;
162167 let running: Running | null = null;
163168 let audioUrl: string | null = null;
164169
170+ const sceneSpec = (p: StringParams, sys: BeadSystem, exc: Excitation): SceneSpec => ({
171+ sys,
172+ exc,
173+ excKind: p.excitation,
174+ pickup: buildPickup(p),
175+ pickupLabel: `${p.pickupMode === 'sum' ? 'sum over all beads' : `bead ${Math.round(p.pickupBead)}`}, ${p.pickupAxis} acceleration`,
176+ });
177+
165178 function updateDerived() {
166179 const p = controls.get();
167180 const d = derive(p);
@@ -178,6 +191,7 @@ export function mountBeadString(root: HTMLElement) {
178191 const warnings = [...d.warnings];
179192 if (d.restLength > 0) {
180193 const sys = buildSystem(p, d);
194+ scene.showSetup(sceneSpec(p, sys, buildExcitation(p)));
181195 const wmax = omegaMaxBound(sys);
182196 const spp = Math.max(1, Math.ceil(1 / (p.fs * ((p.cfl * 2) / wmax))));
183197 rows.push(['Highest frequency bound', `${fmtNum(wmax / (2 * Math.PI))} Hz`]);
@@ -245,7 +259,7 @@ export function mountBeadString(root: HTMLElement) {
245259 });
246260 }
247261
248- function show(p: StringParams, r: SimResult) {
262+ function show(p: StringParams, sys: BeadSystem, exc: Excitation, r: SimResult) {
249263 last = { p, r };
250264 root.querySelectorAll<HTMLElement>('[data-r="out"]').forEach((e) => (e.hidden = false));
251265 const d = derive(p);
@@ -272,7 +286,8 @@ export function mountBeadString(root: HTMLElement) {
272286 drawWave();
273287 spec.setSignal(r.audio, r.fs);
274288 drawSpectrum();
275- const sys = buildSystem(p, d);
289+ timeline.setTimes(r.frameTimes);
290+ scene.showReplay({ ...sceneSpec(p, sys, exc), frames: r.frames });
276291 const rest = new Float64Array(p.n);
277292 for (let i = 0; i < p.n; i++) rest[i] = sys.rest[3 * i];
278293 viewer.set(
@@ -306,7 +321,7 @@ export function mountBeadString(root: HTMLElement) {
306321 progress.value = 0;
307322 running = runInWorker(sys, exc, opt, (f) => (progress.value = f));
308323 try {
309- show(p, await running.promise);
324+ show(p, sys, exc, await running.promise);
310325 } catch (e) {
311326 if ((e as Error).message !== 'cancelled') alert(`Simulation failed: ${(e as Error).message}`);
312327 } finally {
src/style.cssmodified+17−0View file
@@ -14,6 +14,7 @@
1414 --warn-ink: #6b4a00;
1515 --series-1: #2a78d6;
1616 --series-2: #eb6834;
17+ --series-3: #1baf7a;
1718 --ramp: #fcfcfb, #cde2fb, #86b6ef, #3987e5, #1c5cab, #0d366b;
1819 --diverging: #1c5cab, #86b6ef, #f0efec, #f0a3a2, #b42d2c;
1920 }
@@ -33,6 +34,7 @@
3334 --warn-ink: #f3d18a;
3435 --series-1: #3987e5;
3536 --series-2: #d95926;
37+ --series-3: #199e70;
3638 --ramp: #1a1a19, #104281, #1c5cab, #3987e5, #86b6ef, #cde2fb;
3739 --diverging: #6da7ec, #1c5cab, #383835, #a83434, #e66767;
3840 }
@@ -52,6 +54,7 @@
5254 --warn-ink: #f3d18a;
5355 --series-1: #3987e5;
5456 --series-2: #d95926;
57+ --series-3: #199e70;
5558 --ramp: #1a1a19, #104281, #1c5cab, #3987e5, #86b6ef, #cde2fb;
5659 --diverging: #6da7ec, #1c5cab, #383835, #a83434, #e66767;
5760 }
@@ -146,3 +149,17 @@ audio { max-width: 100%; }
146149 .viewer-bar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin: 6px 0; }
147150 .viewer-bar input[type='range'] { flex: 1; min-width: 140px; padding: 0; border: none; background: none; accent-color: var(--accent); }
148151 .inline { font-size: 12px; color: var(--text-secondary); display: inline-flex; gap: 6px; align-items: center; }
152+
153+.scene-wrap { position: relative; height: 420px; margin-top: 10px; border-radius: 8px; overflow: hidden; border: 1px solid var(--border); }
154+.scene-wrap canvas { display: block; width: 100%; height: 100%; touch-action: none; }
155+@media (max-width: 860px) { .scene-wrap { height: 300px; } }
156+.scene-legend { margin-top: 8px; }
157+.glyph { display: inline-block; width: 10px; height: 10px; margin-right: 5px; vertical-align: -1px; background: var(--c); }
158+.glyph.dot { border-radius: 50%; }
159+.glyph.ring { background: transparent; border: 2px solid var(--c); border-radius: 50%; }
160+.glyph.dash { height: 0; width: 14px; background: transparent; border-top: 2px dashed var(--c); vertical-align: middle; }
161+.glyph.arrow { height: 2px; width: 12px; vertical-align: middle; position: relative; }
162+.glyph.arrow::after { content: ''; position: absolute; right: -4px; top: -3px; border-left: 5px solid var(--c); border-top: 4px solid transparent; border-bottom: 4px solid transparent; }
163+.seg button:disabled { opacity: 0.45; cursor: default; }
164+.btn-small.icon { padding: 4px 8px; }
165+.viewer-bar[hidden] { display: none; }
src/ui/chainview.tsmodified+46−86View file
@@ -1,5 +1,6 @@
11 import { css, drawAxes, fmtNum, PAD, setupCanvas } from './plot';
22 import { rampLUT } from './spectrogram';
3+import { timelineBar, type Timeline } from './timeline';
34
45 // Displacement viewer for a chain of beads along x: side (y) and top (z)
56 // views at one frame, plus a space-time image of one component.
@@ -22,34 +23,25 @@ export interface ChainFrames {
2223 export class ChainView {
2324 private g: ChainGeometry | null = null;
2425 private f: ChainFrames | null = null;
25- private frame = 0;
26- private playing = false;
27- private rate = 1 / 100;
2826 private comp = 1;
29- private last = 0;
30- private ptime = 0;
3127 private maxAbs = [0, 0, 0];
3228 private exaggeration = 1;
29+ private stImage: HTMLCanvasElement | null = null;
30+ private stKey = '';
31+ private stFrames: Float32Array | null = null;
3332
3433 private side: HTMLCanvasElement;
3534 private top: HTMLCanvasElement;
3635 private st: HTMLCanvasElement;
37- private slider: HTMLInputElement;
38- private playBtn: HTMLButtonElement;
39- private label: HTMLSpanElement;
4036 private note: HTMLParagraphElement;
4137
42- constructor(root: HTMLElement) {
38+ private get frame() {
39+ return this.tl.frame;
40+ }
41+
42+ constructor(root: HTMLElement, private tl: Timeline) {
4343 root.innerHTML = `
44- <div class="viewer-bar">
45- <button class="btn-small" data-r="play">Play</button>
46- <input type="range" min="0" max="0" value="0" data-r="slider" aria-label="frame">
47- <span class="mono" data-r="label"></span>
48- <label class="inline">speed <select data-r="rate">
49- <option value="0.001">1/1000 ×</option><option value="0.01" selected>1/100 ×</option>
50- <option value="0.05">1/20 ×</option><option value="0.2">1/5 ×</option><option value="1">1 ×</option>
51- </select></label>
52- </div>
44+ <div data-r="bar"></div>
5345 <div class="canvas-wrap short"><canvas data-r="side"></canvas></div>
5446 <div class="canvas-wrap short"><canvas data-r="top"></canvas></div>
5547 <p class="note" data-r="note"></p>
@@ -59,16 +51,11 @@ export class ChainView {
5951 </select></label></div>
6052 <div class="canvas-wrap"><canvas data-r="st"></canvas></div>`;
6153 const q = <T extends HTMLElement>(r: string) => root.querySelector(`[data-r="${r}"]`) as T;
54+ q('bar').replaceWith(timelineBar(tl));
6255 this.side = q('side');
6356 this.top = q('top');
6457 this.st = q('st');
65- this.slider = q('slider');
66- this.playBtn = q('play');
67- this.label = q('label');
6858 this.note = q('note');
69- this.slider.addEventListener('input', () => this.seek(Number(this.slider.value)));
70- this.playBtn.addEventListener('click', () => this.toggle());
71- q<HTMLSelectElement>('rate').addEventListener('change', (e) => (this.rate = Number((e.target as HTMLSelectElement).value)));
7259 q<HTMLSelectElement>('comp').addEventListener('change', (e) => {
7360 this.comp = Number((e.target as HTMLSelectElement).value);
7461 this.drawSpaceTime();
@@ -77,62 +64,21 @@ export class ChainView {
7764 if (!this.f) return;
7865 const w = this.st.clientWidth;
7966 const fx = (e.offsetX - PAD.l) / (w - PAD.l - PAD.r);
80- if (fx >= 0 && fx <= 1) this.seek(Math.round(fx * (this.f.times.length - 1)));
67+ if (fx >= 0 && fx <= 1) tl.seek(Math.round(fx * (this.f.times.length - 1)));
8168 });
69+ tl.onChange(() => this.drawAll());
8270 const ro = new ResizeObserver(() => this.drawAll());
8371 for (const c of [this.side, this.top, this.st]) ro.observe(c);
8472 }
8573
74+ // frames must match the timeline's times
8675 set(g: ChainGeometry, f: ChainFrames) {
8776 this.g = g;
8877 this.f = f;
89- this.maxAbs = [0, 0, 0];
90- for (let i = 0; i < f.frames.length; i++) {
91- const c = i % 3;
92- this.maxAbs[c] = Math.max(this.maxAbs[c], Math.abs(f.frames[i]));
93- }
94- this.slider.max = String(f.times.length - 1);
95- this.frame = 0;
96- this.ptime = 0;
97- this.slider.value = '0';
78+ this.maxAbs = maxAbsPerComponent(f.frames);
9879 this.drawAll();
9980 }
10081
101- private seek(k: number) {
102- this.show(k);
103- this.ptime = this.f?.times[this.frame] ?? 0;
104- }
105-
106- private show(k: number) {
107- this.frame = Math.max(0, Math.min(k, (this.f?.times.length ?? 1) - 1));
108- this.slider.value = String(this.frame);
109- this.drawFrame();
110- this.drawSpaceTime();
111- }
112-
113- private toggle() {
114- if (!this.f) return;
115- this.playing = !this.playing;
116- this.playBtn.textContent = this.playing ? 'Pause' : 'Play';
117- if (this.playing) {
118- if (this.frame >= this.f.times.length - 1) this.seek(0);
119- this.last = performance.now();
120- requestAnimationFrame(this.tick);
121- }
122- }
123-
124- private tick = (now: number) => {
125- if (!this.playing || !this.f) return;
126- const times = this.f.times;
127- this.ptime += ((now - this.last) / 1000) * this.rate;
128- this.last = now;
129- let k = this.frame;
130- while (k < times.length - 1 && times[k + 1] <= this.ptime) k++;
131- this.show(k);
132- if (k >= times.length - 1) this.toggle();
133- else requestAnimationFrame(this.tick);
134- };
135-
13682 private drawAll() {
13783 this.drawFrame();
13884 this.drawSpaceTime();
@@ -153,8 +99,6 @@ export class ChainView {
15399 const half = Math.max((0.5 * panelH) / pxPerM / this.exaggeration, 1.15 * tmax);
154100 this.drawView(this.side, 1, xr, half);
155101 this.drawView(this.top, 2, xr, half);
156- const t = f.times[this.frame];
157- this.label.textContent = `t = ${(t * 1000).toFixed(2)} ms`;
158102 this.note.textContent =
159103 `Displacements (all components) drawn ×${fmtNum(this.exaggeration)}. Max |y| ${fmtNum(this.maxAbs[1] * 1000)} mm, ` +
160104 `max |z| ${fmtNum(this.maxAbs[2] * 1000)} mm, max |x| ${fmtNum(this.maxAbs[0] * 1000)} mm over the run.`;
@@ -219,23 +163,30 @@ export class ChainView {
219163 const nf = f.times.length;
220164 const c = this.comp;
221165 const m = Math.max(this.maxAbs[c], 1e-30);
222- const lut = rampLUT('--diverging');
223- const img = new ImageData(nf, g.n);
224- for (let k = 0; k < nf; k++) {
225- for (let i = 0; i < g.n; i++) {
226- const v = f.frames[k * 3 * g.n + 3 * i + c] / m;
227- const li = Math.round((0.5 + 0.5 * Math.max(-1, Math.min(1, v))) * 255) * 3;
228- const p = 4 * ((g.n - 1 - i) * nf + k);
229- img.data[p] = lut[li];
230- img.data[p + 1] = lut[li + 1];
231- img.data[p + 2] = lut[li + 2];
232- img.data[p + 3] = 255;
166+ const key = `${c}|${f.frames.length}|${css('--diverging')}`;
167+ if (!this.stImage || this.stKey !== key || this.stFrames !== f.frames) {
168+ const lut = rampLUT('--diverging');
169+ const img = new ImageData(nf, g.n);
170+ for (let k = 0; k < nf; k++) {
171+ for (let i = 0; i < g.n; i++) {
172+ const v = f.frames[k * 3 * g.n + 3 * i + c] / m;
173+ const li = Math.round((0.5 + 0.5 * Math.max(-1, Math.min(1, v))) * 255) * 3;
174+ const p = 4 * ((g.n - 1 - i) * nf + k);
175+ img.data[p] = lut[li];
176+ img.data[p + 1] = lut[li + 1];
177+ img.data[p + 2] = lut[li + 2];
178+ img.data[p + 3] = 255;
179+ }
233180 }
181+ const off = document.createElement('canvas');
182+ off.width = nf;
183+ off.height = g.n;
184+ off.getContext('2d')!.putImageData(img, 0, 0);
185+ this.stImage = off;
186+ this.stKey = key;
187+ this.stFrames = f.frames;
234188 }
235- const off = document.createElement('canvas');
236- off.width = nf;
237- off.height = g.n;
238- off.getContext('2d')!.putImageData(img, 0, 0);
189+ const off = this.stImage;
239190 ctx.imageSmoothingEnabled = false;
240191 ctx.drawImage(off, PAD.l, PAD.t, w - PAD.l - PAD.r, h - PAD.t - PAD.b);
241192 const t1 = f.times[nf - 1];
@@ -253,3 +204,12 @@ export class ChainView {
253204 ctx.stroke();
254205 }
255206 }
207+
208+export function maxAbsPerComponent(frames: Float32Array): number[] {
209+ const m = [0, 0, 0];
210+ for (let i = 0; i < frames.length; i++) {
211+ const c = i % 3;
212+ m[c] = Math.max(m[c], Math.abs(frames[i]));
213+ }
214+ return m;
215+}
src/ui/scene3d.tsadded+511−0View file
@@ -0,0 +1,511 @@
1+import * as THREE from 'three';
2+import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
3+import type { BeadSystem, Excitation } from '../sim/types';
4+import { css, fmtNum } from './plot';
5+import { timelineBar, type Timeline } from './timeline';
6+
7+// 3D view of a bead-spring system. "Setup" shows the geometry at rest with
8+// supports, pins, excitation and pickup marked; "Replay" animates saved
9+// displacement frames (exaggerated) on the shared timeline.
10+
11+export interface SceneSpec {
12+ sys: BeadSystem;
13+ exc: Excitation;
14+ excKind: 'pluck' | 'strike' | 'hammer';
15+ pickup: Float64Array; // 3n weights
16+ pickupLabel: string;
17+}
18+
19+export interface ReplaySpec extends SceneSpec {
20+ frames: Float32Array; // nFrames x 3n, matches the timeline
21+}
22+
23+type Mode = 'setup' | 'replay';
24+type View = 'persp' | 'front' | 'top' | 'end';
25+
26+const GLYPH = 0.12; // length of the largest excitation glyph, in units of the model size
27+
28+export class BeadScene {
29+ private renderer: THREE.WebGLRenderer | null = null;
30+ private scene = new THREE.Scene();
31+ private camera = new THREE.PerspectiveCamera(35, 1, 0.01, 100);
32+ private controls: OrbitControls | null = null;
33+ private model = new THREE.Group();
34+ private wrap: HTMLDivElement;
35+ private legend: HTMLDivElement;
36+ private note: HTMLParagraphElement;
37+ private bar: HTMLElement;
38+ private exagRow: HTMLElement;
39+ private exagInput: HTMLInputElement;
40+ private tabs: HTMLElement;
41+
42+ private mode: Mode = 'setup';
43+ private setupSpec: SceneSpec | null = null;
44+ private replaySpec: ReplaySpec | null = null;
45+ private exaggeration = 1;
46+ private autoExaggeration = 1;
47+
48+ // geometry of the current build, in normalized scene units
49+ private scale = 1;
50+ private center = new THREE.Vector3();
51+ private beads: THREE.InstancedMesh | null = null;
52+ private pins: THREE.InstancedMesh | null = null;
53+ private springs: THREE.LineSegments | null = null;
54+ private slideRings: { mesh: THREE.Mesh; bead: number }[] = [];
55+ private hammerArrows: THREE.Group | null = null;
56+ private pickupArrow: { arrow: THREE.ArrowHelper; bead: number } | null = null;
57+ private spec: SceneSpec | null = null;
58+ private frames: Float32Array | null = null;
59+
60+ constructor(root: HTMLElement, private tl: Timeline) {
61+ root.innerHTML = `
62+ <div class="card-head">
63+ <div class="seg" data-r="tabs"><button data-v="setup" class="on">Setup</button><button data-v="replay" disabled>Replay</button></div>
64+ <div class="seg" data-r="views"><button data-v="persp" class="on">3D</button><button data-v="front">Front x–y</button><button data-v="top">Top x–z</button><button data-v="end">End y–z</button></div>
65+ </div>
66+ <div class="scene-wrap" data-r="wrap"></div>
67+ <div class="legend scene-legend" data-r="legend"></div>
68+ <div class="viewer-bar" data-r="exag" hidden>
69+ <label class="inline">exaggeration × <input type="number" min="1" step="any" data-r="exag-in" style="width:110px"></label>
70+ <button class="btn-small" data-r="exag-auto">Auto</button>
71+ </div>
72+ <div data-r="bar" hidden></div>
73+ <p class="note" data-r="note"></p>`;
74+ const q = <T extends HTMLElement>(r: string) => root.querySelector(`[data-r="${r}"]`) as T;
75+ this.wrap = q('wrap');
76+ this.legend = q('legend');
77+ this.note = q('note');
78+ this.tabs = q('tabs');
79+ this.exagRow = q('exag');
80+ this.exagInput = q('exag-in');
81+ this.bar = timelineBar(tl);
82+ this.bar.hidden = true;
83+ q('bar').replaceWith(this.bar);
84+
85+ this.tabs.addEventListener('click', (e) => {
86+ const b = (e.target as HTMLElement).closest('button');
87+ if (b && !b.disabled) this.setMode(b.dataset.v as Mode);
88+ });
89+ q('views').addEventListener('click', (e) => {
90+ const b = (e.target as HTMLElement).closest('button');
91+ if (!b) return;
92+ q('views').querySelectorAll('button').forEach((x) => x.classList.toggle('on', x === b));
93+ this.setView(b.dataset.v as View);
94+ });
95+ this.exagInput.addEventListener('change', () => {
96+ const v = Number(this.exagInput.value);
97+ if (v > 0) {
98+ this.exaggeration = v;
99+ this.update();
100+ }
101+ });
102+ q('exag-auto').addEventListener('click', () => {
103+ this.exaggeration = this.autoExaggeration;
104+ this.exagInput.value = fmtNum(this.exaggeration);
105+ this.update();
106+ });
107+
108+ try {
109+ this.renderer = new THREE.WebGLRenderer({ antialias: true });
110+ this.renderer.setPixelRatio(window.devicePixelRatio || 1);
111+ this.wrap.append(this.renderer.domElement);
112+ this.controls = new OrbitControls(this.camera, this.renderer.domElement);
113+ this.controls.addEventListener('change', () => this.render());
114+ } catch {
115+ this.wrap.innerHTML = '<p class="note">WebGL is not available in this browser, so the 3D view is off.</p>';
116+ }
117+ this.scene.add(new THREE.AmbientLight(0xffffff, 1.6));
118+ const sun = new THREE.DirectionalLight(0xffffff, 2.2);
119+ sun.position.set(0.4, 1, 0.8);
120+ this.scene.add(sun);
121+ this.scene.add(this.model);
122+ this.setView('persp');
123+
124+ tl.onChange(() => {
125+ if (this.mode === 'replay') this.update();
126+ });
127+ new ResizeObserver(() => this.resize()).observe(this.wrap);
128+ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => this.rebuild());
129+ }
130+
131+ showSetup(spec: SceneSpec) {
132+ this.setupSpec = spec;
133+ if (this.mode === 'setup') this.rebuild();
134+ else this.setMode('setup');
135+ }
136+
137+ showReplay(spec: ReplaySpec) {
138+ this.replaySpec = spec;
139+ let tmax = 0;
140+ for (let i = 0; i < spec.frames.length; i++) if (i % 3 !== 0) tmax = Math.max(tmax, Math.abs(spec.frames[i]));
141+ const size = modelSize(spec.sys);
142+ this.autoExaggeration = tmax > 0 ? Math.max(1, (GLYPH * size) / tmax) : 1;
143+ this.exaggeration = this.autoExaggeration;
144+ this.exagInput.value = fmtNum(this.exaggeration);
145+ (this.tabs.querySelector('[data-v="replay"]') as HTMLButtonElement).disabled = false;
146+ this.setMode('replay');
147+ }
148+
149+ private setMode(m: Mode) {
150+ this.mode = m;
151+ this.tabs.querySelectorAll<HTMLButtonElement>('button').forEach((b) => b.classList.toggle('on', b.dataset.v === m));
152+ this.bar.hidden = m !== 'replay';
153+ this.exagRow.hidden = m !== 'replay';
154+ if (m === 'setup') this.tl.pause();
155+ this.rebuild();
156+ }
157+
158+ private setView(v: View) {
159+ const d = 1.45;
160+ const pos: Record<View, [number, number, number]> = {
161+ persp: [-0.45, 0.42, 1.05],
162+ front: [0, 0, d],
163+ top: [0, d, 0.0001],
164+ end: [d, 0, 0],
165+ };
166+ this.camera.position.set(...pos[v]);
167+ this.camera.up.set(0, 1, 0);
168+ if (v === 'top') this.camera.up.set(0, 0, -1);
169+ this.controls?.target.set(0, 0, 0);
170+ this.camera.lookAt(0, 0, 0);
171+ this.controls?.update();
172+ this.render();
173+ }
174+
175+ private resize() {
176+ if (!this.renderer) return;
177+ const w = this.wrap.clientWidth;
178+ const h = this.wrap.clientHeight;
179+ if (w === 0 || h === 0) return;
180+ this.renderer.setSize(w, h);
181+ this.camera.aspect = w / h;
182+ this.camera.updateProjectionMatrix();
183+ this.render();
184+ }
185+
186+ private render() {
187+ this.renderer?.render(this.scene, this.camera);
188+ }
189+
190+ // node position (beads and anchors), normalized
191+ private nodePos(sys: BeadSystem, node: number, out: THREE.Vector3): THREE.Vector3 {
192+ if (node < sys.n) out.set(sys.rest[3 * node], sys.rest[3 * node + 1], sys.rest[3 * node + 2]);
193+ else out.set(...sys.anchors[node - sys.n].pos);
194+ return out.sub(this.center).multiplyScalar(this.scale);
195+ }
196+
197+ private rebuild() {
198+ const spec = this.mode === 'replay' ? this.replaySpec : this.setupSpec;
199+ disposeGroup(this.model);
200+ this.beads = this.pins = this.springs = this.hammerArrows = null;
201+ this.slideRings = [];
202+ this.pickupArrow = null;
203+ this.spec = spec;
204+ this.frames = this.mode === 'replay' && this.replaySpec ? this.replaySpec.frames : null;
205+ if (!spec) return this.render();
206+ if (this.renderer) this.renderer.setClearColor(new THREE.Color(css('--surface-1')));
207+ const { sys, exc, pickup } = spec;
208+ const n = sys.n;
209+
210+ // normalize: longest extent of beads and anchors -> 1, centered
211+ const box = new THREE.Box3();
212+ const v = new THREE.Vector3();
213+ for (let i = 0; i < n; i++) box.expandByPoint(v.set(sys.rest[3 * i], sys.rest[3 * i + 1], sys.rest[3 * i + 2]));
214+ for (const a of sys.anchors) box.expandByPoint(v.set(...a.pos));
215+ box.getCenter(this.center);
216+ const size = box.getSize(new THREE.Vector3());
217+ this.scale = 1 / Math.max(size.x, size.y, size.z, 1e-12);
218+
219+ // bead size from nearest-neighbour spacing
220+ let minLen = Infinity;
221+ const p = new THREE.Vector3();
222+ const q = new THREE.Vector3();
223+ for (let s = 0; s < sys.si.length; s++) minLen = Math.min(minLen, this.nodePos(sys, sys.si[s], p).distanceTo(this.nodePos(sys, sys.sj[s], q)));
224+ if (!Number.isFinite(minLen)) minLen = 0.05;
225+ const r = Math.min(0.32 * minLen, 0.022);
226+
227+ const cBead = new THREE.Color(css('--series-1'));
228+ const cExc = new THREE.Color(css('--series-2'));
229+ const cPick = new THREE.Color(css('--series-3'));
230+ const cInk = new THREE.Color(css('--text-primary'));
231+ const cMuted = new THREE.Color(css('--text-muted'));
232+
233+ // grid floor for depth cues
234+ const grid = new THREE.GridHelper(1.4, 14, cMuted, cMuted);
235+ (grid.material as THREE.Material).transparent = true;
236+ (grid.material as THREE.Material).opacity = 0.25;
237+ grid.position.y = -0.22;
238+ this.model.add(grid);
239+ this.model.add(axisTriad(new THREE.Vector3(-0.58, -0.22, -0.3), 0.08, cInk));
240+
241+ // beads (pinned beads are drawn as cubes instead)
242+ const pickBead = (i: number) => pickup[3 * i] !== 0 || pickup[3 * i + 1] !== 0 || pickup[3 * i + 2] !== 0;
243+ const free: number[] = [];
244+ const pinned: number[] = [];
245+ for (let i = 0; i < n; i++) (sys.pinned[i] ? pinned : free).push(i);
246+ const beadMat = new THREE.MeshStandardMaterial({ roughness: 0.55, metalness: 0.1 });
247+ this.beads = new THREE.InstancedMesh(new THREE.SphereGeometry(r, 20, 14), beadMat, Math.max(1, n));
248+ this.beads.count = n;
249+ for (let i = 0; i < n; i++) this.beads.setColorAt(i, pickBead(i) ? cPick : cBead);
250+ this.model.add(this.beads);
251+ if (pinned.length) {
252+ this.pins = new THREE.InstancedMesh(new THREE.BoxGeometry(2.4 * r, 2.4 * r, 2.4 * r), new THREE.MeshStandardMaterial({ color: cInk, roughness: 0.6 }), pinned.length);
253+ this.model.add(this.pins);
254+ }
255+ (this.beads.userData as { free: number[]; pinned: number[] }) = { free, pinned };
256+
257+ // springs
258+ const sg = new THREE.BufferGeometry();
259+ sg.setAttribute('position', new THREE.BufferAttribute(new Float32Array(sys.si.length * 6), 3));
260+ this.springs = new THREE.LineSegments(sg, new THREE.LineBasicMaterial({ color: cMuted }));
261+ this.model.add(this.springs);
262+
263+ // supports
264+ const wallSize = 0.16;
265+ sys.anchors.forEach((a) => {
266+ const at = this.nodePos(sys, sys.n + sys.anchors.indexOf(a), new THREE.Vector3());
267+ const side = at.x < 0 ? -1 : 1;
268+ if (a.slideBead < 0) {
269+ const wall = new THREE.Mesh(
270+ new THREE.BoxGeometry(0.02, wallSize, wallSize),
271+ new THREE.MeshStandardMaterial({ color: cMuted, roughness: 0.8 }),
272+ );
273+ wall.position.copy(at).add(new THREE.Vector3(side * 0.01, 0, 0));
274+ this.model.add(wall);
275+ } else {
276+ const plane = new THREE.Mesh(
277+ new THREE.PlaneGeometry(wallSize, wallSize),
278+ new THREE.MeshBasicMaterial({ color: cMuted, transparent: true, opacity: 0.18, side: THREE.DoubleSide, depthWrite: false }),
279+ );
280+ plane.rotation.y = Math.PI / 2;
281+ plane.position.copy(at);
282+ this.model.add(plane);
283+ const edges = new THREE.LineSegments(new THREE.EdgesGeometry(plane.geometry), new THREE.LineBasicMaterial({ color: cMuted }));
284+ edges.rotation.y = Math.PI / 2;
285+ edges.position.copy(at);
286+ this.model.add(edges);
287+ const ring = new THREE.Mesh(new THREE.TorusGeometry(1.3 * r, 0.35 * r, 8, 24), new THREE.MeshStandardMaterial({ color: cInk }));
288+ ring.rotation.y = Math.PI / 2;
289+ ring.position.copy(at);
290+ this.model.add(ring);
291+ this.slideRings.push({ mesh: ring, bead: a.slideBead });
292+ }
293+ });
294+
295+ // excitation glyphs
296+ const legend: string[] = [];
297+ const sw = (c: THREE.Color, shape = 'dot') => `<i class="glyph ${shape}" style="--c:#${c.getHexString()}"></i>`;
298+ legend.push(`<span>${sw(cBead)}bead</span>`);
299+ if (pinned.length) legend.push(`<span>${sw(cInk, 'square')}pinned bead</span>`);
300+ if (sys.anchors.some((a) => a.slideBead < 0)) legend.push(`<span>${sw(cMuted, 'square')}clamped end (wall)</span>`);
301+ if (sys.anchors.some((a) => a.slideBead >= 0)) legend.push(`<span>${sw(cInk, 'ring')}sliding end (ring on a frictionless plane)</span>`);
302+ const size0 = modelSize(sys);
303+ const dirs = (field: Float64Array) => {
304+ let m = 0;
305+ for (let i = 0; i < n; i++) m = Math.max(m, Math.hypot(field[3 * i], field[3 * i + 1], field[3 * i + 2]));
306+ return m;
307+ };
308+ if (this.mode === 'setup' && spec.excKind === 'pluck') {
309+ const m = dirs(exc.u0);
310+ if (m > 0) {
311+ const E = (GLYPH * size0) / m;
312+ const pts: THREE.Vector3[] = [];
313+ const left = sys.anchors.find((a) => a.pos[0] < sys.rest[0]);
314+ const right = sys.anchors.find((a) => a.pos[0] > sys.rest[3 * (n - 1)]);
315+ if (left) pts.push(this.nodePos(sys, n + sys.anchors.indexOf(left), new THREE.Vector3()));
316+ for (let i = 0; i < n; i++) {
317+ const w = new THREE.Vector3(exc.u0[3 * i], exc.u0[3 * i + 1], exc.u0[3 * i + 2]).multiplyScalar(E * this.scale);
318+ pts.push(this.nodePos(sys, i, new THREE.Vector3()).add(w));
319+ }
320+ if (right) pts.push(this.nodePos(sys, n + sys.anchors.indexOf(right), new THREE.Vector3()));
321+ const line = new THREE.Line(new THREE.BufferGeometry().setFromPoints(pts), new THREE.LineDashedMaterial({ color: cExc, dashSize: 0.015, gapSize: 0.01 }));
322+ line.computeLineDistances();
323+ this.model.add(line);
324+ let peak = 0;
325+ for (let i = 1; i < n; i++) if (Math.hypot(exc.u0[3 * i + 1], exc.u0[3 * i + 2]) > Math.hypot(exc.u0[3 * peak + 1], exc.u0[3 * peak + 2])) peak = i;
326+ const from = this.nodePos(sys, peak, new THREE.Vector3());
327+ const to = pts[peak + (left ? 1 : 0)];
328+ const len = from.distanceTo(to);
329+ if (len > 0) this.model.add(new THREE.ArrowHelper(to.clone().sub(from).normalize(), from, len, cExc, 0.25 * len, 0.12 * len));
330+ legend.push(`<span>${sw(cExc, 'dash')}pluck: initial shape, drawn ×${fmtNum(E)} (${fmtNum(m * 1000)} mm peak)</span>`);
331+ }
332+ }
333+ if (spec.excKind === 'strike' && this.mode === 'setup') {
334+ const m = dirs(exc.v0);
335+ if (m > 0) this.model.add(this.arrows(sys, exc.v0, m, cExc, false));
336+ legend.push(`<span>${sw(cExc, 'arrow')}strike: initial velocity (${fmtNum(m)} m/s peak)</span>`);
337+ }
338+ if (spec.excKind === 'hammer' && exc.force) {
339+ const m = dirs(exc.force.shape);
340+ if (m > 0) {
341+ this.hammerArrows = this.arrows(sys, exc.force.shape, m, cExc, true);
342+ this.model.add(this.hammerArrows);
343+ }
344+ legend.push(
345+ `<span>${sw(cExc, 'arrow')}hammer: force pulse, ${fmtNum(exc.force.F0)} N peak over ${fmtNum(exc.force.tau * 1000)} ms${this.mode === 'replay' ? ' (shown while it acts)' : ''}</span>`,
346+ );
347+ }
348+
349+ // pickup
350+ const pk: number[] = [];
351+ for (let i = 0; i < n; i++) if (pickBead(i)) pk.push(i);
352+ if (pk.length === 1) {
353+ const i = pk[0];
354+ const d = new THREE.Vector3(pickup[3 * i], pickup[3 * i + 1], pickup[3 * i + 2]).normalize();
355+ const len = Math.max(0.05, 6 * r);
356+ const arrow = new THREE.ArrowHelper(d, this.nodePos(sys, i, new THREE.Vector3()), len, cPick, 0.3 * len, 0.18 * len);
357+ this.model.add(arrow);
358+ this.pickupArrow = { arrow, bead: i };
359+ }
360+ legend.push(`<span>${sw(cPick)}pickup: ${spec.pickupLabel}</span>`);
361+ this.legend.innerHTML = legend.join('');
362+ this.update();
363+ }
364+
365+ // arrows proportional to a per-bead vector field; `into` = arrow ends at the bead (a push)
366+ private arrows(sys: BeadSystem, field: Float64Array, max: number, color: THREE.Color, into: boolean): THREE.Group {
367+ const g = new THREE.Group();
368+ for (let i = 0; i < sys.n; i++) {
369+ const d = new THREE.Vector3(field[3 * i], field[3 * i + 1], field[3 * i + 2]);
370+ const mag = d.length();
371+ if (mag < 0.05 * max) continue;
372+ const len = GLYPH * (mag / max);
373+ d.normalize();
374+ const at = this.nodePos(sys, i, new THREE.Vector3());
375+ const origin = into ? at.clone().addScaledVector(d, -len) : at;
376+ const arrow = new THREE.ArrowHelper(d, origin, len, color, Math.min(0.03, 0.3 * len), Math.min(0.016, 0.18 * len));
377+ arrow.userData = { bead: i, dir: d.clone(), len };
378+ g.add(arrow);
379+ }
380+ return g;
381+ }
382+
383+ // position everything for the current frame (or rest, in setup)
384+ private update() {
385+ const spec = this.spec;
386+ if (!spec || !this.beads) return this.render();
387+ const { sys } = spec;
388+ const n = sys.n;
389+ const frames = this.frames;
390+ const off = frames ? this.tl.frame * 3 * n : 0;
391+ const E = this.exaggeration * this.scale;
392+ const P = new Array<THREE.Vector3>(n + sys.anchors.length);
393+ for (let i = 0; i < n; i++) {
394+ const p = this.nodePos(sys, i, new THREE.Vector3());
395+ if (frames) p.add(new THREE.Vector3(frames[off + 3 * i], frames[off + 3 * i + 1], frames[off + 3 * i + 2]).multiplyScalar(E));
396+ P[i] = p;
397+ }
398+ sys.anchors.forEach((a, k) => {
399+ const p = this.nodePos(sys, n + k, new THREE.Vector3());
400+ if (a.slideBead >= 0) {
401+ p.y = P[a.slideBead].y;
402+ p.z = P[a.slideBead].z;
403+ }
404+ P[n + k] = p;
405+ });
406+ const m = new THREE.Matrix4();
407+ const { pinned } = this.beads.userData as { free: number[]; pinned: number[] };
408+ for (let i = 0; i < n; i++) {
409+ // hide pinned beads' spheres (cubes stand in)
410+ m.makeScale(sys.pinned[i] ? 0 : 1, sys.pinned[i] ? 0 : 1, sys.pinned[i] ? 0 : 1).setPosition(P[i]);
411+ this.beads.setMatrixAt(i, m);
412+ }
413+ this.beads.instanceMatrix.needsUpdate = true;
414+ if (this.beads.instanceColor) this.beads.instanceColor.needsUpdate = true;
415+ if (this.pins) {
416+ pinned.forEach((i, k) => this.pins!.setMatrixAt(k, m.makeTranslation(P[i].x, P[i].y, P[i].z)));
417+ this.pins.instanceMatrix.needsUpdate = true;
418+ }
419+ if (this.springs) {
420+ const arr = (this.springs.geometry.getAttribute('position') as THREE.BufferAttribute).array as Float32Array;
421+ for (let s = 0; s < sys.si.length; s++) {
422+ const a = P[sys.si[s]];
423+ const b = P[sys.sj[s]];
424+ arr.set([a.x, a.y, a.z, b.x, b.y, b.z], 6 * s);
425+ }
426+ this.springs.geometry.getAttribute('position').needsUpdate = true;
427+ this.springs.geometry.computeBoundingSphere();
428+ }
429+ for (const { mesh, bead } of this.slideRings) {
430+ mesh.position.y = P[bead].y;
431+ mesh.position.z = P[bead].z;
432+ }
433+ if (this.pickupArrow) this.pickupArrow.arrow.position.copy(P[this.pickupArrow.bead]);
434+ if (this.hammerArrows && spec.exc.force) {
435+ const f = spec.exc.force;
436+ const t = frames ? this.tl.times[this.tl.frame] : f.tau / 2;
437+ const s = t >= 0 && t <= f.tau ? 0.5 * (1 - Math.cos((2 * Math.PI * t) / f.tau)) : 0;
438+ this.hammerArrows.visible = s > 0.02;
439+ for (const c of this.hammerArrows.children) {
440+ const { bead, dir, len } = c.userData as { bead: number; dir: THREE.Vector3; len: number };
441+ const l = Math.max(1e-4, len * (frames ? s : 1));
442+ const arrow = c as THREE.ArrowHelper;
443+ arrow.position.copy(P[bead]).addScaledVector(dir, -l);
444+ arrow.setLength(l, Math.min(0.03, 0.3 * l), Math.min(0.016, 0.18 * l));
445+ }
446+ }
447+ if (this.mode === 'replay' && frames) {
448+ this.note.textContent =
449+ `Displacements drawn ×${fmtNum(this.exaggeration)} (all three components, so longitudinal motion is exaggerated too). ` +
450+ `Drag to rotate, scroll to zoom, right-drag to pan.`;
451+ } else {
452+ this.note.textContent = 'Beads at rest. Drag to rotate, scroll to zoom, right-drag to pan.';
453+ }
454+ this.render();
455+ }
456+}
457+
458+function modelSize(sys: BeadSystem): number {
459+ let lo = Infinity;
460+ let hi = -Infinity;
461+ for (let i = 0; i < sys.n; i++) {
462+ lo = Math.min(lo, sys.rest[3 * i]);
463+ hi = Math.max(hi, sys.rest[3 * i]);
464+ }
465+ for (const a of sys.anchors) {
466+ lo = Math.min(lo, a.pos[0]);
467+ hi = Math.max(hi, a.pos[0]);
468+ }
469+ return Math.max(hi - lo, 1e-12);
470+}
471+
472+function textSprite(text: string, color: THREE.Color): THREE.Sprite {
473+ const c = document.createElement('canvas');
474+ c.width = c.height = 64;
475+ const ctx = c.getContext('2d')!;
476+ ctx.fillStyle = `#${color.getHexString()}`;
477+ ctx.font = '600 44px system-ui, sans-serif';
478+ ctx.textAlign = 'center';
479+ ctx.textBaseline = 'middle';
480+ ctx.fillText(text, 32, 34);
481+ const s = new THREE.Sprite(new THREE.SpriteMaterial({ map: new THREE.CanvasTexture(c), depthTest: false }));
482+ s.scale.set(0.04, 0.04, 1);
483+ return s;
484+}
485+
486+function axisTriad(at: THREE.Vector3, len: number, ink: THREE.Color): THREE.Group {
487+ const g = new THREE.Group();
488+ const axes: [string, THREE.Vector3][] = [
489+ ['x', new THREE.Vector3(1, 0, 0)],
490+ ['y', new THREE.Vector3(0, 1, 0)],
491+ ['z', new THREE.Vector3(0, 0, 1)],
492+ ];
493+ for (const [name, d] of axes) {
494+ g.add(new THREE.ArrowHelper(d, at, len, ink, 0.2 * len, 0.1 * len));
495+ const label = textSprite(name, ink);
496+ label.position.copy(at).addScaledVector(d, len * 1.3);
497+ g.add(label);
498+ }
499+ return g;
500+}
501+
502+function disposeGroup(g: THREE.Group) {
503+ g.traverse((o) => {
504+ const m = o as THREE.Mesh;
505+ m.geometry?.dispose();
506+ const mat = m.material as THREE.Material | THREE.Material[] | undefined;
507+ if (Array.isArray(mat)) mat.forEach((x) => x.dispose());
508+ else mat?.dispose();
509+ });
510+ g.clear();
511+}
src/ui/timeline.tsadded+118−0View file
@@ -0,0 +1,118 @@
1+// Shared playback state for saved frames. Several views subscribe; any number
2+// of control bars can drive it.
3+
4+export class Timeline {
5+ times: Float64Array = new Float64Array(1);
6+ frame = 0;
7+ playing = false;
8+ rate = 1 / 100; // simulated seconds per real second
9+ private ptime = 0;
10+ private last = 0;
11+ private listeners = new Set<() => void>();
12+
13+ onChange(cb: () => void): () => void {
14+ this.listeners.add(cb);
15+ return () => this.listeners.delete(cb);
16+ }
17+
18+ private emit() {
19+ for (const cb of this.listeners) cb();
20+ }
21+
22+ setTimes(times: Float64Array) {
23+ this.pause();
24+ this.times = times;
25+ this.frame = 0;
26+ this.ptime = 0;
27+ this.emit();
28+ }
29+
30+ seek(k: number) {
31+ this.show(k);
32+ this.ptime = this.times[this.frame] ?? 0;
33+ }
34+
35+ private show(k: number) {
36+ this.frame = Math.max(0, Math.min(Math.round(k), this.times.length - 1));
37+ this.emit();
38+ }
39+
40+ setRate(r: number) {
41+ this.rate = r;
42+ this.emit();
43+ }
44+
45+ toggle() {
46+ if (this.playing) this.pause();
47+ else this.play();
48+ }
49+
50+ play() {
51+ if (this.times.length < 2) return;
52+ if (this.frame >= this.times.length - 1) this.seek(0);
53+ this.playing = true;
54+ this.last = performance.now();
55+ this.emit();
56+ requestAnimationFrame(this.tick);
57+ }
58+
59+ pause() {
60+ if (!this.playing) return;
61+ this.playing = false;
62+ this.emit();
63+ }
64+
65+ private tick = (now: number) => {
66+ if (!this.playing) return;
67+ this.ptime += ((now - this.last) / 1000) * this.rate;
68+ this.last = now;
69+ let k = this.frame;
70+ while (k < this.times.length - 1 && this.times[k + 1] <= this.ptime) k++;
71+ if (k !== this.frame) this.show(k);
72+ if (k >= this.times.length - 1) this.pause();
73+ else requestAnimationFrame(this.tick);
74+ };
75+}
76+
77+const RATES: [number, string][] = [
78+ [0.001, '1/1000 ×'],
79+ [0.01, '1/100 ×'],
80+ [0.05, '1/20 ×'],
81+ [0.2, '1/5 ×'],
82+ [1, '1 ×'],
83+];
84+
85+// Play/pause, frame slider, step buttons, time label and speed select.
86+export function timelineBar(tl: Timeline): HTMLElement {
87+ const bar = document.createElement('div');
88+ bar.className = 'viewer-bar';
89+ bar.innerHTML = `
90+ <button class="btn-small" data-r="play">Play</button>
91+ <button class="btn-small icon" data-r="prev" title="Previous frame" aria-label="Previous frame">‹</button>
92+ <button class="btn-small icon" data-r="next" title="Next frame" aria-label="Next frame">›</button>
93+ <input type="range" min="0" max="0" value="0" data-r="slider" aria-label="frame">
94+ <span class="mono" data-r="label"></span>
95+ <label class="inline">speed <select data-r="rate">${RATES.map(([v, l]) => `<option value="${v}">${l}</option>`).join('')}</select></label>`;
96+ const q = <T extends HTMLElement>(r: string) => bar.querySelector(`[data-r="${r}"]`) as T;
97+ const play = q<HTMLButtonElement>('play');
98+ const slider = q<HTMLInputElement>('slider');
99+ const label = q<HTMLSpanElement>('label');
100+ const rate = q<HTMLSelectElement>('rate');
101+ play.addEventListener('click', () => tl.toggle());
102+ q('prev').addEventListener('click', () => tl.seek(tl.frame - 1));
103+ q('next').addEventListener('click', () => tl.seek(tl.frame + 1));
104+ slider.addEventListener('input', () => tl.seek(Number(slider.value)));
105+ rate.addEventListener('change', () => tl.setRate(Number(rate.value)));
106+ const sync = () => {
107+ play.textContent = tl.playing ? 'Pause' : 'Play';
108+ slider.max = String(tl.times.length - 1);
109+ slider.value = String(tl.frame);
110+ const n = tl.times.length;
111+ const dt = n > 1 ? tl.times[1] - tl.times[0] : 0;
112+ label.textContent = `t = ${(tl.times[tl.frame] * 1000).toFixed(2)} ms (frame ${tl.frame + 1}/${n}, every ${(dt * 1000).toPrecision(3)} ms)`;
113+ rate.value = String(tl.rate);
114+ };
115+ tl.onChange(sync);
116+ sync();
117+ return bar;
118+}
vite.config.tsmodified+1−0View file
@@ -3,4 +3,5 @@ import { defineConfig } from 'vite';
33 export default defineConfig({
44 base: './',
55 worker: { format: 'es' },
6+ build: { chunkSizeWarningLimit: 800 },
67 });
↑↓ move↵openescclose