/ proof-of-concept / toy-instruments
Sign in
proof-of-concept / toy-instruments
toy-instruments / src / ui / scene3d.ts
522 lines · 22.1 KBBlameHistoryRaw
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;
19export interface ReplaySpec extends SceneSpec {
20 frames: Float32Array; // nFrames x 3n, matches the timeline
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 view: View = 'persp';
44 private fitted = false;
45 private setupSpec: SceneSpec | null = null;
46 private replaySpec: ReplaySpec | null = null;
47 private exaggeration = 1;
48 private autoExaggeration = 1;
50 // geometry of the current build, in normalized scene units
51 private scale = 1;
52 private center = new THREE.Vector3();
53 private beads: THREE.InstancedMesh | null = null;
54 private pins: THREE.InstancedMesh | null = null;
55 private springs: THREE.LineSegments | null = null;
56 private slideRings: { mesh: THREE.Mesh; bead: number }[] = [];
57 private hammerArrows: THREE.Group | null = null;
58 private pickupArrow: { arrow: THREE.ArrowHelper; bead: number } | null = null;
59 private spec: SceneSpec | null = null;
60 private frames: Float32Array | null = null;
62 constructor(root: HTMLElement, private tl: Timeline) {
63 root.innerHTML = `
64 <div class="card-head">
65 <div class="seg" data-r="tabs"><button data-v="setup" class="on">Setup</button><button data-v="replay" disabled>Replay</button></div>
66 <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>
67 </div>
68 <div class="scene-wrap" data-r="wrap"></div>
69 <div class="legend scene-legend" data-r="legend"></div>
70 <div class="viewer-bar" data-r="exag" hidden>
71 <label class="inline">exaggeration × <input type="number" min="1" step="any" data-r="exag-in" style="width:110px"></label>
72 <button class="btn-small" data-r="exag-auto">Auto</button>
73 </div>
74 <div data-r="bar" hidden></div>
75 <p class="note" data-r="note"></p>`;
76 const q = <T extends HTMLElement>(r: string) => root.querySelector(`[data-r="${r}"]`) as T;
77 this.wrap = q('wrap');
78 this.legend = q('legend');
79 this.note = q('note');
80 this.tabs = q('tabs');
81 this.exagRow = q('exag');
82 this.exagInput = q('exag-in');
83 this.bar = timelineBar(tl);
84 this.bar.hidden = true;
85 q('bar').replaceWith(this.bar);
87 this.tabs.addEventListener('click', (e) => {
88 const b = (e.target as HTMLElement).closest('button');
89 if (b && !b.disabled) this.setMode(b.dataset.v as Mode);
90 });
91 q('views').addEventListener('click', (e) => {
92 const b = (e.target as HTMLElement).closest('button');
93 if (!b) return;
94 q('views').querySelectorAll('button').forEach((x) => x.classList.toggle('on', x === b));
95 this.setView(b.dataset.v as View);
96 });
97 this.exagInput.addEventListener('change', () => {
98 const v = Number(this.exagInput.value);
99 if (v > 0) {
100 this.exaggeration = v;
101 this.update();
102 }
103 });
104 q('exag-auto').addEventListener('click', () => {
105 this.exaggeration = this.autoExaggeration;
106 this.exagInput.value = fmtNum(this.exaggeration);
107 this.update();
108 });
110 try {
111 this.renderer = new THREE.WebGLRenderer({ antialias: true });
112 this.renderer.setPixelRatio(window.devicePixelRatio || 1);
113 this.wrap.append(this.renderer.domElement);
114 this.controls = new OrbitControls(this.camera, this.renderer.domElement);
115 this.controls.addEventListener('change', () => this.render());
116 } catch {
117 this.wrap.innerHTML = '<p class="note">WebGL is not available in this browser, so the 3D view is off.</p>';
118 }
119 this.scene.add(new THREE.AmbientLight(0xffffff, 1.6));
120 const sun = new THREE.DirectionalLight(0xffffff, 2.2);
121 sun.position.set(0.4, 1, 0.8);
122 this.scene.add(sun);
123 this.scene.add(this.model);
124 this.setView('persp');
126 tl.onChange(() => {
127 if (this.mode === 'replay') this.update();
128 });
129 new ResizeObserver(() => this.resize()).observe(this.wrap);
130 window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => this.rebuild());
131 }
133 showSetup(spec: SceneSpec) {
134 this.setupSpec = spec;
135 if (this.mode === 'setup') this.rebuild();
136 else this.setMode('setup');
137 }
139 showReplay(spec: ReplaySpec) {
140 this.replaySpec = spec;
141 let tmax = 0;
142 for (let i = 0; i < spec.frames.length; i++) if (i % 3 !== 0) tmax = Math.max(tmax, Math.abs(spec.frames[i]));
143 const size = modelSize(spec.sys);
144 this.autoExaggeration = tmax > 0 ? Math.max(1, (GLYPH * size) / tmax) : 1;
145 this.exaggeration = this.autoExaggeration;
146 this.exagInput.value = fmtNum(this.exaggeration);
147 (this.tabs.querySelector('[data-v="replay"]') as HTMLButtonElement).disabled = false;
148 this.setMode('replay');
149 }
151 private setMode(m: Mode) {
152 this.mode = m;
153 this.tabs.querySelectorAll<HTMLButtonElement>('button').forEach((b) => b.classList.toggle('on', b.dataset.v === m));
154 this.bar.hidden = m !== 'replay';
155 this.exagRow.hidden = m !== 'replay';
156 if (m === 'setup') this.tl.pause();
157 this.rebuild();
158 }
160 private setView(v: View) {
161 this.view = v;
162 const dirs: Record<View, [number, number, number]> = {
163 persp: [-0.45, 0.42, 1.05],
164 front: [0, 0, 1],
165 top: [0, 1, 0.0001],
166 end: [1, 0, 0],
167 };
168 // back off far enough that the model (about 1.4 wide with its supports) fits horizontally
169 const half = Math.tan(THREE.MathUtils.degToRad(this.camera.fov / 2));
170 const fit = 0.75 / (half * Math.min(this.camera.aspect, 1)) + 0.3;
171 const dist = Math.max(this.view === 'persp' ? 1.2 : 1.45, fit);
172 this.camera.position.set(...dirs[v]).setLength(dist);
173 this.camera.up.set(0, 1, 0);
174 if (v === 'top') this.camera.up.set(0, 0, -1);
175 this.controls?.target.set(0, 0, 0);
176 this.camera.lookAt(0, 0, 0);
177 this.controls?.update();
178 this.render();
179 }
181 private resize() {
182 if (!this.renderer) return;
183 const w = this.wrap.clientWidth;
184 const h = this.wrap.clientHeight;
185 if (w === 0 || h === 0) return;
186 this.renderer.setSize(w, h);
187 const narrowBefore = this.camera.aspect < 1;
188 this.camera.aspect = w / h;
189 this.camera.updateProjectionMatrix();
190 // refit when the view turns portrait or landscape (phone rotation, first layout)
191 if (narrowBefore !== this.camera.aspect < 1 || !this.fitted) {
192 this.fitted = true;
193 this.setView(this.view);
194 } else this.render();
195 }
197 private render() {
198 this.renderer?.render(this.scene, this.camera);
199 }
201 // node position (beads and anchors), normalized
202 private nodePos(sys: BeadSystem, node: number, out: THREE.Vector3): THREE.Vector3 {
203 if (node < sys.n) out.set(sys.rest[3 * node], sys.rest[3 * node + 1], sys.rest[3 * node + 2]);
204 else out.set(...sys.anchors[node - sys.n].pos);
205 return out.sub(this.center).multiplyScalar(this.scale);
206 }
208 private rebuild() {
209 const spec = this.mode === 'replay' ? this.replaySpec : this.setupSpec;
210 disposeGroup(this.model);
211 this.beads = this.pins = this.springs = this.hammerArrows = null;
212 this.slideRings = [];
213 this.pickupArrow = null;
214 this.spec = spec;
215 this.frames = this.mode === 'replay' && this.replaySpec ? this.replaySpec.frames : null;
216 if (!spec) return this.render();
217 if (this.renderer) this.renderer.setClearColor(new THREE.Color(css('--surface-1')));
218 const { sys, exc, pickup } = spec;
219 const n = sys.n;
221 // normalize: longest extent of beads and anchors -> 1, centered
222 const box = new THREE.Box3();
223 const v = new THREE.Vector3();
224 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]));
225 for (const a of sys.anchors) box.expandByPoint(v.set(...a.pos));
226 box.getCenter(this.center);
227 const size = box.getSize(new THREE.Vector3());
228 this.scale = 1 / Math.max(size.x, size.y, size.z, 1e-12);
230 // bead size from nearest-neighbour spacing
231 let minLen = Infinity;
232 const p = new THREE.Vector3();
233 const q = new THREE.Vector3();
234 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)));
235 if (!Number.isFinite(minLen)) minLen = 0.05;
236 const r = Math.min(0.32 * minLen, 0.022);
238 const cBead = new THREE.Color(css('--series-1'));
239 const cExc = new THREE.Color(css('--series-2'));
240 const cPick = new THREE.Color(css('--series-3'));
241 const cInk = new THREE.Color(css('--text-primary'));
242 const cMuted = new THREE.Color(css('--text-muted'));
244 // grid floor for depth cues
245 const grid = new THREE.GridHelper(1.4, 14, cMuted, cMuted);
246 (grid.material as THREE.Material).transparent = true;
247 (grid.material as THREE.Material).opacity = 0.25;
248 grid.position.y = -0.22;
249 this.model.add(grid);
250 this.model.add(axisTriad(new THREE.Vector3(-0.58, -0.22, -0.3), 0.08, cInk));
252 // beads (pinned beads are drawn as cubes instead)
253 const pickBead = (i: number) => pickup[3 * i] !== 0 || pickup[3 * i + 1] !== 0 || pickup[3 * i + 2] !== 0;
254 const free: number[] = [];
255 const pinned: number[] = [];
256 for (let i = 0; i < n; i++) (sys.pinned[i] ? pinned : free).push(i);
257 const beadMat = new THREE.MeshStandardMaterial({ roughness: 0.55, metalness: 0.1 });
258 this.beads = new THREE.InstancedMesh(new THREE.SphereGeometry(r, 20, 14), beadMat, Math.max(1, n));
259 this.beads.count = n;
260 for (let i = 0; i < n; i++) this.beads.setColorAt(i, pickBead(i) ? cPick : cBead);
261 this.model.add(this.beads);
262 if (pinned.length) {
263 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);
264 this.model.add(this.pins);
265 }
266 (this.beads.userData as { free: number[]; pinned: number[] }) = { free, pinned };
268 // springs
269 const sg = new THREE.BufferGeometry();
270 sg.setAttribute('position', new THREE.BufferAttribute(new Float32Array(sys.si.length * 6), 3));
271 this.springs = new THREE.LineSegments(sg, new THREE.LineBasicMaterial({ color: cMuted }));
272 this.model.add(this.springs);
274 // supports
275 const wallSize = 0.16;
276 sys.anchors.forEach((a) => {
277 const at = this.nodePos(sys, sys.n + sys.anchors.indexOf(a), new THREE.Vector3());
278 const side = at.x < 0 ? -1 : 1;
279 if (a.slideBead < 0) {
280 const wall = new THREE.Mesh(
281 new THREE.BoxGeometry(0.02, wallSize, wallSize),
282 new THREE.MeshStandardMaterial({ color: cMuted, roughness: 0.8 }),
283 );
284 wall.position.copy(at).add(new THREE.Vector3(side * 0.01, 0, 0));
285 this.model.add(wall);
286 } else {
287 const plane = new THREE.Mesh(
288 new THREE.PlaneGeometry(wallSize, wallSize),
289 new THREE.MeshBasicMaterial({ color: cMuted, transparent: true, opacity: 0.18, side: THREE.DoubleSide, depthWrite: false }),
290 );
291 plane.rotation.y = Math.PI / 2;
292 plane.position.copy(at);
293 this.model.add(plane);
294 const edges = new THREE.LineSegments(new THREE.EdgesGeometry(plane.geometry), new THREE.LineBasicMaterial({ color: cMuted }));
295 edges.rotation.y = Math.PI / 2;
296 edges.position.copy(at);
297 this.model.add(edges);
298 const ring = new THREE.Mesh(new THREE.TorusGeometry(1.3 * r, 0.35 * r, 8, 24), new THREE.MeshStandardMaterial({ color: cInk }));
299 ring.rotation.y = Math.PI / 2;
300 ring.position.copy(at);
301 this.model.add(ring);
302 this.slideRings.push({ mesh: ring, bead: a.slideBead });
303 }
304 });
306 // excitation glyphs
307 const legend: string[] = [];
308 const sw = (c: THREE.Color, shape = 'dot') => `<i class="glyph ${shape}" style="--c:#${c.getHexString()}"></i>`;
309 legend.push(`<span>${sw(cBead)}bead</span>`);
310 if (pinned.length) legend.push(`<span>${sw(cInk, 'square')}pinned bead</span>`);
311 if (sys.anchors.some((a) => a.slideBead < 0)) legend.push(`<span>${sw(cMuted, 'square')}clamped end (wall)</span>`);
312 if (sys.anchors.some((a) => a.slideBead >= 0)) legend.push(`<span>${sw(cInk, 'ring')}sliding end (ring on a frictionless plane)</span>`);
313 const size0 = modelSize(sys);
314 const dirs = (field: Float64Array) => {
315 let m = 0;
316 for (let i = 0; i < n; i++) m = Math.max(m, Math.hypot(field[3 * i], field[3 * i + 1], field[3 * i + 2]));
317 return m;
318 };
319 if (this.mode === 'setup' && spec.excKind === 'pluck') {
320 const m = dirs(exc.u0);
321 if (m > 0) {
322 const E = (GLYPH * size0) / m;
323 const pts: THREE.Vector3[] = [];
324 const left = sys.anchors.find((a) => a.pos[0] < sys.rest[0]);
325 const right = sys.anchors.find((a) => a.pos[0] > sys.rest[3 * (n - 1)]);
326 if (left) pts.push(this.nodePos(sys, n + sys.anchors.indexOf(left), new THREE.Vector3()));
327 for (let i = 0; i < n; i++) {
328 const w = new THREE.Vector3(exc.u0[3 * i], exc.u0[3 * i + 1], exc.u0[3 * i + 2]).multiplyScalar(E * this.scale);
329 pts.push(this.nodePos(sys, i, new THREE.Vector3()).add(w));
330 }
331 if (right) pts.push(this.nodePos(sys, n + sys.anchors.indexOf(right), new THREE.Vector3()));
332 const line = new THREE.Line(new THREE.BufferGeometry().setFromPoints(pts), new THREE.LineDashedMaterial({ color: cExc, dashSize: 0.015, gapSize: 0.01 }));
333 line.computeLineDistances();
334 this.model.add(line);
335 let peak = 0;
336 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;
337 const from = this.nodePos(sys, peak, new THREE.Vector3());
338 const to = pts[peak + (left ? 1 : 0)];
339 const len = from.distanceTo(to);
340 if (len > 0) this.model.add(new THREE.ArrowHelper(to.clone().sub(from).normalize(), from, len, cExc, 0.25 * len, 0.12 * len));
341 legend.push(`<span>${sw(cExc, 'dash')}pluck: initial shape, drawn ×${fmtNum(E)} (${fmtNum(m * 1000)} mm peak)</span>`);
342 }
343 }
344 if (spec.excKind === 'strike' && this.mode === 'setup') {
345 const m = dirs(exc.v0);
346 if (m > 0) this.model.add(this.arrows(sys, exc.v0, m, cExc, false));
347 legend.push(`<span>${sw(cExc, 'arrow')}strike: initial velocity (${fmtNum(m)} m/s peak)</span>`);
348 }
349 if (spec.excKind === 'hammer' && exc.force) {
350 const m = dirs(exc.force.shape);
351 if (m > 0) {
352 this.hammerArrows = this.arrows(sys, exc.force.shape, m, cExc, true);
353 this.model.add(this.hammerArrows);
354 }
355 legend.push(
356 `<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>`,
357 );
358 }
360 // pickup
361 const pk: number[] = [];
362 for (let i = 0; i < n; i++) if (pickBead(i)) pk.push(i);
363 if (pk.length === 1) {
364 const i = pk[0];
365 const d = new THREE.Vector3(pickup[3 * i], pickup[3 * i + 1], pickup[3 * i + 2]).normalize();
366 const len = Math.max(0.05, 6 * r);
367 const arrow = new THREE.ArrowHelper(d, this.nodePos(sys, i, new THREE.Vector3()), len, cPick, 0.3 * len, 0.18 * len);
368 this.model.add(arrow);
369 this.pickupArrow = { arrow, bead: i };
370 }
371 legend.push(`<span>${sw(cPick)}pickup: ${spec.pickupLabel}</span>`);
372 this.legend.innerHTML = legend.join('');
373 this.update();
374 }
376 // arrows proportional to a per-bead vector field; `into` = arrow ends at the bead (a push)
377 private arrows(sys: BeadSystem, field: Float64Array, max: number, color: THREE.Color, into: boolean): THREE.Group {
378 const g = new THREE.Group();
379 for (let i = 0; i < sys.n; i++) {
380 const d = new THREE.Vector3(field[3 * i], field[3 * i + 1], field[3 * i + 2]);
381 const mag = d.length();
382 if (mag < 0.05 * max) continue;
383 const len = GLYPH * (mag / max);
384 d.normalize();
385 const at = this.nodePos(sys, i, new THREE.Vector3());
386 const origin = into ? at.clone().addScaledVector(d, -len) : at;
387 const arrow = new THREE.ArrowHelper(d, origin, len, color, Math.min(0.03, 0.3 * len), Math.min(0.016, 0.18 * len));
388 arrow.userData = { bead: i, dir: d.clone(), len };
389 g.add(arrow);
390 }
391 return g;
392 }
394 // position everything for the current frame (or rest, in setup)
395 private update() {
396 const spec = this.spec;
397 if (!spec || !this.beads) return this.render();
398 const { sys } = spec;
399 const n = sys.n;
400 const frames = this.frames;
401 const off = frames ? this.tl.frame * 3 * n : 0;
402 const E = this.exaggeration * this.scale;
403 const P = new Array<THREE.Vector3>(n + sys.anchors.length);
404 for (let i = 0; i < n; i++) {
405 const p = this.nodePos(sys, i, new THREE.Vector3());
406 if (frames) p.add(new THREE.Vector3(frames[off + 3 * i], frames[off + 3 * i + 1], frames[off + 3 * i + 2]).multiplyScalar(E));
407 P[i] = p;
408 }
409 sys.anchors.forEach((a, k) => {
410 const p = this.nodePos(sys, n + k, new THREE.Vector3());
411 if (a.slideBead >= 0) {
412 p.y = P[a.slideBead].y;
413 p.z = P[a.slideBead].z;
414 }
415 P[n + k] = p;
416 });
417 const m = new THREE.Matrix4();
418 const { pinned } = this.beads.userData as { free: number[]; pinned: number[] };
419 for (let i = 0; i < n; i++) {
420 // hide pinned beads' spheres (cubes stand in)
421 m.makeScale(sys.pinned[i] ? 0 : 1, sys.pinned[i] ? 0 : 1, sys.pinned[i] ? 0 : 1).setPosition(P[i]);
422 this.beads.setMatrixAt(i, m);
423 }
424 this.beads.instanceMatrix.needsUpdate = true;
425 if (this.beads.instanceColor) this.beads.instanceColor.needsUpdate = true;
426 if (this.pins) {
427 pinned.forEach((i, k) => this.pins!.setMatrixAt(k, m.makeTranslation(P[i].x, P[i].y, P[i].z)));
428 this.pins.instanceMatrix.needsUpdate = true;
429 }
430 if (this.springs) {
431 const arr = (this.springs.geometry.getAttribute('position') as THREE.BufferAttribute).array as Float32Array;
432 for (let s = 0; s < sys.si.length; s++) {
433 const a = P[sys.si[s]];
434 const b = P[sys.sj[s]];
435 arr.set([a.x, a.y, a.z, b.x, b.y, b.z], 6 * s);
436 }
437 this.springs.geometry.getAttribute('position').needsUpdate = true;
438 this.springs.geometry.computeBoundingSphere();
439 }
440 for (const { mesh, bead } of this.slideRings) {
441 mesh.position.y = P[bead].y;
442 mesh.position.z = P[bead].z;
443 }
444 if (this.pickupArrow) this.pickupArrow.arrow.position.copy(P[this.pickupArrow.bead]);
445 if (this.hammerArrows && spec.exc.force) {
446 const f = spec.exc.force;
447 const t = frames ? this.tl.times[this.tl.frame] : f.tau / 2;
448 const s = t >= 0 && t <= f.tau ? 0.5 * (1 - Math.cos((2 * Math.PI * t) / f.tau)) : 0;
449 this.hammerArrows.visible = s > 0.02;
450 for (const c of this.hammerArrows.children) {
451 const { bead, dir, len } = c.userData as { bead: number; dir: THREE.Vector3; len: number };
452 const l = Math.max(1e-4, len * (frames ? s : 1));
453 const arrow = c as THREE.ArrowHelper;
454 arrow.position.copy(P[bead]).addScaledVector(dir, -l);
455 arrow.setLength(l, Math.min(0.03, 0.3 * l), Math.min(0.016, 0.18 * l));
456 }
457 }
458 if (this.mode === 'replay' && frames) {
459 this.note.textContent =
460 `Displacements drawn ×${fmtNum(this.exaggeration)} (all three components, so longitudinal motion is exaggerated too). ` +
461 `Drag to rotate; pinch or scroll to zoom; two-finger or right-drag to pan.`;
462 } else {
463 this.note.textContent = 'Beads at rest. Drag to rotate; pinch or scroll to zoom; two-finger or right-drag to pan.';
464 }
465 this.render();
466 }
469function modelSize(sys: BeadSystem): number {
470 let lo = Infinity;
471 let hi = -Infinity;
472 for (let i = 0; i < sys.n; i++) {
473 lo = Math.min(lo, sys.rest[3 * i]);
474 hi = Math.max(hi, sys.rest[3 * i]);
475 }
476 for (const a of sys.anchors) {
477 lo = Math.min(lo, a.pos[0]);
478 hi = Math.max(hi, a.pos[0]);
479 }
480 return Math.max(hi - lo, 1e-12);
483function textSprite(text: string, color: THREE.Color): THREE.Sprite {
484 const c = document.createElement('canvas');
485 c.width = c.height = 64;
486 const ctx = c.getContext('2d')!;
487 ctx.fillStyle = `#${color.getHexString()}`;
488 ctx.font = '600 44px system-ui, sans-serif';
489 ctx.textAlign = 'center';
490 ctx.textBaseline = 'middle';
491 ctx.fillText(text, 32, 34);
492 const s = new THREE.Sprite(new THREE.SpriteMaterial({ map: new THREE.CanvasTexture(c), depthTest: false }));
493 s.scale.set(0.04, 0.04, 1);
494 return s;
497function axisTriad(at: THREE.Vector3, len: number, ink: THREE.Color): THREE.Group {
498 const g = new THREE.Group();
499 const axes: [string, THREE.Vector3][] = [
500 ['x', new THREE.Vector3(1, 0, 0)],
501 ['y', new THREE.Vector3(0, 1, 0)],
502 ['z', new THREE.Vector3(0, 0, 1)],
503 ];
504 for (const [name, d] of axes) {
505 g.add(new THREE.ArrowHelper(d, at, len, ink, 0.2 * len, 0.1 * len));
506 const label = textSprite(name, ink);
507 label.position.copy(at).addScaledVector(d, len * 1.3);
508 g.add(label);
509 }
510 return g;
513function disposeGroup(g: THREE.Group) {
514 g.traverse((o) => {
515 const m = o as THREE.Mesh;
516 m.geometry?.dispose();
517 const mat = m.material as THREE.Material | THREE.Material[] | undefined;
518 if (Array.isArray(mat)) mat.forEach((x) => x.dispose());
519 else mat?.dispose();
520 });
521 g.clear();
↑↓ move↵openescclose