// 3D view of the geometry: the shell, its handle, the grain impact points // scattered over the surface, and the listener. Model coordinates put +x // along the shake axis, so the two alternating impact poles sit at +/-x; // three.js is y-up, so we map (x, y, z)_model -> (x, z, -y)_three, a proper // rotation. Impact points are coloured by stroke so the alternation between // the two poles is visible, and their size follows the impact speed. import { useEffect, useRef } from 'react'; import * as THREE from 'three'; import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; import type { MaracaParams } from '../params'; function m2t(x: number, y: number, z: number): [number, number, number] { return [x, z, -y]; } function makeLabel(text: string, color: string, scale = 1): THREE.Sprite { const canvas = document.createElement('canvas'); canvas.width = 320; canvas.height = 64; const ctx = canvas.getContext('2d')!; ctx.font = '600 36px system-ui, sans-serif'; ctx.fillStyle = color; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(text, 160, 32); const tex = new THREE.CanvasTexture(canvas); const mat = new THREE.SpriteMaterial({ map: tex, depthTest: false, transparent: true }); const sprite = new THREE.Sprite(mat); sprite.scale.set(0.075 * scale, 0.015 * scale, 1); return sprite; } interface Props { params: MaracaParams; impactPos: Float32Array | null; impactV: Float32Array | null; impactT: Float32Array | null; } export default function Scene3D({ params, impactPos, impactV, impactT }: Props) { const mountRef = useRef(null); const sceneRef = useRef<{ scene: THREE.Scene; group: THREE.Group | null; renderer: THREE.WebGLRenderer; camera: THREE.PerspectiveCamera; } | null>(null); // one-time scene setup useEffect(() => { const mount = mountRef.current!; const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setPixelRatio(window.devicePixelRatio); renderer.setClearColor(0x14161a); mount.appendChild(renderer.domElement); const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(45, 1, 0.005, 20); camera.position.set(0.13, 0.1, 0.19); const controls = new OrbitControls(camera, renderer.domElement); controls.target.set(0, 0.0, 0); controls.enableDamping = true; scene.add(new THREE.AmbientLight(0xffffff, 0.55)); const dir = new THREE.DirectionalLight(0xffffff, 1.1); dir.position.set(0.5, 1.0, 0.6); scene.add(dir); const dir2 = new THREE.DirectionalLight(0x8899ff, 0.3); dir2.position.set(-0.6, 0.3, -0.5); scene.add(dir2); const grid = new THREE.GridHelper(0.6, 24, 0x3a4048, 0x262b33); grid.position.y = -0.09; scene.add(grid); // model axes: x red (shake axis), y green, z blue const axisLen = 0.07; const axes: [THREE.Vector3, number, string][] = [ [new THREE.Vector3(...m2t(axisLen, 0, 0)), 0xe57373, '+x'], [new THREE.Vector3(...m2t(0, axisLen, 0)), 0x81c784, '+y'], [new THREE.Vector3(...m2t(0, 0, axisLen)), 0x64b5f6, '+z'], ]; for (const [v, color, label] of axes) { const geo = new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(0, 0, 0), v]); scene.add(new THREE.Line(geo, new THREE.LineBasicMaterial({ color }))); const s = makeLabel(label, '#' + color.toString(16).padStart(6, '0')); s.position.copy(v).multiplyScalar(1.15); scene.add(s); } sceneRef.current = { scene, group: null, renderer, camera }; let raf = 0; const animate = () => { raf = requestAnimationFrame(animate); controls.update(); renderer.render(scene, camera); }; animate(); const resize = () => { const w = mount.clientWidth; const h = mount.clientHeight; renderer.setSize(w, h, false); camera.aspect = w / h; camera.updateProjectionMatrix(); }; resize(); const ro = new ResizeObserver(resize); ro.observe(mount); return () => { cancelAnimationFrame(raf); ro.disconnect(); controls.dispose(); renderer.dispose(); mount.removeChild(renderer.domElement); sceneRef.current = null; }; }, []); // rebuild the geometry group whenever relevant parameters or impacts change useEffect(() => { const ctx = sceneRef.current; if (!ctx) return; if (ctx.group) { ctx.scene.remove(ctx.group); ctx.group.traverse((o) => { const mesh = o as THREE.Mesh; if (mesh.geometry) mesh.geometry.dispose(); }); } const g = new THREE.Group(); const R = params.shell_radius_m; // shell: translucent so the impact points on the far side stay visible const shell = new THREE.Mesh( new THREE.SphereGeometry(R, 48, 32), new THREE.MeshStandardMaterial({ color: 0xc08b52, roughness: 0.7, metalness: 0.0, transparent: true, opacity: 0.42, depthWrite: false, }), ); g.add(shell); g.add( new THREE.LineSegments( new THREE.WireframeGeometry(new THREE.SphereGeometry(R, 24, 16)), new THREE.LineBasicMaterial({ color: 0x6d4a29, transparent: true, opacity: 0.35 }), ), ); // handle, along -z (the shell is shaken about the x axis) const handle = new THREE.Mesh( new THREE.CylinderGeometry(R * 0.16, R * 0.13, R * 2.6, 16), new THREE.MeshStandardMaterial({ color: 0x8d6e63, roughness: 0.75 }), ); handle.position.set(...m2t(0, 0, -R - R * 1.2)); g.add(handle); // grain impact points if (impactPos && impactV && impactT && impactV.length > 0) { const n = impactV.length; let vMax = 1e-9; for (let i = 0; i < n; i++) vMax = Math.max(vMax, impactV[i]); const period = 1 / Math.max(1e-6, params.stroke_rate_hz); const pos = new Float32Array(n * 3); const col = new Float32Array(n * 3); const sizes = new Float32Array(n); const warm = new THREE.Color(0xffb74d); const cool = new THREE.Color(0x4dd0e1); for (let i = 0; i < n; i++) { // sit the marker just outside the surface so it is not z-fought const s = R * 1.012; const t = m2t(impactPos[i * 3] * s, impactPos[i * 3 + 1] * s, impactPos[i * 3 + 2] * s); pos[i * 3] = t[0]; pos[i * 3 + 1] = t[1]; pos[i * 3 + 2] = t[2]; const stroke = Math.floor(impactT[i] / period); const c = stroke % 2 === 0 ? warm : cool; col[i * 3] = c.r; col[i * 3 + 1] = c.g; col[i * 3 + 2] = c.b; sizes[i] = impactV[i] / vMax; } const geo = new THREE.BufferGeometry(); geo.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3)); geo.setAttribute('color', new THREE.Float32BufferAttribute(col, 3)); g.add( new THREE.Points( geo, new THREE.PointsMaterial({ size: R * 0.055, vertexColors: true, transparent: true, opacity: 0.75, sizeAttenuation: true, }), ), ); } // the two poles the grains alternate between for (const sgn of [1, -1]) { const dot = new THREE.Mesh( new THREE.SphereGeometry(R * 0.07, 14, 10), new THREE.MeshStandardMaterial({ color: sgn > 0 ? 0xffb74d : 0x4dd0e1, emissive: sgn > 0 ? 0x4a2f0a : 0x0c3b42, }), ); dot.position.set(...m2t(sgn * R * 1.04, 0, 0)); g.add(dot); } const poleLabel = makeLabel('impact poles', '#ffb74d'); poleLabel.position.set(...m2t(R * 1.1, 0, R * 0.55)); g.add(poleLabel); // listener: microphone marker + line from the shell centre const lis = new THREE.Vector3( ...m2t(params.listener_x_m, params.listener_y_m, params.listener_z_m), ); const micHead = new THREE.Mesh( new THREE.SphereGeometry(0.016, 20, 16), new THREE.MeshStandardMaterial({ color: 0x4dd0e1, emissive: 0x0c3b42, roughness: 0.35 }), ); micHead.position.copy(lis); g.add(micHead); const micBody = new THREE.Mesh( new THREE.CylinderGeometry(0.008, 0.008, 0.05, 12), new THREE.MeshStandardMaterial({ color: 0x455a64, roughness: 0.6 }), ); micBody.position.copy(lis).add(new THREE.Vector3(0, -0.033, 0)); g.add(micBody); const lineGeo = new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(0, 0, 0), lis]); const line = new THREE.Line( lineGeo, new THREE.LineDashedMaterial({ color: 0x4dd0e1, dashSize: 0.012, gapSize: 0.009 }), ); line.computeLineDistances(); g.add(line); const dist = Math.hypot(params.listener_x_m, params.listener_y_m, params.listener_z_m); const micLabel = makeLabel(`mic ${(dist * 100).toFixed(0)} cm`, '#4dd0e1'); micLabel.position.copy(lis).add(new THREE.Vector3(0, 0.045, 0)); g.add(micLabel); ctx.scene.add(g); ctx.group = g; }, [ params.shell_radius_m, params.stroke_rate_hz, params.listener_x_m, params.listener_y_m, params.listener_z_m, impactPos, impactV, impactT, ]); return (
drag to orbit · scroll to zoom · right-drag to pan
); }