/ proof-of-concept / toy-instruments
Sign in
proof-of-concept / toy-instruments
toy-instruments / src / ui / scene3d.ts
611 lines · 27.2 KBCodeBlameHistory
2import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
3import type { BeadSystem, Excitation } from '../sim/types';
4import { css, fmtNum } from './plot';
5import { timelineBar, type Timeline } from './timeline';
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.
11export interface SceneSpec {
12 sys: BeadSystem;
13 exc: Excitation;
c696b9aCymbal: plate bending via per-triangle shape operators, felt centre support, stick and mallet with angle and friction, far-field microphoneJeremy Magland 14 excKind: 'pluck' | 'strike' | 'hammer' | 'mallet' | 'stick' | 'force' | 'press';
16 pickupLabel: string;
2cd36faBead drum: hex-lattice round head, felt mallet contact, top view and cross-section; shared instrument pageJeremy Magland 17 hoop?: { radius: number }; // round rim in the x-z plane, centred on the origin (instead of walls)
15fbb00Cymbal: replace felt washers with a minimal rigid hub (pinned beads around the hole), no support dampingJeremy Magland 18 post?: { y: number }; // centre support: a stand rod from (0, y, 0) down to the floor
c696b9aCymbal: plate bending via per-triangle shape operators, felt centre support, stick and mallet with angle and friction, far-field microphoneJeremy Magland 20 mic?: { dir: [number, number, number]; label: string; from: [number, number, number] }; // direction to a far-field microphone
23export interface ReplaySpec extends SceneSpec {
24 frames: Float32Array; // nFrames x 3n, matches the timeline
28type Mode = 'setup' | 'replay';
29type View = 'persp' | 'front' | 'top' | 'end';
31const GLYPH = 0.12; // length of the largest excitation glyph, in units of the model size
33export class BeadScene {
34 private renderer: THREE.WebGLRenderer | null = null;
35 private scene = new THREE.Scene();
36 private camera = new THREE.PerspectiveCamera(35, 1, 0.01, 100);
37 private controls: OrbitControls | null = null;
38 private model = new THREE.Group();
39 private wrap: HTMLDivElement;
40 private legend: HTMLDivElement;
41 private note: HTMLParagraphElement;
42 private bar: HTMLElement;
43 private exagRow: HTMLElement;
44 private exagInput: HTMLInputElement;
45 private tabs: HTMLElement;
47 private mode: Mode = 'setup';
49 private fitted = false;
51 private replaySpec: ReplaySpec | null = null;
52 private exaggeration = 1;
53 private autoExaggeration = 1;
55 // geometry of the current build, in normalized scene units
56 private scale = 1;
57 private center = new THREE.Vector3();
58 private beads: THREE.InstancedMesh | null = null;
59 private pins: THREE.InstancedMesh | null = null;
60 private springs: THREE.LineSegments | null = null;
61 private slideRings: { mesh: THREE.Mesh; bead: number }[] = [];
62 private hammerArrows: THREE.Group | null = null;
63 private pickupArrow: { arrow: THREE.ArrowHelper; bead: number } | null = null;
c696b9aCymbal: plate bending via per-triangle shape operators, felt centre support, stick and mallet with angle and friction, far-field microphoneJeremy Magland 64 private mallet: { group: THREE.Group; contact: THREE.Vector3; dir: THREE.Vector3 } | null = null;
66 private frames: Float32Array | null = null;
68 constructor(root: HTMLElement, private tl: Timeline) {
69 root.innerHTML = `
70 <div class="card-head">
71 <div class="seg" data-r="tabs"><button data-v="setup" class="on">Setup</button><button data-v="replay" disabled>Replay</button></div>
72 <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>
73 </div>
74 <div class="scene-wrap" data-r="wrap"></div>
75 <div class="legend scene-legend" data-r="legend"></div>
76 <div class="viewer-bar" data-r="exag" hidden>
77 <label class="inline">exaggeration × <input type="number" min="1" step="any" data-r="exag-in" style="width:110px"></label>
78 <button class="btn-small" data-r="exag-auto">Auto</button>
79 </div>
80 <div data-r="bar" hidden></div>
81 <p class="note" data-r="note"></p>`;
82 const q = <T extends HTMLElement>(r: string) => root.querySelector(`[data-r="${r}"]`) as T;
83 this.wrap = q('wrap');
84 this.legend = q('legend');
85 this.note = q('note');
86 this.tabs = q('tabs');
87 this.exagRow = q('exag');
88 this.exagInput = q('exag-in');
89 this.bar = timelineBar(tl);
90 this.bar.hidden = true;
91 q('bar').replaceWith(this.bar);
93 this.tabs.addEventListener('click', (e) => {
94 const b = (e.target as HTMLElement).closest('button');
95 if (b && !b.disabled) this.setMode(b.dataset.v as Mode);
96 });
97 q('views').addEventListener('click', (e) => {
98 const b = (e.target as HTMLElement).closest('button');
99 if (!b) return;
100 q('views').querySelectorAll('button').forEach((x) => x.classList.toggle('on', x === b));
101 this.setView(b.dataset.v as View);
102 });
103 this.exagInput.addEventListener('change', () => {
104 const v = Number(this.exagInput.value);
105 if (v > 0) {
106 this.exaggeration = v;
107 this.update();
108 }
109 });
110 q('exag-auto').addEventListener('click', () => {
111 this.exaggeration = this.autoExaggeration;
112 this.exagInput.value = fmtNum(this.exaggeration);
113 this.update();
114 });
116 try {
117 this.renderer = new THREE.WebGLRenderer({ antialias: true });
118 this.renderer.setPixelRatio(window.devicePixelRatio || 1);
119 this.wrap.append(this.renderer.domElement);
120 this.controls = new OrbitControls(this.camera, this.renderer.domElement);
121 this.controls.addEventListener('change', () => this.render());
122 } catch {
123 this.wrap.innerHTML = '<p class="note">WebGL is not available in this browser, so the 3D view is off.</p>';
124 }
125 this.scene.add(new THREE.AmbientLight(0xffffff, 1.6));
126 const sun = new THREE.DirectionalLight(0xffffff, 2.2);
127 sun.position.set(0.4, 1, 0.8);
128 this.scene.add(sun);
129 this.scene.add(this.model);
130 this.setView('persp');
132 tl.onChange(() => {
133 if (this.mode === 'replay') this.update();
134 });
135 new ResizeObserver(() => this.resize()).observe(this.wrap);
136 window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => this.rebuild());
137 }
139 showSetup(spec: SceneSpec) {
140 this.setupSpec = spec;
141 if (this.mode === 'setup') this.rebuild();
142 else this.setMode('setup');
143 }
146 // growing); keeps the mode and any exaggeration the user set
147 showReplay(spec: ReplaySpec, keep = false) {
149 let tmax = 0;
150 for (let i = 0; i < spec.frames.length; i++) if (i % 3 !== 0) tmax = Math.max(tmax, Math.abs(spec.frames[i]));
151 const size = modelSize(spec.sys);
d2bfcd73D model view: setup (supports, pins, excitation, pickup) and replay of saved frames on a shared timelineJeremy Magland 153 this.autoExaggeration = tmax > 0 ? Math.max(1, (GLYPH * size) / tmax) : 1;
157 if (this.mode === 'replay') {
158 this.frames = spec.frames;
159 this.update();
160 }
161 return;
162 }
d2bfcd73D model view: setup (supports, pins, excitation, pickup) and replay of saved frames on a shared timelineJeremy Magland 163 (this.tabs.querySelector('[data-v="replay"]') as HTMLButtonElement).disabled = false;
164 this.setMode('replay');
165 }
167 private setMode(m: Mode) {
168 this.mode = m;
169 this.tabs.querySelectorAll<HTMLButtonElement>('button').forEach((b) => b.classList.toggle('on', b.dataset.v === m));
170 this.bar.hidden = m !== 'replay';
171 this.exagRow.hidden = m !== 'replay';
172 if (m === 'setup') this.tl.pause();
173 this.rebuild();
174 }
176 private setView(v: View) {
178 const dirs: Record<View, [number, number, number]> = {
181 top: [0, 1, 0.0001],
182 end: [1, 0, 0],
a302473Phone fixes: fit 3D camera to narrow views, hide [hidden] buttons, touch hintsJeremy Magland 184 // back off far enough that the model (about 1.4 wide with its supports) fits horizontally
185 const half = Math.tan(THREE.MathUtils.degToRad(this.camera.fov / 2));
2cd36faBead drum: hex-lattice round head, felt mallet contact, top view and cross-section; shared instrument pageJeremy Magland 186 const fit = 0.75 / (half * this.camera.aspect) + 0.3;
a302473Phone fixes: fit 3D camera to narrow views, hide [hidden] buttons, touch hintsJeremy Magland 187 const dist = Math.max(this.view === 'persp' ? 1.2 : 1.45, fit);
188 this.camera.position.set(...dirs[v]).setLength(dist);
190 if (v === 'top') this.camera.up.set(0, 0, -1);
191 this.controls?.target.set(0, 0, 0);
192 this.camera.lookAt(0, 0, 0);
193 this.controls?.update();
194 this.render();
195 }
197 private resize() {
198 if (!this.renderer) return;
199 const w = this.wrap.clientWidth;
200 const h = this.wrap.clientHeight;
201 if (w === 0 || h === 0) return;
202 this.renderer.setSize(w, h);
a302473Phone fixes: fit 3D camera to narrow views, hide [hidden] buttons, touch hintsJeremy Magland 203 const narrowBefore = this.camera.aspect < 1;
205 this.camera.updateProjectionMatrix();
a302473Phone fixes: fit 3D camera to narrow views, hide [hidden] buttons, touch hintsJeremy Magland 206 // refit when the view turns portrait or landscape (phone rotation, first layout)
207 if (narrowBefore !== this.camera.aspect < 1 || !this.fitted) {
208 this.fitted = true;
209 this.setView(this.view);
210 } else this.render();
213 private render() {
214 this.renderer?.render(this.scene, this.camera);
215 }
217 // node position (beads and anchors), normalized
218 private nodePos(sys: BeadSystem, node: number, out: THREE.Vector3): THREE.Vector3 {
219 if (node < sys.n) out.set(sys.rest[3 * node], sys.rest[3 * node + 1], sys.rest[3 * node + 2]);
220 else out.set(...sys.anchors[node - sys.n].pos);
221 return out.sub(this.center).multiplyScalar(this.scale);
222 }
224 private rebuild() {
225 const spec = this.mode === 'replay' ? this.replaySpec : this.setupSpec;
226 disposeGroup(this.model);
227 this.beads = this.pins = this.springs = this.hammerArrows = null;
228 this.slideRings = [];
229 this.pickupArrow = null;
232 this.frames = this.mode === 'replay' && this.replaySpec ? this.replaySpec.frames : null;
233 if (!spec) return this.render();
234 if (this.renderer) this.renderer.setClearColor(new THREE.Color(css('--surface-1')));
235 const { sys, exc, pickup } = spec;
236 const n = sys.n;
238 // normalize: longest extent of beads and anchors -> 1, centered
239 const box = new THREE.Box3();
240 const v = new THREE.Vector3();
241 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]));
242 for (const a of sys.anchors) box.expandByPoint(v.set(...a.pos));
243 box.getCenter(this.center);
244 const size = box.getSize(new THREE.Vector3());
245 this.scale = 1 / Math.max(size.x, size.y, size.z, 1e-12);
247 // bead size from nearest-neighbour spacing
248 let minLen = Infinity;
249 const p = new THREE.Vector3();
250 const q = new THREE.Vector3();
251 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)));
252 if (!Number.isFinite(minLen)) minLen = 0.05;
253 const r = Math.min(0.32 * minLen, 0.022);
255 const cBead = new THREE.Color(css('--series-1'));
256 const cExc = new THREE.Color(css('--series-2'));
257 const cPick = new THREE.Color(css('--series-3'));
258 const cInk = new THREE.Color(css('--text-primary'));
259 const cMuted = new THREE.Color(css('--text-muted'));
261 // grid floor for depth cues
262 const grid = new THREE.GridHelper(1.4, 14, cMuted, cMuted);
263 (grid.material as THREE.Material).transparent = true;
264 (grid.material as THREE.Material).opacity = 0.25;
265 grid.position.y = -0.22;
266 this.model.add(grid);
267 this.model.add(axisTriad(new THREE.Vector3(-0.58, -0.22, -0.3), 0.08, cInk));
269 // beads (pinned beads are drawn as cubes instead)
270 const pickBead = (i: number) => pickup[3 * i] !== 0 || pickup[3 * i + 1] !== 0 || pickup[3 * i + 2] !== 0;
271 const free: number[] = [];
272 const pinned: number[] = [];
273 for (let i = 0; i < n; i++) (sys.pinned[i] ? pinned : free).push(i);
274 const beadMat = new THREE.MeshStandardMaterial({ roughness: 0.55, metalness: 0.1 });
275 this.beads = new THREE.InstancedMesh(new THREE.SphereGeometry(r, 20, 14), beadMat, Math.max(1, n));
276 this.beads.count = n;
2cd36faBead drum: hex-lattice round head, felt mallet contact, top view and cross-section; shared instrument pageJeremy Magland 277 // a pickup over every bead is named in the legend rather than painting the whole model
278 let nPick = 0;
279 for (let i = 0; i < n; i++) if (pickBead(i)) nPick++;
280 for (let i = 0; i < n; i++) this.beads.setColorAt(i, pickBead(i) && nPick < n ? cPick : cBead);
282 if (pinned.length) {
283 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);
284 this.model.add(this.pins);
285 }
286 (this.beads.userData as { free: number[]; pinned: number[] }) = { free, pinned };
288 // springs
289 const sg = new THREE.BufferGeometry();
290 sg.setAttribute('position', new THREE.BufferAttribute(new Float32Array(sys.si.length * 6), 3));
291 this.springs = new THREE.LineSegments(sg, new THREE.LineBasicMaterial({ color: cMuted }));
292 this.model.add(this.springs);
294 // supports
295 const wallSize = 0.16;
297 const hoop = new THREE.Mesh(
298 new THREE.TorusGeometry(spec.hoop.radius * this.scale, 0.012, 10, 96),
299 new THREE.MeshStandardMaterial({ color: cMuted, roughness: 0.7 }),
300 );
301 hoop.rotation.x = Math.PI / 2;
302 hoop.position.set(-this.center.x * this.scale, -this.center.y * this.scale, -this.center.z * this.scale);
303 this.model.add(hoop);
304 }
306 const o = new THREE.Vector3(-this.center.x, spec.post.y - this.center.y, -this.center.z).multiplyScalar(this.scale);
15fbb00Cymbal: replace felt washers with a minimal rigid hub (pinned beads around the hole), no support dampingJeremy Magland 308 const rod = new THREE.Mesh(new THREE.CylinderGeometry(0.006, 0.006, rodLen + 0.04, 10), new THREE.MeshStandardMaterial({ color: cMuted }));
309 rod.position.copy(o).add(new THREE.Vector3(0, 0.02 - rodLen / 2, 0));
311 }
312 if (spec.mic) {
313 const o = new THREE.Vector3(...spec.mic.from).sub(this.center).multiplyScalar(this.scale);
314 this.model.add(new THREE.ArrowHelper(new THREE.Vector3(...spec.mic.dir).normalize(), o, 0.42, new THREE.Color(css('--series-3')), 0.05, 0.025));
315 }
d2bfcd73D model view: setup (supports, pins, excitation, pickup) and replay of saved frames on a shared timelineJeremy Magland 317 const at = this.nodePos(sys, sys.n + sys.anchors.indexOf(a), new THREE.Vector3());
318 const side = at.x < 0 ? -1 : 1;
319 if (a.slideBead < 0) {
320 const wall = new THREE.Mesh(
321 new THREE.BoxGeometry(0.02, wallSize, wallSize),
322 new THREE.MeshStandardMaterial({ color: cMuted, roughness: 0.8 }),
323 );
324 wall.position.copy(at).add(new THREE.Vector3(side * 0.01, 0, 0));
325 this.model.add(wall);
326 } else {
327 const plane = new THREE.Mesh(
328 new THREE.PlaneGeometry(wallSize, wallSize),
329 new THREE.MeshBasicMaterial({ color: cMuted, transparent: true, opacity: 0.18, side: THREE.DoubleSide, depthWrite: false }),
330 );
331 plane.rotation.y = Math.PI / 2;
332 plane.position.copy(at);
333 this.model.add(plane);
334 const edges = new THREE.LineSegments(new THREE.EdgesGeometry(plane.geometry), new THREE.LineBasicMaterial({ color: cMuted }));
335 edges.rotation.y = Math.PI / 2;
336 edges.position.copy(at);
337 this.model.add(edges);
338 const ring = new THREE.Mesh(new THREE.TorusGeometry(1.3 * r, 0.35 * r, 8, 24), new THREE.MeshStandardMaterial({ color: cInk }));
339 ring.rotation.y = Math.PI / 2;
340 ring.position.copy(at);
341 this.model.add(ring);
342 this.slideRings.push({ mesh: ring, bead: a.slideBead });
343 }
344 });
346 // excitation glyphs
347 const legend: string[] = [];
348 const sw = (c: THREE.Color, shape = 'dot') => `<i class="glyph ${shape}" style="--c:#${c.getHexString()}"></i>`;
349 legend.push(`<span>${sw(cBead)}bead</span>`);
15fbb00Cymbal: replace felt washers with a minimal rigid hub (pinned beads around the hole), no support dampingJeremy Magland 350 if (pinned.length) legend.push(`<span>${sw(cInk, 'square')}${spec.post ? 'bead held at the hub' : 'pinned bead'}</span>`);
2cd36faBead drum: hex-lattice round head, felt mallet contact, top view and cross-section; shared instrument pageJeremy Magland 351 if (spec.hoop) legend.push(`<span>${sw(cMuted, 'ring')}clamped rim</span>`);
2cd36faBead drum: hex-lattice round head, felt mallet contact, top view and cross-section; shared instrument pageJeremy Magland 353 else if (sys.anchors.some((a) => a.slideBead < 0)) legend.push(`<span>${sw(cMuted, 'square')}clamped end (wall)</span>`);
d2bfcd73D model view: setup (supports, pins, excitation, pickup) and replay of saved frames on a shared timelineJeremy Magland 354 if (sys.anchors.some((a) => a.slideBead >= 0)) legend.push(`<span>${sw(cInk, 'ring')}sliding end (ring on a frictionless plane)</span>`);
355 const size0 = modelSize(sys);
356 const dirs = (field: Float64Array) => {
357 let m = 0;
358 for (let i = 0; i < n; i++) m = Math.max(m, Math.hypot(field[3 * i], field[3 * i + 1], field[3 * i + 2]));
359 return m;
360 };
361 if (this.mode === 'setup' && spec.excKind === 'pluck') {
362 const m = dirs(exc.u0);
363 if (m > 0) {
364 const E = (GLYPH * size0) / m;
365 const pts: THREE.Vector3[] = [];
366 const left = sys.anchors.find((a) => a.pos[0] < sys.rest[0]);
367 const right = sys.anchors.find((a) => a.pos[0] > sys.rest[3 * (n - 1)]);
368 if (left) pts.push(this.nodePos(sys, n + sys.anchors.indexOf(left), new THREE.Vector3()));
369 for (let i = 0; i < n; i++) {
370 const w = new THREE.Vector3(exc.u0[3 * i], exc.u0[3 * i + 1], exc.u0[3 * i + 2]).multiplyScalar(E * this.scale);
371 pts.push(this.nodePos(sys, i, new THREE.Vector3()).add(w));
372 }
373 if (right) pts.push(this.nodePos(sys, n + sys.anchors.indexOf(right), new THREE.Vector3()));
374 const line = new THREE.Line(new THREE.BufferGeometry().setFromPoints(pts), new THREE.LineDashedMaterial({ color: cExc, dashSize: 0.015, gapSize: 0.01 }));
375 line.computeLineDistances();
376 this.model.add(line);
377 let peak = 0;
378 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;
379 const from = this.nodePos(sys, peak, new THREE.Vector3());
380 const to = pts[peak + (left ? 1 : 0)];
381 const len = from.distanceTo(to);
382 if (len > 0) this.model.add(new THREE.ArrowHelper(to.clone().sub(from).normalize(), from, len, cExc, 0.25 * len, 0.12 * len));
383 legend.push(`<span>${sw(cExc, 'dash')}pluck: initial shape, drawn ×${fmtNum(E)} (${fmtNum(m * 1000)} mm peak)</span>`);
384 }
385 }
386 if (spec.excKind === 'strike' && this.mode === 'setup') {
387 const m = dirs(exc.v0);
388 if (m > 0) this.model.add(this.arrows(sys, exc.v0, m, cExc, false));
389 legend.push(`<span>${sw(cExc, 'arrow')}strike: initial velocity (${fmtNum(m)} m/s peak)</span>`);
390 }
2cd36faBead drum: hex-lattice round head, felt mallet contact, top view and cross-section; shared instrument pageJeremy Magland 391 if (this.mode === 'setup' && spec.excKind === 'press') {
392 const m = dirs(exc.u0);
393 if (m > 0) {
394 const E = (0.5 * GLYPH * size0) / m;
395 const pts: THREE.Vector3[] = [];
396 for (let i = 0; i < n; i++) {
397 if (Math.hypot(exc.u0[3 * i], exc.u0[3 * i + 1], exc.u0[3 * i + 2]) < 0.02 * m) continue;
398 const w = new THREE.Vector3(exc.u0[3 * i], exc.u0[3 * i + 1], exc.u0[3 * i + 2]).multiplyScalar(E * this.scale);
399 pts.push(this.nodePos(sys, i, new THREE.Vector3()).add(w));
400 }
401 const dots = new THREE.Points(new THREE.BufferGeometry().setFromPoints(pts), new THREE.PointsMaterial({ color: cExc, size: 1.2 * r }));
402 this.model.add(dots);
403 legend.push(`<span>${sw(cExc)}press: initial shape, drawn ×${fmtNum(E)} (${fmtNum(m * 1000)} mm deep)</span>`);
404 }
405 }
406 if (exc.mallet) {
407 const ml = exc.mallet;
c696b9aCymbal: plate bending via per-triangle shape operators, felt centre support, stick and mallet with angle and friction, far-field microphoneJeremy Magland 408 const dir = new THREE.Vector3(...ml.vel0).normalize(); // the stick travels along its own axis
410 for (let i = 0; i < n; i++) if (ml.weights[i] > 0) contact.addScaledVector(this.nodePos(sys, i, new THREE.Vector3()), ml.weights[i]);
411 const head = Math.max(1.6 * r, 0.025);
412 const group = new THREE.Group();
413 const felt = new THREE.Mesh(new THREE.SphereGeometry(head, 24, 16), new THREE.MeshStandardMaterial({ color: cExc, roughness: 0.9 }));
414 felt.position.copy(dir).multiplyScalar(-head);
415 const stick = new THREE.Mesh(
416 new THREE.CylinderGeometry(0.15 * head, 0.15 * head, 0.3, 10),
417 new THREE.MeshStandardMaterial({ color: cMuted, roughness: 0.6 }),
418 );
419 stick.position.copy(dir).multiplyScalar(-(2 * head + 0.15));
420 stick.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), dir);
421 group.add(felt, stick);
422 this.model.add(group);
424 const speed = Math.hypot(...ml.vel0);
425 const what = spec.excKind === 'stick' ? 'stick' : 'mallet';
c696b9aCymbal: plate bending via per-triangle shape operators, felt centre support, stick and mallet with angle and friction, far-field microphoneJeremy Magland 427 `<span>${sw(cExc)}${what}: ${fmtNum(ml.mass * 1000)} g at ${fmtNum(speed)} m/s, contact F = K δ^${fmtNum(ml.p)}${this.mode === 'replay' ? ' (motion exaggerated like the beads)' : ''}</span>`,
429 }
430 if ((spec.excKind === 'hammer' || spec.excKind === 'force') && exc.force) {
432 if (m > 0) {
433 this.hammerArrows = this.arrows(sys, exc.force.shape, m, cExc, true);
434 this.model.add(this.hammerArrows);
435 }
436 legend.push(
2cd36faBead drum: hex-lattice round head, felt mallet contact, top view and cross-section; shared instrument pageJeremy Magland 437 `<span>${sw(cExc, 'arrow')}${spec.excKind === 'hammer' ? 'hammer' : 'force'}: force pulse, ${fmtNum(exc.force.F0)} N peak over ${fmtNum(exc.force.tau * 1000)} ms${this.mode === 'replay' ? ' (shown while it acts)' : ''}</span>`,
439 }
441 // pickup
442 const pk: number[] = [];
443 for (let i = 0; i < n; i++) if (pickBead(i)) pk.push(i);
444 if (pk.length === 1) {
445 const i = pk[0];
446 const d = new THREE.Vector3(pickup[3 * i], pickup[3 * i + 1], pickup[3 * i + 2]).normalize();
447 const len = Math.max(0.05, 6 * r);
448 const arrow = new THREE.ArrowHelper(d, this.nodePos(sys, i, new THREE.Vector3()), len, cPick, 0.3 * len, 0.18 * len);
449 this.model.add(arrow);
450 this.pickupArrow = { arrow, bead: i };
451 }
c696b9aCymbal: plate bending via per-triangle shape operators, felt centre support, stick and mallet with angle and friction, far-field microphoneJeremy Magland 452 legend.push(spec.mic ? `<span>${sw(cPick, 'arrow')}${spec.mic.label}</span>` : `<span>${sw(cPick)}pickup: ${spec.pickupLabel}</span>`);
591834dCymbal: free plate with rigid-body motion removed (no support); lighter default dampingJeremy Magland 453 if (spec.note) legend.push(`<span>${spec.note}</span>`);
455 this.update();
456 }
458 // arrows proportional to a per-bead vector field; `into` = arrow ends at the bead (a push)
459 private arrows(sys: BeadSystem, field: Float64Array, max: number, color: THREE.Color, into: boolean): THREE.Group {
460 const g = new THREE.Group();
461 for (let i = 0; i < sys.n; i++) {
462 const d = new THREE.Vector3(field[3 * i], field[3 * i + 1], field[3 * i + 2]);
463 const mag = d.length();
464 if (mag < 0.05 * max) continue;
465 const len = GLYPH * (mag / max);
466 d.normalize();
467 const at = this.nodePos(sys, i, new THREE.Vector3());
468 const origin = into ? at.clone().addScaledVector(d, -len) : at;
469 const arrow = new THREE.ArrowHelper(d, origin, len, color, Math.min(0.03, 0.3 * len), Math.min(0.016, 0.18 * len));
470 arrow.userData = { bead: i, dir: d.clone(), len };
471 g.add(arrow);
472 }
473 return g;
474 }
476 // position everything for the current frame (or rest, in setup)
477 private update() {
478 const spec = this.spec;
479 if (!spec || !this.beads) return this.render();
480 const { sys } = spec;
481 const n = sys.n;
482 const frames = this.frames;
483 const off = frames ? this.tl.frame * 3 * n : 0;
484 const E = this.exaggeration * this.scale;
485 const P = new Array<THREE.Vector3>(n + sys.anchors.length);
486 for (let i = 0; i < n; i++) {
487 const p = this.nodePos(sys, i, new THREE.Vector3());
488 if (frames) p.add(new THREE.Vector3(frames[off + 3 * i], frames[off + 3 * i + 1], frames[off + 3 * i + 2]).multiplyScalar(E));
489 P[i] = p;
490 }
491 sys.anchors.forEach((a, k) => {
492 const p = this.nodePos(sys, n + k, new THREE.Vector3());
493 if (a.slideBead >= 0) {
494 p.y = P[a.slideBead].y;
495 p.z = P[a.slideBead].z;
496 }
497 P[n + k] = p;
498 });
499 const m = new THREE.Matrix4();
500 const { pinned } = this.beads.userData as { free: number[]; pinned: number[] };
501 for (let i = 0; i < n; i++) {
502 // hide pinned beads' spheres (cubes stand in)
503 m.makeScale(sys.pinned[i] ? 0 : 1, sys.pinned[i] ? 0 : 1, sys.pinned[i] ? 0 : 1).setPosition(P[i]);
504 this.beads.setMatrixAt(i, m);
505 }
506 this.beads.instanceMatrix.needsUpdate = true;
507 if (this.beads.instanceColor) this.beads.instanceColor.needsUpdate = true;
508 if (this.pins) {
509 pinned.forEach((i, k) => this.pins!.setMatrixAt(k, m.makeTranslation(P[i].x, P[i].y, P[i].z)));
510 this.pins.instanceMatrix.needsUpdate = true;
511 }
512 if (this.springs) {
513 const arr = (this.springs.geometry.getAttribute('position') as THREE.BufferAttribute).array as Float32Array;
514 for (let s = 0; s < sys.si.length; s++) {
515 const a = P[sys.si[s]];
516 const b = P[sys.sj[s]];
517 arr.set([a.x, a.y, a.z, b.x, b.y, b.z], 6 * s);
518 }
519 this.springs.geometry.getAttribute('position').needsUpdate = true;
520 this.springs.geometry.computeBoundingSphere();
521 }
522 for (const { mesh, bead } of this.slideRings) {
523 mesh.position.y = P[bead].y;
524 mesh.position.z = P[bead].z;
525 }
526 if (this.pickupArrow) this.pickupArrow.arrow.position.copy(P[this.pickupArrow.bead]);
528 const mp = this.mode === 'replay' ? this.replaySpec?.malletPos : undefined;
530 // in setup it hovers back along its path; in replay it follows the run
531 if (mp) g.add(new THREE.Vector3(mp[3 * this.tl.frame], mp[3 * this.tl.frame + 1], mp[3 * this.tl.frame + 2]).multiplyScalar(E));
532 else g.addScaledVector(this.mallet.dir, -0.6 * GLYPH);
535 const f = spec.exc.force;
536 const t = frames ? this.tl.times[this.tl.frame] : f.tau / 2;
537 const s = t >= 0 && t <= f.tau ? 0.5 * (1 - Math.cos((2 * Math.PI * t) / f.tau)) : 0;
538 this.hammerArrows.visible = s > 0.02;
539 for (const c of this.hammerArrows.children) {
540 const { bead, dir, len } = c.userData as { bead: number; dir: THREE.Vector3; len: number };
541 const l = Math.max(1e-4, len * (frames ? s : 1));
542 const arrow = c as THREE.ArrowHelper;
543 arrow.position.copy(P[bead]).addScaledVector(dir, -l);
544 arrow.setLength(l, Math.min(0.03, 0.3 * l), Math.min(0.016, 0.18 * l));
545 }
546 }
547 if (this.mode === 'replay' && frames) {
548 this.note.textContent =
549 `Displacements drawn ×${fmtNum(this.exaggeration)} (all three components, so longitudinal motion is exaggerated too). ` +
a302473Phone fixes: fit 3D camera to narrow views, hide [hidden] buttons, touch hintsJeremy Magland 550 `Drag to rotate; pinch or scroll to zoom; two-finger or right-drag to pan.`;
a302473Phone fixes: fit 3D camera to narrow views, hide [hidden] buttons, touch hintsJeremy Magland 552 this.note.textContent = 'Beads at rest. Drag to rotate; pinch or scroll to zoom; two-finger or right-drag to pan.';
554 this.render();
555 }
558function modelSize(sys: BeadSystem): number {
559 let lo = Infinity;
560 let hi = -Infinity;
561 for (let i = 0; i < sys.n; i++) {
562 lo = Math.min(lo, sys.rest[3 * i]);
563 hi = Math.max(hi, sys.rest[3 * i]);
564 }
565 for (const a of sys.anchors) {
566 lo = Math.min(lo, a.pos[0]);
567 hi = Math.max(hi, a.pos[0]);
568 }
569 return Math.max(hi - lo, 1e-12);
572function textSprite(text: string, color: THREE.Color): THREE.Sprite {
573 const c = document.createElement('canvas');
574 c.width = c.height = 64;
575 const ctx = c.getContext('2d')!;
576 ctx.fillStyle = `#${color.getHexString()}`;
577 ctx.font = '600 44px system-ui, sans-serif';
578 ctx.textAlign = 'center';
579 ctx.textBaseline = 'middle';
580 ctx.fillText(text, 32, 34);
581 const s = new THREE.Sprite(new THREE.SpriteMaterial({ map: new THREE.CanvasTexture(c), depthTest: false }));
582 s.scale.set(0.04, 0.04, 1);
583 return s;
586function axisTriad(at: THREE.Vector3, len: number, ink: THREE.Color): THREE.Group {
587 const g = new THREE.Group();
588 const axes: [string, THREE.Vector3][] = [
589 ['x', new THREE.Vector3(1, 0, 0)],
590 ['y', new THREE.Vector3(0, 1, 0)],
591 ['z', new THREE.Vector3(0, 0, 1)],
592 ];
593 for (const [name, d] of axes) {
594 g.add(new THREE.ArrowHelper(d, at, len, ink, 0.2 * len, 0.1 * len));
595 const label = textSprite(name, ink);
596 label.position.copy(at).addScaledVector(d, len * 1.3);
597 g.add(label);
598 }
599 return g;
602function disposeGroup(g: THREE.Group) {
603 g.traverse((o) => {
604 const m = o as THREE.Mesh;
605 m.geometry?.dispose();
606 const mat = m.material as THREE.Material | THREE.Material[] | undefined;
607 if (Array.isArray(mat)) mat.forEach((x) => x.dispose());
608 else mat?.dispose();
609 });
610 g.clear();
↑↓ move↵openescclose