// 3D view of the geometry: the recorder hanging vertically (window at the // origin, foot down along -y, tone holes opening toward +z, thumb hole // toward -z), the fingering of the first note shown as finger pads on the // closed holes, and the listener. three.js is y-up, so the mapping is the // identity. import { useEffect, useRef } from 'react'; import * as THREE from 'three'; import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; import type { RecorderParams } from '../params'; import { FINGERINGS, HOLE_FRAC, HOLE_RAD } from '../lib/instrument'; 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.11 * scale, 0.022 * scale, 1); return sprite; } interface Props { params: RecorderParams; } 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.005, 20); camera.position.set(0.30, 0.06, 0.42); const controls = new OrbitControls(camera, renderer.domElement); controls.target.set(0, -0.14, 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(1.0, 32, 0x3a4048, 0x262b33); grid.position.y = -0.42; scene.add(grid); // model axes: x red, y green (along the bore, up), z blue (front) const axisLen = 0.06; const axes: [THREE.Vector3, number, string][] = [ [new THREE.Vector3(axisLen, 0, 0), 0xe57373, '+x'], [new THREE.Vector3(0, axisLen, 0), 0x81c784, '+y'], [new THREE.Vector3(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.bore_length_m; const rHead = params.bore_r_head_m + params.wall_m; const rFoot = params.bore_r_foot_m + params.wall_m; const wood = new THREE.MeshStandardMaterial({ color: 0xa8763e, roughness: 0.55, metalness: 0.05, }); const darkWood = new THREE.MeshStandardMaterial({ color: 0x6e4a26, roughness: 0.6, metalness: 0.05, }); const holeMat = new THREE.MeshStandardMaterial({ color: 0x17130e, roughness: 0.9 }); // body: window (y = 0) down to the foot const body = new THREE.Mesh(new THREE.CylinderGeometry(rHead, rFoot, L, 28), wood); body.position.set(0, -L / 2, 0); g.add(body); // head above the window: beak with the windway const headLen = 0.045; const head = new THREE.Mesh(new THREE.CylinderGeometry(rHead * 1.06, rHead, headLen, 28), darkWood); head.position.set(0, headLen / 2 + params.cutup_m, 0); g.add(head); const beak = new THREE.Mesh( new THREE.SphereGeometry(rHead * 1.06, 20, 12, 0, Math.PI * 2, 0, Math.PI / 2), darkWood, ); beak.position.set(0, headLen + params.cutup_m, 0); g.add(beak); // window: dark opening on the +z side between flue exit and labium const win = new THREE.Mesh( new THREE.BoxGeometry(params.window_width_m, params.cutup_m, 0.0015), holeMat, ); win.position.set(0, params.cutup_m / 2, rHead - 0.0002); g.add(win); // tone holes; a fingering pad covers each closed hole const fing = FINGERINGS[Math.min(14, Math.max(1, Math.round(params.note1))) - 1]; const pad = new THREE.MeshStandardMaterial({ color: 0xd8a37a, roughness: 0.8 }); for (let j = 0; j < 8; j++) { const y = -HOLE_FRAC[j] * L; const r = HOLE_RAD[j] * params.hole_scale; const frac = HOLE_FRAC[j]; const rAt = rHead + (rFoot - rHead) * frac; const zdir = j === 0 ? -1 : 1; // thumb hole on the back const hole = new THREE.Mesh(new THREE.CylinderGeometry(r, r, 0.004, 16), holeMat); hole.rotation.x = Math.PI / 2; hole.position.set(0, y, zdir * rAt); g.add(hole); const state = fing[j]; if (state < 0.75) { // closed or pinched: a finger pad (smaller for the pinch) const pr = state < 0.25 ? r * 1.9 : r * 1.1; const cap = new THREE.Mesh(new THREE.SphereGeometry(pr, 14, 10), pad); cap.scale.z = 0.45; cap.position.set(0, y, zdir * (rAt + 0.001)); g.add(cap); } } // foot opening const foot = new THREE.Mesh( new THREE.CylinderGeometry(params.bore_r_foot_m, params.bore_r_foot_m, 0.002, 20), holeMat, ); foot.position.set(0, -L - 0.0005, 0); g.add(foot); // listener: microphone marker + line from the window const lis = new THREE.Vector3(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 windowPos = new THREE.Vector3(0, 0, 0); const lineGeo = new THREE.BufferGeometry().setFromPoints([windowPos, 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 = windowPos.distanceTo(lis); 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); const winLabel = makeLabel('window', '#e8a33d', 0.8); winLabel.position.set(0.05, 0.012, rHead + 0.01); g.add(winLabel); ctx.scene.add(g); ctx.group = g; }, [ params.bore_length_m, params.bore_r_head_m, params.bore_r_foot_m, params.wall_m, params.hole_scale, params.cutup_m, params.window_width_m, params.note1, params.listener_x_m, params.listener_y_m, params.listener_z_m, ]); return (
drag to orbit · scroll to zoom · right-drag to pan
); }