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';
12import { cymbalMeridian } from '../lib/profile';
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 );
145 // meridian sample points, used both for the lathe and for placing the stick
146 const NS = 64;
147 const rs: number[] = [];
148 for (let i = 0; i <= NS; i++) rs.push(mer.rHole + (i / NS) * (mer.rRim - mer.rHole));
149 // arclength, so that strike_r addresses the same point the model does
150 const arc: number[] = [0];
151 for (let i = 1; i <= NS; i++) {
152 arc.push(arc[i - 1] + Math.hypot(rs[i] - rs[i - 1], mer.z(rs[i]) - mer.z(rs[i - 1])));
153 }
154 const total = arc[NS];
155 const atFrac = (frac: number) => {
156 const s = Math.min(0.9999, Math.max(0, frac)) * total;
157 let i = 0;
158 while (i < NS - 1 && arc[i + 1] < s) i++;
159 const t = (s - arc[i]) / Math.max(1e-12, arc[i + 1] - arc[i]);
160 const r = rs[i] + t * (rs[i + 1] - rs[i]);
161 const z = mer.z(rs[i]) + t * (mer.z(rs[i + 1]) - mer.z(rs[i]));
162 const dr = rs[i + 1] - rs[i];
163 const dz = mer.z(rs[i + 1]) - mer.z(rs[i]);
164 const dl = Math.hypot(dr, dz) || 1;
165 return { r, z, tr: dr / dl, tz: dz / dl };
166 };
168 // the plate: a lathe of the meridian offset to both faces
169 const pts: THREE.Vector2[] = [];
170 for (let i = 0; i <= NS; i++) {
171 const h = mer.thickness(rs[i]) / 2;
172 pts.push(new THREE.Vector2(rs[i], mer.z(rs[i]) + h));
173 }
174 for (let i = NS; i >= 0; i--) {
175 const h = mer.thickness(rs[i]) / 2;
176 pts.push(new THREE.Vector2(rs[i], mer.z(rs[i]) - h));
177 }
178 pts.push(pts[0].clone());
179 const lathe = new THREE.Mesh(
180 new THREE.LatheGeometry(pts, 96),
181 new THREE.MeshStandardMaterial({
182 color: 0xc9a227,
183 roughness: 0.32,
184 metalness: 0.9,
185 side: THREE.DoubleSide,
186 }),
187 );
188 lathe.rotation.x = -Math.PI / 2;
189 g.add(lathe);
191 // lathing grooves, as faint rings
192 for (let k = 1; k < 9; k++) {
193 const r = mer.rBell + (k / 9) * (mer.rRim - mer.rBell);
194 const ringPts: THREE.Vector3[] = [];
195 for (let a = 0; a <= 72; a++) {
196 const th = (a / 72) * 2 * Math.PI;
197 ringPts.push(toScene(r * Math.cos(th), r * Math.sin(th), mer.z(r) + 0.0008));
198 }
199 const ring = new THREE.Line(
200 new THREE.BufferGeometry().setFromPoints(ringPts),
201 new THREE.LineBasicMaterial({ color: 0x8a6d18, transparent: true, opacity: 0.5 }),
202 );
203 g.add(ring);
204 }
206 // stand: rod through the hole, felt washer, tripod post
207 const rod = new THREE.Mesh(
208 new THREE.CylinderGeometry(0.0035, 0.0035, 0.09, 12),
209 new THREE.MeshStandardMaterial({ color: 0x9aa0aa, roughness: 0.4, metalness: 0.8 }),
210 );
211 rod.position.set(0, mer.apexHeight + 0.01, 0);
212 g.add(rod);
213 const felt = new THREE.Mesh(
214 new THREE.CylinderGeometry(0.012, 0.012, 0.006, 16),
215 new THREE.MeshStandardMaterial({ color: 0x2f3a44, roughness: 0.95 }),
216 );
217 felt.position.set(0, mer.apexHeight - 0.004, 0);
218 g.add(felt);
219 const post = new THREE.Mesh(
220 new THREE.CylinderGeometry(0.008, 0.008, 0.36, 12),
221 new THREE.MeshStandardMaterial({ color: 0x6f757f, roughness: 0.5, metalness: 0.7 }),
222 );
223 post.position.set(0, mer.apexHeight - 0.19, 0);
224 g.add(post);
226 // stick at the contact point, along the stroke direction
227 const addStick = (frac: number, primary: boolean) => {
228 const pt = atFrac(frac);
229 const ang = (params.strike_angle_deg * Math.PI) / 180;
230 // surface normal (-dz, dr) in the meridian plane, at theta = 0
231 const nrm = toScene(-pt.tz, 0, pt.tr);
232 const tan = toScene(pt.tr, 0, pt.tz);
233 // stroke direction: -cos(angle) * normal + sin(angle) * tangent
234 const dirv = nrm.clone().multiplyScalar(-Math.cos(ang)).addScaledVector(tan, Math.sin(ang));
235 const contact = toScene(pt.r, 0, pt.z);
236 const tip = contact.clone().addScaledVector(dirv, -(params.tip_radius_m + 0.0008));
237 const color = primary ? 0xe8a33d : 0x9a7a3d;
238 const head = new THREE.Mesh(
239 new THREE.SphereGeometry(params.tip_radius_m, 16, 12),
240 new THREE.MeshStandardMaterial({ color, roughness: 0.35 }),
241 );
242 head.position.copy(tip);
243 g.add(head);
244 const shaftLen = 0.24;
245 const shaft = new THREE.Mesh(
246 new THREE.CylinderGeometry(0.0055, 0.0072, shaftLen, 12),
247 new THREE.MeshStandardMaterial({ color: 0xb08c5a, roughness: 0.75 }),
248 );
249 shaft.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), dirv.clone().negate());
250 shaft.position.copy(tip).addScaledVector(dirv, -(params.tip_radius_m + shaftLen / 2));
251 g.add(shaft);
252 if (primary) {
253 const lbl = makeLabel('stroke', '#e8a33d');
254 lbl.position.copy(tip).add(new THREE.Vector3(0, 0.05, 0));
255 g.add(lbl);
256 }
257 };
258 addStick(params.strike_r, true);
259 if (params.strike_count > 1) addStick(params.roll_r2, false);
261 // listener
262 const lis = toScene(params.listener_x_m, params.listener_y_m, params.listener_z_m);
263 const micHead = new THREE.Mesh(
264 new THREE.SphereGeometry(0.022, 20, 16),
265 new THREE.MeshStandardMaterial({ color: 0x4dd0e1, emissive: 0x0c3b42, roughness: 0.35 }),
266 );
267 micHead.position.copy(lis);
268 g.add(micHead);
269 const micBody = new THREE.Mesh(
270 new THREE.CylinderGeometry(0.011, 0.011, 0.07, 12),
271 new THREE.MeshStandardMaterial({ color: 0x455a64, roughness: 0.6 }),
272 );
273 micBody.position.copy(lis).add(new THREE.Vector3(0, -0.046, 0));
274 g.add(micBody);
275 const centroid = toScene(0, 0, mer.z(mer.rRim * 0.6));
276 const lineGeo = new THREE.BufferGeometry().setFromPoints([centroid, lis]);
277 const line = new THREE.Line(
278 lineGeo,
279 new THREE.LineDashedMaterial({ color: 0x4dd0e1, dashSize: 0.02, gapSize: 0.014 }),
280 );
281 line.computeLineDistances();
282 g.add(line);
283 const micLabel = makeLabel(`mic ${(centroid.distanceTo(lis) * 100).toFixed(0)} cm`, '#4dd0e1');
284 micLabel.position.copy(lis).add(new THREE.Vector3(0, 0.06, 0));
285 g.add(micLabel);
287 ctx.scene.add(g);
288 ctx.group = g;
289 }, [
290 params.radius_m,
291 params.hole_radius_m,
292 params.bell_radius_m,
293 params.bell_height_m,
294 params.bow_height_m,
295 params.thickness_bell_m,
296 params.thickness_rim_m,
297 params.strike_r,
298 params.strike_angle_deg,
299 params.strike_count,
300 params.roll_r2,
301 params.tip_radius_m,
302 params.listener_x_m,
303 params.listener_y_m,
304 params.listener_z_m,
305 ]);
307 return (
308 <div className="scene3d" ref={mountRef}>
309 <div className="scene-hint">drag to orbit · scroll to zoom · right-drag to pan</div>
310 </div>
311 );
312}