d2bfcd73D model view: setup (supports, pins, excitation, pickup) and replay of saved frames on a shared timelineJeremy Magland 1import * as THREE from 'three';
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;
14 excKind: 'pluck' | 'strike' | 'hammer';
15 pickup: Float64Array; // 3n weights
16 pickupLabel: string;
17}
19export interface ReplaySpec extends SceneSpec {
20 frames: Float32Array; // nFrames x 3n, matches the timeline
21}
23type Mode = 'setup' | 'replay';
24type View = 'persp' | 'front' | 'top' | 'end';
26const GLYPH = 0.12; // length of the largest excitation glyph, in units of the model size
28export 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;
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;
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;
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);
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 });
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');
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 }
131 showSetup(spec: SceneSpec) {
132 this.setupSpec = spec;
133 if (this.mode === 'setup') this.rebuild();
134 else this.setMode('setup');
135 }
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 }
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 }
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 }
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 }
186 private render() {
187 this.renderer?.render(this.scene, this.camera);
188 }
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 }
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;
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);
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);
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'));
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));
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 };
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);
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 });
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 }
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 }
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 }
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}
458function 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}
472function 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}
486function 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}
502function 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}