1// 3D view of the geometry: the shell, its handle, the grain impact points
2// scattered over the surface, and the listener. Model coordinates put +x
3// along the shake axis, so the two alternating impact poles sit at +/-x;
4// three.js is y-up, so we map (x, y, z)_model -> (x, z, -y)_three, a proper
5// rotation. Impact points are coloured by stroke so the alternation between
6// the two poles is visible, and their size follows the impact speed.
8import { useEffect, useRef } from 'react';
9import * as THREE from 'three';
10import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
11import type { MaracaParams } from '../params';
13function m2t(x: number, y: number, z: number): [number, number, number] {
14 return [x, z, -y];
15}
17function makeLabel(text: string, color: string, scale = 1): THREE.Sprite {
18 const canvas = document.createElement('canvas');
19 canvas.width = 320;
20 canvas.height = 64;
21 const ctx = canvas.getContext('2d')!;
22 ctx.font = '600 36px system-ui, sans-serif';
23 ctx.fillStyle = color;
24 ctx.textAlign = 'center';
25 ctx.textBaseline = 'middle';
26 ctx.fillText(text, 160, 32);
27 const tex = new THREE.CanvasTexture(canvas);
28 const mat = new THREE.SpriteMaterial({ map: tex, depthTest: false, transparent: true });
29 const sprite = new THREE.Sprite(mat);
30 sprite.scale.set(0.075 * scale, 0.015 * scale, 1);
31 return sprite;
32}
34interface Props {
35 params: MaracaParams;
36 impactPos: Float32Array | null;
37 impactV: Float32Array | null;
38 impactT: Float32Array | null;
39}
41export default function Scene3D({ params, impactPos, impactV, impactT }: Props) {
42 const mountRef = useRef<HTMLDivElement | null>(null);
43 const sceneRef = useRef<{
44 scene: THREE.Scene;
45 group: THREE.Group | null;
46 renderer: THREE.WebGLRenderer;
47 camera: THREE.PerspectiveCamera;
48 } | null>(null);
50 // one-time scene setup
51 useEffect(() => {
52 const mount = mountRef.current!;
53 const renderer = new THREE.WebGLRenderer({ antialias: true });
54 renderer.setPixelRatio(window.devicePixelRatio);
55 renderer.setClearColor(0x14161a);
56 mount.appendChild(renderer.domElement);
58 const scene = new THREE.Scene();
59 const camera = new THREE.PerspectiveCamera(45, 1, 0.005, 20);
60 camera.position.set(0.13, 0.1, 0.19);
62 const controls = new OrbitControls(camera, renderer.domElement);
63 controls.target.set(0, 0.0, 0);
64 controls.enableDamping = true;
66 scene.add(new THREE.AmbientLight(0xffffff, 0.55));
67 const dir = new THREE.DirectionalLight(0xffffff, 1.1);
68 dir.position.set(0.5, 1.0, 0.6);
69 scene.add(dir);
70 const dir2 = new THREE.DirectionalLight(0x8899ff, 0.3);
71 dir2.position.set(-0.6, 0.3, -0.5);
72 scene.add(dir2);
74 const grid = new THREE.GridHelper(0.6, 24, 0x3a4048, 0x262b33);
75 grid.position.y = -0.09;
76 scene.add(grid);
78 // model axes: x red (shake axis), y green, z blue
79 const axisLen = 0.07;
80 const axes: [THREE.Vector3, number, string][] = [
81 [new THREE.Vector3(...m2t(axisLen, 0, 0)), 0xe57373, '+x'],
82 [new THREE.Vector3(...m2t(0, axisLen, 0)), 0x81c784, '+y'],
83 [new THREE.Vector3(...m2t(0, 0, axisLen)), 0x64b5f6, '+z'],
84 ];
85 for (const [v, color, label] of axes) {
86 const geo = new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(0, 0, 0), v]);
87 scene.add(new THREE.Line(geo, new THREE.LineBasicMaterial({ color })));
88 const s = makeLabel(label, '#' + color.toString(16).padStart(6, '0'));
89 s.position.copy(v).multiplyScalar(1.15);
90 scene.add(s);
91 }
93 sceneRef.current = { scene, group: null, renderer, camera };
95 let raf = 0;
96 const animate = () => {
97 raf = requestAnimationFrame(animate);
98 controls.update();
99 renderer.render(scene, camera);
100 };
101 animate();
103 const resize = () => {
104 const w = mount.clientWidth;
105 const h = mount.clientHeight;
106 renderer.setSize(w, h, false);
107 camera.aspect = w / h;
108 camera.updateProjectionMatrix();
109 };
110 resize();
111 const ro = new ResizeObserver(resize);
112 ro.observe(mount);
114 return () => {
115 cancelAnimationFrame(raf);
116 ro.disconnect();
117 controls.dispose();
118 renderer.dispose();
119 mount.removeChild(renderer.domElement);
120 sceneRef.current = null;
121 };
122 }, []);
124 // rebuild the geometry group whenever relevant parameters or impacts change
125 useEffect(() => {
126 const ctx = sceneRef.current;
127 if (!ctx) return;
128 if (ctx.group) {
129 ctx.scene.remove(ctx.group);
130 ctx.group.traverse((o) => {
131 const mesh = o as THREE.Mesh;
132 if (mesh.geometry) mesh.geometry.dispose();
133 });
134 }
136 const g = new THREE.Group();
137 const R = params.shell_radius_m;
139 // shell: translucent so the impact points on the far side stay visible
140 const shell = new THREE.Mesh(
141 new THREE.SphereGeometry(R, 48, 32),
142 new THREE.MeshStandardMaterial({
143 color: 0xc08b52,
144 roughness: 0.7,
145 metalness: 0.0,
146 transparent: true,
147 opacity: 0.42,
148 depthWrite: false,
149 }),
150 );
151 g.add(shell);
152 g.add(
153 new THREE.LineSegments(
154 new THREE.WireframeGeometry(new THREE.SphereGeometry(R, 24, 16)),
155 new THREE.LineBasicMaterial({ color: 0x6d4a29, transparent: true, opacity: 0.35 }),
156 ),
157 );
159 // handle, along -z (the shell is shaken about the x axis)
160 const handle = new THREE.Mesh(
161 new THREE.CylinderGeometry(R * 0.16, R * 0.13, R * 2.6, 16),
162 new THREE.MeshStandardMaterial({ color: 0x8d6e63, roughness: 0.75 }),
163 );
164 handle.position.set(...m2t(0, 0, -R - R * 1.2));
165 g.add(handle);
167 // grain impact points
168 if (impactPos && impactV && impactT && impactV.length > 0) {
169 const n = impactV.length;
170 let vMax = 1e-9;
171 for (let i = 0; i < n; i++) vMax = Math.max(vMax, impactV[i]);
172 const period = 1 / Math.max(1e-6, params.stroke_rate_hz);
173 const pos = new Float32Array(n * 3);
174 const col = new Float32Array(n * 3);
175 const sizes = new Float32Array(n);
176 const warm = new THREE.Color(0xffb74d);
177 const cool = new THREE.Color(0x4dd0e1);
178 for (let i = 0; i < n; i++) {
179 // sit the marker just outside the surface so it is not z-fought
180 const s = R * 1.012;
181 const t = m2t(impactPos[i * 3] * s, impactPos[i * 3 + 1] * s, impactPos[i * 3 + 2] * s);
182 pos[i * 3] = t[0];
183 pos[i * 3 + 1] = t[1];
184 pos[i * 3 + 2] = t[2];
185 const stroke = Math.floor(impactT[i] / period);
186 const c = stroke % 2 === 0 ? warm : cool;
187 col[i * 3] = c.r;
188 col[i * 3 + 1] = c.g;
189 col[i * 3 + 2] = c.b;
190 sizes[i] = impactV[i] / vMax;
191 }
192 const geo = new THREE.BufferGeometry();
193 geo.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
194 geo.setAttribute('color', new THREE.Float32BufferAttribute(col, 3));
195 g.add(
196 new THREE.Points(
197 geo,
198 new THREE.PointsMaterial({
199 size: R * 0.055,
200 vertexColors: true,
201 transparent: true,
202 opacity: 0.75,
203 sizeAttenuation: true,
204 }),
205 ),
206 );
207 }
209 // the two poles the grains alternate between
210 for (const sgn of [1, -1]) {
211 const dot = new THREE.Mesh(
212 new THREE.SphereGeometry(R * 0.07, 14, 10),
213 new THREE.MeshStandardMaterial({
214 color: sgn > 0 ? 0xffb74d : 0x4dd0e1,
215 emissive: sgn > 0 ? 0x4a2f0a : 0x0c3b42,
216 }),
217 );
218 dot.position.set(...m2t(sgn * R * 1.04, 0, 0));
219 g.add(dot);
220 }
221 const poleLabel = makeLabel('impact poles', '#ffb74d');
222 poleLabel.position.set(...m2t(R * 1.1, 0, R * 0.55));
223 g.add(poleLabel);
225 // listener: microphone marker + line from the shell centre
226 const lis = new THREE.Vector3(
227 ...m2t(params.listener_x_m, params.listener_y_m, params.listener_z_m),
228 );
229 const micHead = new THREE.Mesh(
230 new THREE.SphereGeometry(0.016, 20, 16),
231 new THREE.MeshStandardMaterial({ color: 0x4dd0e1, emissive: 0x0c3b42, roughness: 0.35 }),
232 );
233 micHead.position.copy(lis);
234 g.add(micHead);
235 const micBody = new THREE.Mesh(
236 new THREE.CylinderGeometry(0.008, 0.008, 0.05, 12),
237 new THREE.MeshStandardMaterial({ color: 0x455a64, roughness: 0.6 }),
238 );
239 micBody.position.copy(lis).add(new THREE.Vector3(0, -0.033, 0));
240 g.add(micBody);
241 const lineGeo = new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(0, 0, 0), lis]);
242 const line = new THREE.Line(
243 lineGeo,
244 new THREE.LineDashedMaterial({ color: 0x4dd0e1, dashSize: 0.012, gapSize: 0.009 }),
245 );
246 line.computeLineDistances();
247 g.add(line);
248 const dist = Math.hypot(params.listener_x_m, params.listener_y_m, params.listener_z_m);
249 const micLabel = makeLabel(`mic ${(dist * 100).toFixed(0)} cm`, '#4dd0e1');
250 micLabel.position.copy(lis).add(new THREE.Vector3(0, 0.045, 0));
251 g.add(micLabel);
253 ctx.scene.add(g);
254 ctx.group = g;
255 }, [
256 params.shell_radius_m,
257 params.stroke_rate_hz,
258 params.listener_x_m,
259 params.listener_y_m,
260 params.listener_z_m,
261 impactPos,
262 impactV,
263 impactT,
264 ]);
266 return (
267 <div className="scene3d" ref={mountRef}>
268 <div className="scene-hint">drag to orbit · scroll to zoom · right-drag to pan</div>
269 </div>
270 );
271}