// 3D view of the geometry: block, cavity hint, slit, strike point + mallet, // listener, radiating surface elements. Model coordinates are block-centred // with +z up out of the struck face; three.js is y-up, so we map // (x, y, z)_model -> (x, z, -y)_three, a proper rotation. import { useEffect, useRef } from 'react'; import * as THREE from 'three'; import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; import type { WoodblockParams } from '../params'; function m2t(x: number, y: number, z: number): [number, number, number] { return [x, z, -y]; } function makeLabel(text: string, color: string): 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.15, 0.03, 1); return sprite; } interface Props { params: WoodblockParams; } export default function Scene3D({ params }: 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.01, 20); camera.position.set(0.45, 0.35, 0.6); const controls = new OrbitControls(camera, renderer.domElement); controls.target.set(0, 0.06, 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(2, 40, 0x3a4048, 0x262b33); grid.position.y = -0.101; scene.add(grid); // model axes: x red (along bar), y green (across width), z blue (up) const axisLen = 0.14; 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 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 L = params.length_m; const W = params.width_m; const Hb = params.block_height_m; // block body const wood = new THREE.MeshStandardMaterial({ color: 0xa06f45, roughness: 0.65, metalness: 0.0, transparent: true, opacity: 0.92, }); const block = new THREE.Mesh(new THREE.BoxGeometry(L, Hb, W), wood); g.add(block); const edges = new THREE.LineSegments( new THREE.EdgesGeometry(new THREE.BoxGeometry(L, Hb, W)), new THREE.LineBasicMaterial({ color: 0x5c4026 }), ); g.add(edges); // cavity hint: darker inner box over the cavity wall span const lo = params.cavity_span_lo; const hi = params.cavity_span_hi; const cavLen = Math.max(0.001, (hi - lo) * L); const cavCentre = ((lo + hi) / 2 - 0.5) * L; const cavity = new THREE.Mesh( new THREE.BoxGeometry(cavLen, Hb * 0.55, W * 0.72), new THREE.MeshStandardMaterial({ color: 0x241a10, roughness: 0.9 }), ); cavity.position.set(...m2t(cavCentre, 0, 0)); g.add(cavity); // slit opening marker: dark patch + small sphere at the monopole point const slitPos = new THREE.Vector3(...m2t(params.slit_x_m, params.slit_y_m, params.slit_z_m)); const slitArea = params.slit_area_m2; const slitLen = Math.min(L * 0.6, Math.sqrt(slitArea * 10)); // display proportions only const slitWid = slitArea / slitLen; const slit = new THREE.Mesh( new THREE.BoxGeometry(slitLen, slitWid, 0.002), new THREE.MeshStandardMaterial({ color: 0x0d0a06, roughness: 1.0 }), ); slit.position.copy(slitPos); // orient the patch to lie in the wall nearest the slit point if (Math.abs(params.slit_z_m) >= Math.abs(params.slit_y_m)) slit.rotation.x = Math.PI / 2; g.add(slit); const slitDot = new THREE.Mesh( new THREE.SphereGeometry(0.005, 16, 12), new THREE.MeshStandardMaterial({ color: 0xba68c8, emissive: 0x4a1f55 }), ); slitDot.position.copy(slitPos); g.add(slitDot); const slitLabel = makeLabel('slit', '#ba68c8'); slitLabel.position.copy(slitPos).add(new THREE.Vector3(0, 0.03, 0)); g.add(slitLabel); // strike point + mallet const xc = (params.strike_position - 0.5) * L; const strikeM = m2t(xc, 0, Hb / 2); const strikeDot = new THREE.Mesh( new THREE.SphereGeometry(0.006, 16, 12), new THREE.MeshStandardMaterial({ color: 0xef5350, emissive: 0x5a1210 }), ); strikeDot.position.set(...strikeM); g.add(strikeDot); const malletBall = new THREE.Mesh( new THREE.SphereGeometry(0.014, 20, 16), new THREE.MeshStandardMaterial({ color: 0xd7ccc8, roughness: 0.5 }), ); malletBall.position.set(strikeM[0], strikeM[1] + 0.05, strikeM[2]); g.add(malletBall); const shaft = new THREE.Mesh( new THREE.CylinderGeometry(0.004, 0.004, 0.16, 12), new THREE.MeshStandardMaterial({ color: 0x8d6e63, roughness: 0.7 }), ); shaft.position.set(strikeM[0] + 0.055, strikeM[1] + 0.115, strikeM[2]); shaft.rotation.z = -0.75; g.add(shaft); const strikeLabel = makeLabel('strike', '#ef5350'); strikeLabel.position.set(strikeM[0] - 0.01, strikeM[1] + 0.09, strikeM[2]); g.add(strikeLabel); // radiating surface elements on both faces const M = params.radiating_elements; const pts: number[] = []; for (let j = 0; j < M; j++) { const x = ((j + 0.5) / M - 0.5) * L; pts.push(...m2t(x, 0, Hb / 2 + 0.001)); pts.push(...m2t(x, 0, -Hb / 2 - 0.001)); } const ptsGeo = new THREE.BufferGeometry(); ptsGeo.setAttribute('position', new THREE.Float32BufferAttribute(pts, 3)); g.add(new THREE.Points(ptsGeo, new THREE.PointsMaterial({ color: 0xffe082, size: 0.004 }))); // listener: microphone marker + line from block 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.length_m, params.width_m, params.block_height_m, params.cavity_span_lo, params.cavity_span_hi, params.slit_x_m, params.slit_y_m, params.slit_z_m, params.slit_area_m2, params.strike_position, params.radiating_elements, params.listener_x_m, params.listener_y_m, params.listener_z_m, ]); return (
drag to orbit · scroll to zoom · right-drag to pan
); }