// 3D view of the geometry: a stylized double bass standing on its endpin, // the plucking finger at the pluck point of the first note's string, the // stopping finger when that note is fingered, and the listener. Model // coordinates put the origin at the bridge, +y up the neck, and +z out of // the top plate; three.js is y-up too, so the mapping is the identity. The // body outline is a visual stand-in: the acoustics use only the modal table // of bass_body.m, the two f-hole points, and the plate-normal dipole. import { useEffect, useRef } from 'react'; import * as THREE from 'three'; import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; import type { BassParams } from '../params'; 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.24 * scale, 0.048 * scale, 1); return sprite; } // The fingering of the first note, mirroring bass_notes.m: the highest-tuned // string whose open pitch does not exceed the note. function fingeringOf(rootMidi: number): { str: number; stop: number } { const open = [43, 38, 33, 28]; const m = Math.min(55, Math.max(28, Math.round(rootMidi))); for (let s = 0; s < 4; s++) { if (open[s] <= m) return { str: s, stop: m - open[s] }; } return { str: 3, stop: 0 }; } // Right half of the body outline (gamba-ish sloped shoulders), mirrored. function bodyShape(): THREE.Shape { const sh = new THREE.Shape(); sh.moveTo(0, -0.61); sh.bezierCurveTo(0.24, -0.61, 0.345, -0.48, 0.33, -0.30); sh.bezierCurveTo(0.315, -0.16, 0.245, -0.105, 0.205, -0.06); sh.bezierCurveTo(0.158, -0.005, 0.158, 0.10, 0.20, 0.155); sh.bezierCurveTo(0.238, 0.205, 0.258, 0.245, 0.245, 0.30); sh.bezierCurveTo(0.222, 0.40, 0.125, 0.475, 0, 0.50); sh.bezierCurveTo(-0.125, 0.475, -0.222, 0.40, -0.245, 0.30); sh.bezierCurveTo(-0.258, 0.245, -0.238, 0.205, -0.20, 0.155); sh.bezierCurveTo(-0.158, 0.10, -0.158, -0.005, -0.205, -0.06); sh.bezierCurveTo(-0.245, -0.105, -0.315, -0.16, -0.33, -0.30); sh.bezierCurveTo(-0.345, -0.48, -0.24, -0.61, 0, -0.61); return sh; } interface Props { params: BassParams; } 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, 40); camera.position.set(1.35, 1.05, 2.3); const controls = new OrbitControls(camera, renderer.domElement); controls.target.set(0, 0.35, 0); controls.enableDamping = true; scene.add(new THREE.AmbientLight(0xffffff, 0.5)); const dir = new THREE.DirectionalLight(0xffffff, 1.1); dir.position.set(1.2, 2.2, 1.6); scene.add(dir); const dir2 = new THREE.DirectionalLight(0x8899ff, 0.3); dir2.position.set(-1.4, 0.8, -1.2); scene.add(dir2); const grid = new THREE.GridHelper(3.2, 32, 0x3a4048, 0x262b33); grid.position.y = -0.78; scene.add(grid); // model axes: x red (across), y green (up the neck), z blue (plate normal) const axisLen = 0.22; 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'), 0.6); 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 yNut = params.scale_length_m; const spruce = new THREE.MeshStandardMaterial({ color: 0xc79b56, roughness: 0.55, metalness: 0.05 }); const maple = new THREE.MeshStandardMaterial({ color: 0x8a5a2b, roughness: 0.6, metalness: 0.05 }); const ebony = new THREE.MeshStandardMaterial({ color: 0x17130f, roughness: 0.45, metalness: 0.1 }); // body: extruded outline, top plate at z ~ +0.11 const body = new THREE.Mesh( new THREE.ExtrudeGeometry(bodyShape(), { depth: 0.2, bevelEnabled: true, bevelThickness: 0.006, bevelSize: 0.006, bevelSegments: 2, curveSegments: 24, }), spruce, ); body.position.z = -0.106; g.add(body); // f-holes: dark slots flanking the bridge (the monopole points sit here) for (const sx of [-1, 1]) { const fh = new THREE.Mesh(new THREE.BoxGeometry(0.02, 0.17, 0.004), ebony); fh.position.set(sx * 0.095, -0.02, 0.111); fh.rotation.z = sx * 0.18; g.add(fh); } // neck, fingerboard, pegbox, scroll hint const neck = new THREE.Mesh(new THREE.CylinderGeometry(0.024, 0.03, yNut - 0.42, 12), maple); neck.position.set(0, 0.44 + (yNut - 0.42) / 2, 0.075); g.add(neck); const fbLen = yNut - 0.28; const fb = new THREE.Mesh(new THREE.BoxGeometry(0.075, fbLen, 0.016), ebony); fb.position.set(0, 0.28 + fbLen / 2, 0.118); g.add(fb); const pegbox = new THREE.Mesh(new THREE.BoxGeometry(0.055, 0.17, 0.05), maple); pegbox.position.set(0, yNut + 0.085, 0.095); g.add(pegbox); const scroll = new THREE.Mesh(new THREE.TorusGeometry(0.032, 0.014, 8, 20), maple); scroll.position.set(0, yNut + 0.185, 0.095); g.add(scroll); // bridge, tailpiece, endpin const bridge = new THREE.Mesh(new THREE.BoxGeometry(0.15, 0.012, 0.075), maple); bridge.position.set(0, 0, 0.148); g.add(bridge); const tail = new THREE.Mesh(new THREE.BoxGeometry(0.085, 0.22, 0.018), ebony); tail.position.set(0, -0.46, 0.128); g.add(tail); const endpin = new THREE.Mesh(new THREE.CylinderGeometry(0.006, 0.006, 0.17, 8), new THREE.MeshStandardMaterial({ color: 0x777d88, roughness: 0.4, metalness: 0.8 })); endpin.position.set(0, -0.695, 0); g.add(endpin); // strings: G D A E left to right at the bridge, converging at the nut const bridgeX = [-0.045, -0.015, 0.015, 0.045]; const nutX = [-0.0165, -0.0055, 0.0055, 0.0165]; const rad = [0.0011, 0.0013, 0.0016, 0.002]; const zBridge = 0.187; const zNut = 0.132; const zTail = 0.135; const steel = new THREE.MeshStandardMaterial({ color: 0xb8bec8, roughness: 0.35, metalness: 0.85 }); const addSeg = (a: THREE.Vector3, b: THREE.Vector3, r: number) => { const len = a.distanceTo(b); const cyl = new THREE.Mesh(new THREE.CylinderGeometry(r, r, len, 8), steel); cyl.position.copy(a).add(b).multiplyScalar(0.5); cyl.quaternion.setFromUnitVectors( new THREE.Vector3(0, 1, 0), b.clone().sub(a).normalize(), ); g.add(cyl); }; for (let s = 0; s < 4; s++) { const pB = new THREE.Vector3(bridgeX[s], 0, zBridge); const pN = new THREE.Vector3(nutX[s], yNut, zNut); const pT = new THREE.Vector3(bridgeX[s] * 0.6, -0.42, zTail); addSeg(pB, pN, rad[s]); addSeg(pT, pB, rad[s]); } // the first note: plucking finger at the pluck point, stopping finger at // the stop (both on the string bass_notes.m would choose for the root) const { str, stop } = fingeringOf(params.root_midi); const Lv = yNut * 2 ** (-stop / 12); const along = (t: number) => new THREE.Vector3( bridgeX[str] + (nutX[str] - bridgeX[str]) * t, yNut * t, zBridge + (zNut - zBridge) * t, ); const tPluck = (params.pluck_pos * Lv) / yNut; const pluck = new THREE.Mesh( new THREE.SphereGeometry(0.016, 16, 12), new THREE.MeshStandardMaterial({ color: 0xe8a33d, roughness: 0.4 }), ); pluck.position.copy(along(tPluck)).add(new THREE.Vector3(0, 0, 0.012)); g.add(pluck); const pluckLbl = makeLabel('pluck', '#e8a33d', 0.7); pluckLbl.position.copy(pluck.position).add(new THREE.Vector3(0.05, 0.045, 0.03)); g.add(pluckLbl); if (stop > 0) { const fingerPos = along(Lv / yNut); const finger = new THREE.Mesh( new THREE.SphereGeometry(0.013, 16, 12), new THREE.MeshStandardMaterial({ color: 0xd98a7a, roughness: 0.6 }), ); finger.position.copy(fingerPos).add(new THREE.Vector3(0, 0, 0.01)); g.add(finger); const stopLbl = makeLabel('stop', '#d98a7a', 0.6); stopLbl.position.copy(fingerPos).add(new THREE.Vector3(-0.05, 0.045, 0.03)); g.add(stopLbl); } // listener: microphone marker + line from the bridge 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.035, 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.016, 0.016, 0.1, 12), new THREE.MeshStandardMaterial({ color: 0x455a64, roughness: 0.6 }), ); micBody.position.copy(lis).add(new THREE.Vector3(0, -0.068, 0)); g.add(micBody); const lineGeo = new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(0, 0, 0.12), lis]); const line = new THREE.Line( lineGeo, new THREE.LineDashedMaterial({ color: 0x4dd0e1, dashSize: 0.03, gapSize: 0.022 }), ); line.computeLineDistances(); g.add(line); const dist = lis.length(); const micLabel = makeLabel(`mic ${(dist * 100).toFixed(0)} cm`, '#4dd0e1', 0.8); micLabel.position.copy(lis).add(new THREE.Vector3(0, 0.1, 0)); g.add(micLabel); ctx.scene.add(g); ctx.group = g; }, [ params.scale_length_m, params.root_midi, params.pluck_pos, params.listener_x_m, params.listener_y_m, params.listener_z_m, ]); return (
drag to orbit · scroll to zoom · right-drag to pan
); }