// 3D view of the geometry: the open triangle hanging in a vertical plane, // the beater at the strike point (and the second roll contact), and the // listener. Model coordinates put the triangle in the x-y plane with the // apex up and z normal to the plane; three.js is y-up too, 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 { TriangleParams } from '../params'; import { triangleOutline } from '../lib/outline'; 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: TriangleParams; } 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.16, 0.16, 0.34); const controls = new OrbitControls(camera, renderer.domElement); controls.target.set(0, 0.07, 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.8, 32, 0x3a4048, 0x262b33); grid.position.y = -0.06; scene.add(grid); // model axes: x red (along the base), y green (up), z blue (plane normal) 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 outline = triangleOutline(params.side_length_m, params.gap_m); const a = params.rod_diameter_m / 2; const steel = new THREE.MeshStandardMaterial({ color: 0xb8bec8, roughness: 0.35, metalness: 0.85, }); // the rod: one cylinder per leg, spheres closing the two corners and // capping the free ends at the gap for (let k = 0; k < 3; k++) { const p0 = outline.pts[k]; const p1 = outline.pts[k + 1]; const len = Math.hypot(p1.x - p0.x, p1.y - p0.y); const cyl = new THREE.Mesh(new THREE.CylinderGeometry(a, a, len, 20), steel); cyl.position.set((p0.x + p1.x) / 2, (p0.y + p1.y) / 2, 0); const dirv = new THREE.Vector3(p1.x - p0.x, p1.y - p0.y, 0).normalize(); cyl.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), dirv); g.add(cyl); } for (const p of outline.pts) { const cap = new THREE.Mesh(new THREE.SphereGeometry(a, 14, 10), steel); cap.position.set(p.x, p.y, 0); g.add(cap); } // suspension loop at the apex const apexY = (params.side_length_m * Math.sqrt(3)) / 2; const loop = new THREE.Mesh( new THREE.TorusGeometry(0.011, 0.0016, 8, 24), new THREE.MeshStandardMaterial({ color: 0x8d6e63, roughness: 0.8 }), ); loop.position.set(0, apexY + 0.013, 0); g.add(loop); const cordGeo = new THREE.BufferGeometry().setFromPoints([ new THREE.Vector3(0, apexY + 0.022, 0), new THREE.Vector3(0, apexY + 0.08, 0), ]); g.add(new THREE.Line(cordGeo, new THREE.LineBasicMaterial({ color: 0x6b5a50 }))); // beater at the strike point, pointing along the strike direction const addBeater = (frac: number, primary: boolean) => { const pt = outline.at(frac); const ang = (params.strike_angle_deg * Math.PI) / 180; // inward in-plane normal of a counterclockwise outline, tilted out of plane const dirv = new THREE.Vector3( Math.cos(ang) * -pt.ty, Math.cos(ang) * pt.tx, Math.sin(ang), ); const tip = new THREE.Vector3(pt.x, pt.y, 0).addScaledVector( dirv, -(a + params.beater_radius_m + 0.001), ); const color = primary ? 0xe8a33d : 0x9a7a3d; const head = new THREE.Mesh( new THREE.SphereGeometry(params.beater_radius_m, 16, 12), new THREE.MeshStandardMaterial({ color, roughness: 0.4, metalness: 0.6 }), ); head.position.copy(tip); g.add(head); const shaftLen = 0.07; const shaft = new THREE.Mesh( new THREE.CylinderGeometry(0.0022, 0.0022, shaftLen, 10), new THREE.MeshStandardMaterial({ color: 0x777d88, roughness: 0.5, metalness: 0.7 }), ); shaft.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), dirv.clone().negate()); shaft.position.copy(tip).addScaledVector(dirv, -(params.beater_radius_m + shaftLen / 2)); g.add(shaft); if (primary) { const lbl = makeLabel('strike', '#e8a33d'); lbl.position.copy(tip).add(new THREE.Vector3(0, -0.02, 0.01)); g.add(lbl); } }; addBeater(params.strike_pos, true); if (params.strike_count > 1) addBeater(params.roll_pos2, false); // listener: microphone marker + line from the frame centroid 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 centroid = new THREE.Vector3(0, apexY / 2.2, 0); const lineGeo = new THREE.BufferGeometry().setFromPoints([centroid, 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 = centroid.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); ctx.scene.add(g); ctx.group = g; }, [ params.side_length_m, params.gap_m, params.rod_diameter_m, params.strike_pos, params.strike_angle_deg, params.strike_count, params.roll_pos2, params.beater_radius_m, params.listener_x_m, params.listener_y_m, params.listener_z_m, ]); return (
drag to orbit · scroll to zoom · right-drag to pan
); }