// 3D view of the geometry: the cymbal on its stand, the stick at the contact // point (and the second contact radius of a repeated figure), and the // listener. Model coordinates put the symmetry axis along z with the rim at // z = 0 and the bell rising toward +z, and the stroke on the theta = 0 // meridian, which is +x. three.js is y-up, so the scene rotates the model by // -90 degrees about x and the model's z becomes the scene's y. import { useEffect, useRef } from 'react'; import * as THREE from 'three'; import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; import type { CymbalParams } from '../params'; import { cymbalMeridian } from '../lib/profile'; 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; } // model (r along +x at theta = 0, z along the axis) -> scene (y up) function toScene(x: number, y: number, z: number): THREE.Vector3 { return new THREE.Vector3(x, z, -y); } interface Props { params: CymbalParams; } 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); 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, 30); camera.position.set(0.42, 0.36, 0.55); const controls = new OrbitControls(camera, renderer.domElement); controls.target.set(0, 0.03, 0); controls.enableDamping = true; scene.add(new THREE.AmbientLight(0xffffff, 0.5)); const dir = new THREE.DirectionalLight(0xffffff, 1.2); dir.position.set(0.6, 1.0, 0.5); scene.add(dir); const dir2 = new THREE.DirectionalLight(0x8899ff, 0.35); dir2.position.set(-0.6, 0.4, -0.6); scene.add(dir2); const grid = new THREE.GridHelper(1.6, 32, 0x3a4048, 0x262b33); grid.position.y = -0.36; scene.add(grid); // model axes: x red (the stroke meridian), y green (the other in-plane // direction), z blue (the symmetry axis) const axisLen = 0.09; const axes: [THREE.Vector3, number, string][] = [ [toScene(axisLen, 0, 0), 0xe57373, '+x'], [toScene(0, axisLen, 0), 0x81c784, '+y'], [toScene(0, 0, axisLen), 0x64b5f6, '+z axis'], ]; 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.2); 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; }; }, []); 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 mer = cymbalMeridian( params.radius_m, params.hole_radius_m, params.bell_radius_m, params.bell_height_m, params.bow_height_m, params.thickness_bell_m, params.thickness_rim_m, ); // meridian sample points, used both for the lathe and for placing the stick const NS = 64; const rs: number[] = []; for (let i = 0; i <= NS; i++) rs.push(mer.rHole + (i / NS) * (mer.rRim - mer.rHole)); // arclength, so that strike_r addresses the same point the model does const arc: number[] = [0]; for (let i = 1; i <= NS; i++) { arc.push(arc[i - 1] + Math.hypot(rs[i] - rs[i - 1], mer.z(rs[i]) - mer.z(rs[i - 1]))); } const total = arc[NS]; const atFrac = (frac: number) => { const s = Math.min(0.9999, Math.max(0, frac)) * total; let i = 0; while (i < NS - 1 && arc[i + 1] < s) i++; const t = (s - arc[i]) / Math.max(1e-12, arc[i + 1] - arc[i]); const r = rs[i] + t * (rs[i + 1] - rs[i]); const z = mer.z(rs[i]) + t * (mer.z(rs[i + 1]) - mer.z(rs[i])); const dr = rs[i + 1] - rs[i]; const dz = mer.z(rs[i + 1]) - mer.z(rs[i]); const dl = Math.hypot(dr, dz) || 1; return { r, z, tr: dr / dl, tz: dz / dl }; }; // the plate: a lathe of the meridian offset to both faces const pts: THREE.Vector2[] = []; for (let i = 0; i <= NS; i++) { const h = mer.thickness(rs[i]) / 2; pts.push(new THREE.Vector2(rs[i], mer.z(rs[i]) + h)); } for (let i = NS; i >= 0; i--) { const h = mer.thickness(rs[i]) / 2; pts.push(new THREE.Vector2(rs[i], mer.z(rs[i]) - h)); } pts.push(pts[0].clone()); const lathe = new THREE.Mesh( new THREE.LatheGeometry(pts, 96), new THREE.MeshStandardMaterial({ color: 0xc9a227, roughness: 0.32, metalness: 0.9, side: THREE.DoubleSide, }), ); lathe.rotation.x = -Math.PI / 2; g.add(lathe); // lathing grooves, as faint rings for (let k = 1; k < 9; k++) { const r = mer.rBell + (k / 9) * (mer.rRim - mer.rBell); const ringPts: THREE.Vector3[] = []; for (let a = 0; a <= 72; a++) { const th = (a / 72) * 2 * Math.PI; ringPts.push(toScene(r * Math.cos(th), r * Math.sin(th), mer.z(r) + 0.0008)); } const ring = new THREE.Line( new THREE.BufferGeometry().setFromPoints(ringPts), new THREE.LineBasicMaterial({ color: 0x8a6d18, transparent: true, opacity: 0.5 }), ); g.add(ring); } // stand: rod through the hole, felt washer, tripod post const rod = new THREE.Mesh( new THREE.CylinderGeometry(0.0035, 0.0035, 0.09, 12), new THREE.MeshStandardMaterial({ color: 0x9aa0aa, roughness: 0.4, metalness: 0.8 }), ); rod.position.set(0, mer.apexHeight + 0.01, 0); g.add(rod); const felt = new THREE.Mesh( new THREE.CylinderGeometry(0.012, 0.012, 0.006, 16), new THREE.MeshStandardMaterial({ color: 0x2f3a44, roughness: 0.95 }), ); felt.position.set(0, mer.apexHeight - 0.004, 0); g.add(felt); const post = new THREE.Mesh( new THREE.CylinderGeometry(0.008, 0.008, 0.36, 12), new THREE.MeshStandardMaterial({ color: 0x6f757f, roughness: 0.5, metalness: 0.7 }), ); post.position.set(0, mer.apexHeight - 0.19, 0); g.add(post); // stick at the contact point, along the stroke direction const addStick = (frac: number, primary: boolean) => { const pt = atFrac(frac); const ang = (params.strike_angle_deg * Math.PI) / 180; // surface normal (-dz, dr) in the meridian plane, at theta = 0 const nrm = toScene(-pt.tz, 0, pt.tr); const tan = toScene(pt.tr, 0, pt.tz); // stroke direction: -cos(angle) * normal + sin(angle) * tangent const dirv = nrm.clone().multiplyScalar(-Math.cos(ang)).addScaledVector(tan, Math.sin(ang)); const contact = toScene(pt.r, 0, pt.z); const tip = contact.clone().addScaledVector(dirv, -(params.tip_radius_m + 0.0008)); const color = primary ? 0xe8a33d : 0x9a7a3d; const head = new THREE.Mesh( new THREE.SphereGeometry(params.tip_radius_m, 16, 12), new THREE.MeshStandardMaterial({ color, roughness: 0.35 }), ); head.position.copy(tip); g.add(head); const shaftLen = 0.24; const shaft = new THREE.Mesh( new THREE.CylinderGeometry(0.0055, 0.0072, shaftLen, 12), new THREE.MeshStandardMaterial({ color: 0xb08c5a, roughness: 0.75 }), ); shaft.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), dirv.clone().negate()); shaft.position.copy(tip).addScaledVector(dirv, -(params.tip_radius_m + shaftLen / 2)); g.add(shaft); if (primary) { const lbl = makeLabel('stroke', '#e8a33d'); lbl.position.copy(tip).add(new THREE.Vector3(0, 0.05, 0)); g.add(lbl); } }; addStick(params.strike_r, true); if (params.strike_count > 1) addStick(params.roll_r2, false); // listener const lis = toScene(params.listener_x_m, params.listener_y_m, params.listener_z_m); const micHead = new THREE.Mesh( new THREE.SphereGeometry(0.022, 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.011, 0.011, 0.07, 12), new THREE.MeshStandardMaterial({ color: 0x455a64, roughness: 0.6 }), ); micBody.position.copy(lis).add(new THREE.Vector3(0, -0.046, 0)); g.add(micBody); const centroid = toScene(0, 0, mer.z(mer.rRim * 0.6)); const lineGeo = new THREE.BufferGeometry().setFromPoints([centroid, lis]); const line = new THREE.Line( lineGeo, new THREE.LineDashedMaterial({ color: 0x4dd0e1, dashSize: 0.02, gapSize: 0.014 }), ); line.computeLineDistances(); g.add(line); const micLabel = makeLabel(`mic ${(centroid.distanceTo(lis) * 100).toFixed(0)} cm`, '#4dd0e1'); micLabel.position.copy(lis).add(new THREE.Vector3(0, 0.06, 0)); g.add(micLabel); ctx.scene.add(g); ctx.group = g; }, [ params.radius_m, params.hole_radius_m, params.bell_radius_m, params.bell_height_m, params.bow_height_m, params.thickness_bell_m, params.thickness_rim_m, params.strike_r, params.strike_angle_deg, params.strike_count, params.roll_r2, params.tip_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
); }