4ddcc1bCymbal simulator: shell-of-revolution modal model with a nonlinear energy cascade, numbl MATLAB reference, and interactive web appJeremy Magland 1// 3D view of the geometry: the cymbal on its stand, the stick at the contact
2// point (and the second contact radius of a repeated figure), and the
3// listener. Model coordinates put the symmetry axis along z with the rim at
4// z = 0 and the bell rising toward +z, and the stroke on the theta = 0
5// meridian, which is +x. three.js is y-up, so the scene rotates the model by
6// -90 degrees about x and the model's z becomes the scene's y.
8import { useEffect, useRef } from 'react';
9import * as THREE from 'three';
10import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
11import type { CymbalParams } from '../params';
a136eacMake the contact point a first-class controlJeremy Magland 12import { cymbalMeridian, meridianSamples, pointAtFrac } from '../lib/profile';
4ddcc1bCymbal simulator: shell-of-revolution modal model with a nonlinear energy cascade, numbl MATLAB reference, and interactive web appJeremy Magland 13
14function makeLabel(text: string, color: string, scale = 1): THREE.Sprite {
15 const canvas = document.createElement('canvas');
16 canvas.width = 320;
17 canvas.height = 64;
18 const ctx = canvas.getContext('2d')!;
19 ctx.font = '600 36px system-ui, sans-serif';
20 ctx.fillStyle = color;
21 ctx.textAlign = 'center';
22 ctx.textBaseline = 'middle';
23 ctx.fillText(text, 160, 32);
24 const tex = new THREE.CanvasTexture(canvas);
25 const mat = new THREE.SpriteMaterial({ map: tex, depthTest: false, transparent: true });
26 const sprite = new THREE.Sprite(mat);
27 sprite.scale.set(0.11 * scale, 0.022 * scale, 1);
28 return sprite;
29}
31// model (r along +x at theta = 0, z along the axis) -> scene (y up)
32function toScene(x: number, y: number, z: number): THREE.Vector3 {
33 return new THREE.Vector3(x, z, -y);
34}
36interface Props {
37 params: CymbalParams;
38}
40export default function Scene3D({ params }: Props) {
41 const mountRef = useRef<HTMLDivElement | null>(null);
42 const sceneRef = useRef<{
43 scene: THREE.Scene;
44 group: THREE.Group | null;
45 renderer: THREE.WebGLRenderer;
46 camera: THREE.PerspectiveCamera;
47 } | null>(null);
49 useEffect(() => {
50 const mount = mountRef.current!;
51 const renderer = new THREE.WebGLRenderer({ antialias: true });
52 renderer.setPixelRatio(window.devicePixelRatio);
53 renderer.setClearColor(0x14161a);
54 mount.appendChild(renderer.domElement);
56 const scene = new THREE.Scene();
57 const camera = new THREE.PerspectiveCamera(45, 1, 0.01, 30);
58 camera.position.set(0.42, 0.36, 0.55);
60 const controls = new OrbitControls(camera, renderer.domElement);
61 controls.target.set(0, 0.03, 0);
62 controls.enableDamping = true;
64 scene.add(new THREE.AmbientLight(0xffffff, 0.5));
65 const dir = new THREE.DirectionalLight(0xffffff, 1.2);
66 dir.position.set(0.6, 1.0, 0.5);
67 scene.add(dir);
68 const dir2 = new THREE.DirectionalLight(0x8899ff, 0.35);
69 dir2.position.set(-0.6, 0.4, -0.6);
70 scene.add(dir2);
72 const grid = new THREE.GridHelper(1.6, 32, 0x3a4048, 0x262b33);
73 grid.position.y = -0.36;
74 scene.add(grid);
76 // model axes: x red (the stroke meridian), y green (the other in-plane
77 // direction), z blue (the symmetry axis)
78 const axisLen = 0.09;
79 const axes: [THREE.Vector3, number, string][] = [
80 [toScene(axisLen, 0, 0), 0xe57373, '+x'],
81 [toScene(0, axisLen, 0), 0x81c784, '+y'],
82 [toScene(0, 0, axisLen), 0x64b5f6, '+z axis'],
83 ];
84 for (const [v, color, label] of axes) {
85 const geo = new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(0, 0, 0), v]);
86 scene.add(new THREE.Line(geo, new THREE.LineBasicMaterial({ color })));
87 const s = makeLabel(label, '#' + color.toString(16).padStart(6, '0'));
88 s.position.copy(v).multiplyScalar(1.2);
89 scene.add(s);
90 }
92 sceneRef.current = { scene, group: null, renderer, camera };
94 let raf = 0;
95 const animate = () => {
96 raf = requestAnimationFrame(animate);
97 controls.update();
98 renderer.render(scene, camera);
99 };
100 animate();
102 const resize = () => {
103 const w = mount.clientWidth;
104 const h = mount.clientHeight;
105 renderer.setSize(w, h, false);
106 camera.aspect = w / h;
107 camera.updateProjectionMatrix();
108 };
109 resize();
110 const ro = new ResizeObserver(resize);
111 ro.observe(mount);
113 return () => {
114 cancelAnimationFrame(raf);
115 ro.disconnect();
116 controls.dispose();
117 renderer.dispose();
118 mount.removeChild(renderer.domElement);
119 sceneRef.current = null;
120 };
121 }, []);
123 useEffect(() => {
124 const ctx = sceneRef.current;
125 if (!ctx) return;
126 if (ctx.group) {
127 ctx.scene.remove(ctx.group);
128 ctx.group.traverse((o) => {
129 const mesh = o as THREE.Mesh;
130 if (mesh.geometry) mesh.geometry.dispose();
131 });
132 }
134 const g = new THREE.Group();
135 const mer = cymbalMeridian(
136 params.radius_m,
137 params.hole_radius_m,
138 params.bell_radius_m,
139 params.bell_height_m,
140 params.bow_height_m,
141 params.thickness_bell_m,
142 params.thickness_rim_m,
143 );
a136eacMake the contact point a first-class controlJeremy Magland 145 // meridian samples, shared with the strike picker so that both place the
146 // stick exactly where cymbal_point.m does
4ddcc1bCymbal simulator: shell-of-revolution modal model with a nonlinear energy cascade, numbl MATLAB reference, and interactive web appJeremy Magland 147 const NS = 64;
148 const rs: number[] = [];
149 for (let i = 0; i <= NS; i++) rs.push(mer.rHole + (i / NS) * (mer.rRim - mer.rHole));
a136eacMake the contact point a first-class controlJeremy Magland 150 const samp = meridianSamples(mer);
151 const atFrac = (frac: number) => pointAtFrac(samp, frac);
4ddcc1bCymbal simulator: shell-of-revolution modal model with a nonlinear energy cascade, numbl MATLAB reference, and interactive web appJeremy Magland 152
153 // the plate: a lathe of the meridian offset to both faces
154 const pts: THREE.Vector2[] = [];
155 for (let i = 0; i <= NS; i++) {
156 const h = mer.thickness(rs[i]) / 2;
157 pts.push(new THREE.Vector2(rs[i], mer.z(rs[i]) + h));
158 }
159 for (let i = NS; i >= 0; i--) {
160 const h = mer.thickness(rs[i]) / 2;
161 pts.push(new THREE.Vector2(rs[i], mer.z(rs[i]) - h));
162 }
163 pts.push(pts[0].clone());
164 const lathe = new THREE.Mesh(
165 new THREE.LatheGeometry(pts, 96),
166 new THREE.MeshStandardMaterial({
167 color: 0xc9a227,
168 roughness: 0.32,
169 metalness: 0.9,
170 side: THREE.DoubleSide,
171 }),
172 );
173 lathe.rotation.x = -Math.PI / 2;
174 g.add(lathe);
176 // lathing grooves, as faint rings
177 for (let k = 1; k < 9; k++) {
178 const r = mer.rBell + (k / 9) * (mer.rRim - mer.rBell);
179 const ringPts: THREE.Vector3[] = [];
180 for (let a = 0; a <= 72; a++) {
181 const th = (a / 72) * 2 * Math.PI;
182 ringPts.push(toScene(r * Math.cos(th), r * Math.sin(th), mer.z(r) + 0.0008));
183 }
184 const ring = new THREE.Line(
185 new THREE.BufferGeometry().setFromPoints(ringPts),
186 new THREE.LineBasicMaterial({ color: 0x8a6d18, transparent: true, opacity: 0.5 }),
187 );
188 g.add(ring);
189 }
191 // stand: rod through the hole, felt washer, tripod post
192 const rod = new THREE.Mesh(
193 new THREE.CylinderGeometry(0.0035, 0.0035, 0.09, 12),
194 new THREE.MeshStandardMaterial({ color: 0x9aa0aa, roughness: 0.4, metalness: 0.8 }),
195 );
196 rod.position.set(0, mer.apexHeight + 0.01, 0);
197 g.add(rod);
198 const felt = new THREE.Mesh(
199 new THREE.CylinderGeometry(0.012, 0.012, 0.006, 16),
200 new THREE.MeshStandardMaterial({ color: 0x2f3a44, roughness: 0.95 }),
201 );
202 felt.position.set(0, mer.apexHeight - 0.004, 0);
203 g.add(felt);
204 const post = new THREE.Mesh(
205 new THREE.CylinderGeometry(0.008, 0.008, 0.36, 12),
206 new THREE.MeshStandardMaterial({ color: 0x6f757f, roughness: 0.5, metalness: 0.7 }),
207 );
208 post.position.set(0, mer.apexHeight - 0.19, 0);
209 g.add(post);
211 // stick at the contact point, along the stroke direction
212 const addStick = (frac: number, primary: boolean) => {
213 const pt = atFrac(frac);
214 const ang = (params.strike_angle_deg * Math.PI) / 180;
215 // surface normal (-dz, dr) in the meridian plane, at theta = 0
216 const nrm = toScene(-pt.tz, 0, pt.tr);
217 const tan = toScene(pt.tr, 0, pt.tz);
218 // stroke direction: -cos(angle) * normal + sin(angle) * tangent
219 const dirv = nrm.clone().multiplyScalar(-Math.cos(ang)).addScaledVector(tan, Math.sin(ang));
220 const contact = toScene(pt.r, 0, pt.z);
221 const tip = contact.clone().addScaledVector(dirv, -(params.tip_radius_m + 0.0008));
222 const color = primary ? 0xe8a33d : 0x9a7a3d;
223 const head = new THREE.Mesh(
224 new THREE.SphereGeometry(params.tip_radius_m, 16, 12),
225 new THREE.MeshStandardMaterial({ color, roughness: 0.35 }),
226 );
227 head.position.copy(tip);
228 g.add(head);
229 const shaftLen = 0.24;
230 const shaft = new THREE.Mesh(
231 new THREE.CylinderGeometry(0.0055, 0.0072, shaftLen, 12),
232 new THREE.MeshStandardMaterial({ color: 0xb08c5a, roughness: 0.75 }),
233 );
234 shaft.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), dirv.clone().negate());
235 shaft.position.copy(tip).addScaledVector(dirv, -(params.tip_radius_m + shaftLen / 2));
236 g.add(shaft);
237 if (primary) {
238 const lbl = makeLabel('stroke', '#e8a33d');
239 lbl.position.copy(tip).add(new THREE.Vector3(0, 0.05, 0));
240 g.add(lbl);
241 }
242 };
243 addStick(params.strike_r, true);
244 if (params.strike_count > 1) addStick(params.roll_r2, false);
246 // listener
247 const lis = toScene(params.listener_x_m, params.listener_y_m, params.listener_z_m);
248 const micHead = new THREE.Mesh(
249 new THREE.SphereGeometry(0.022, 20, 16),
250 new THREE.MeshStandardMaterial({ color: 0x4dd0e1, emissive: 0x0c3b42, roughness: 0.35 }),
251 );
252 micHead.position.copy(lis);
253 g.add(micHead);
254 const micBody = new THREE.Mesh(
255 new THREE.CylinderGeometry(0.011, 0.011, 0.07, 12),
256 new THREE.MeshStandardMaterial({ color: 0x455a64, roughness: 0.6 }),
257 );
258 micBody.position.copy(lis).add(new THREE.Vector3(0, -0.046, 0));
259 g.add(micBody);
260 const centroid = toScene(0, 0, mer.z(mer.rRim * 0.6));
261 const lineGeo = new THREE.BufferGeometry().setFromPoints([centroid, lis]);
262 const line = new THREE.Line(
263 lineGeo,
264 new THREE.LineDashedMaterial({ color: 0x4dd0e1, dashSize: 0.02, gapSize: 0.014 }),
265 );
266 line.computeLineDistances();
267 g.add(line);
268 const micLabel = makeLabel(`mic ${(centroid.distanceTo(lis) * 100).toFixed(0)} cm`, '#4dd0e1');
269 micLabel.position.copy(lis).add(new THREE.Vector3(0, 0.06, 0));
270 g.add(micLabel);
272 ctx.scene.add(g);
273 ctx.group = g;
274 }, [
275 params.radius_m,
276 params.hole_radius_m,
277 params.bell_radius_m,
278 params.bell_height_m,
279 params.bow_height_m,
280 params.thickness_bell_m,
281 params.thickness_rim_m,
282 params.strike_r,
283 params.strike_angle_deg,
284 params.strike_count,
285 params.roll_r2,
286 params.tip_radius_m,
287 params.listener_x_m,
288 params.listener_y_m,
289 params.listener_z_m,
290 ]);
292 return (
293 <div className="scene3d" ref={mountRef}>
294 <div className="scene-hint">drag to orbit · scroll to zoom · right-drag to pan</div>
295 </div>
296 );
297}