/ simulated_instruments / recorder
Sign in
simulated_instruments / recorder
recorder / webapp / src / components / Scene3D.tsx
254 lines · 8.7 KBCodeBlameHistory
5623b11Recorder simulator: MATLAB jet-drive model and interactive web appJeremy Magland 1// 3D view of the geometry: the recorder hanging vertically (window at the
2// origin, foot down along -y, tone holes opening toward +z, thumb hole
3// toward -z), the fingering of the first note shown as finger pads on the
4// closed holes, and the listener. three.js is y-up, so the mapping is the
5// identity.
7import { useEffect, useRef } from 'react';
8import * as THREE from 'three';
9import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
10import type { RecorderParams } from '../params';
11import { FINGERINGS, HOLE_FRAC, HOLE_RAD } from '../lib/instrument';
13function makeLabel(text: string, color: string, scale = 1): THREE.Sprite {
14 const canvas = document.createElement('canvas');
15 canvas.width = 320;
16 canvas.height = 64;
17 const ctx = canvas.getContext('2d')!;
18 ctx.font = '600 36px system-ui, sans-serif';
19 ctx.fillStyle = color;
20 ctx.textAlign = 'center';
21 ctx.textBaseline = 'middle';
22 ctx.fillText(text, 160, 32);
23 const tex = new THREE.CanvasTexture(canvas);
24 const mat = new THREE.SpriteMaterial({ map: tex, depthTest: false, transparent: true });
25 const sprite = new THREE.Sprite(mat);
26 sprite.scale.set(0.11 * scale, 0.022 * scale, 1);
27 return sprite;
30interface Props {
31 params: RecorderParams;
34export default function Scene3D({ params }: Props) {
35 const mountRef = useRef<HTMLDivElement | null>(null);
36 const sceneRef = useRef<{
37 scene: THREE.Scene;
38 group: THREE.Group | null;
39 renderer: THREE.WebGLRenderer;
40 camera: THREE.PerspectiveCamera;
41 } | null>(null);
43 // one-time scene setup
44 useEffect(() => {
45 const mount = mountRef.current!;
46 const renderer = new THREE.WebGLRenderer({ antialias: true });
47 renderer.setPixelRatio(window.devicePixelRatio);
48 renderer.setClearColor(0x14161a);
49 mount.appendChild(renderer.domElement);
51 const scene = new THREE.Scene();
52 const camera = new THREE.PerspectiveCamera(45, 1, 0.005, 20);
53 camera.position.set(0.30, 0.06, 0.42);
55 const controls = new OrbitControls(camera, renderer.domElement);
56 controls.target.set(0, -0.14, 0);
57 controls.enableDamping = true;
59 scene.add(new THREE.AmbientLight(0xffffff, 0.55));
60 const dir = new THREE.DirectionalLight(0xffffff, 1.1);
61 dir.position.set(0.5, 1.0, 0.6);
62 scene.add(dir);
63 const dir2 = new THREE.DirectionalLight(0x8899ff, 0.3);
64 dir2.position.set(-0.6, 0.3, -0.5);
65 scene.add(dir2);
67 const grid = new THREE.GridHelper(1.0, 32, 0x3a4048, 0x262b33);
68 grid.position.y = -0.42;
69 scene.add(grid);
71 // model axes: x red, y green (along the bore, up), z blue (front)
72 const axisLen = 0.06;
73 const axes: [THREE.Vector3, number, string][] = [
74 [new THREE.Vector3(axisLen, 0, 0), 0xe57373, '+x'],
75 [new THREE.Vector3(0, axisLen, 0), 0x81c784, '+y'],
76 [new THREE.Vector3(0, 0, axisLen), 0x64b5f6, '+z'],
77 ];
78 for (const [v, color, label] of axes) {
79 const geo = new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(0, 0, 0), v]);
80 scene.add(new THREE.Line(geo, new THREE.LineBasicMaterial({ color })));
81 const s = makeLabel(label, '#' + color.toString(16).padStart(6, '0'));
82 s.position.copy(v).multiplyScalar(1.15);
83 scene.add(s);
84 }
86 sceneRef.current = { scene, group: null, renderer, camera };
88 let raf = 0;
89 const animate = () => {
90 raf = requestAnimationFrame(animate);
91 controls.update();
92 renderer.render(scene, camera);
93 };
94 animate();
96 const resize = () => {
97 const w = mount.clientWidth;
98 const h = mount.clientHeight;
99 renderer.setSize(w, h, false);
100 camera.aspect = w / h;
101 camera.updateProjectionMatrix();
102 };
103 resize();
104 const ro = new ResizeObserver(resize);
105 ro.observe(mount);
107 return () => {
108 cancelAnimationFrame(raf);
109 ro.disconnect();
110 controls.dispose();
111 renderer.dispose();
112 mount.removeChild(renderer.domElement);
113 sceneRef.current = null;
114 };
115 }, []);
117 // rebuild the geometry group whenever relevant parameters change
118 useEffect(() => {
119 const ctx = sceneRef.current;
120 if (!ctx) return;
121 if (ctx.group) {
122 ctx.scene.remove(ctx.group);
123 ctx.group.traverse((o) => {
124 const mesh = o as THREE.Mesh;
125 if (mesh.geometry) mesh.geometry.dispose();
126 });
127 }
129 const g = new THREE.Group();
130 const L = params.bore_length_m;
131 const rHead = params.bore_r_head_m + params.wall_m;
132 const rFoot = params.bore_r_foot_m + params.wall_m;
133 const wood = new THREE.MeshStandardMaterial({
134 color: 0xa8763e,
135 roughness: 0.55,
136 metalness: 0.05,
137 });
138 const darkWood = new THREE.MeshStandardMaterial({
139 color: 0x6e4a26,
140 roughness: 0.6,
141 metalness: 0.05,
142 });
143 const holeMat = new THREE.MeshStandardMaterial({ color: 0x17130e, roughness: 0.9 });
145 // body: window (y = 0) down to the foot
146 const body = new THREE.Mesh(new THREE.CylinderGeometry(rHead, rFoot, L, 28), wood);
147 body.position.set(0, -L / 2, 0);
148 g.add(body);
150 // head above the window: beak with the windway
151 const headLen = 0.045;
152 const head = new THREE.Mesh(new THREE.CylinderGeometry(rHead * 1.06, rHead, headLen, 28), darkWood);
153 head.position.set(0, headLen / 2 + params.cutup_m, 0);
154 g.add(head);
155 const beak = new THREE.Mesh(
156 new THREE.SphereGeometry(rHead * 1.06, 20, 12, 0, Math.PI * 2, 0, Math.PI / 2),
157 darkWood,
158 );
159 beak.position.set(0, headLen + params.cutup_m, 0);
160 g.add(beak);
162 // window: dark opening on the +z side between flue exit and labium
163 const win = new THREE.Mesh(
164 new THREE.BoxGeometry(params.window_width_m, params.cutup_m, 0.0015),
165 holeMat,
166 );
167 win.position.set(0, params.cutup_m / 2, rHead - 0.0002);
168 g.add(win);
170 // tone holes; a fingering pad covers each closed hole
171 const fing = FINGERINGS[Math.min(14, Math.max(1, Math.round(params.note1))) - 1];
172 const pad = new THREE.MeshStandardMaterial({ color: 0xd8a37a, roughness: 0.8 });
173 for (let j = 0; j < 8; j++) {
174 const y = -HOLE_FRAC[j] * L;
175 const r = HOLE_RAD[j] * params.hole_scale;
176 const frac = HOLE_FRAC[j];
177 const rAt = rHead + (rFoot - rHead) * frac;
178 const zdir = j === 0 ? -1 : 1; // thumb hole on the back
179 const hole = new THREE.Mesh(new THREE.CylinderGeometry(r, r, 0.004, 16), holeMat);
180 hole.rotation.x = Math.PI / 2;
181 hole.position.set(0, y, zdir * rAt);
182 g.add(hole);
183 const state = fing[j];
184 if (state < 0.75) {
185 // closed or pinched: a finger pad (smaller for the pinch)
186 const pr = state < 0.25 ? r * 1.9 : r * 1.1;
187 const cap = new THREE.Mesh(new THREE.SphereGeometry(pr, 14, 10), pad);
188 cap.scale.z = 0.45;
189 cap.position.set(0, y, zdir * (rAt + 0.001));
190 g.add(cap);
191 }
192 }
194 // foot opening
195 const foot = new THREE.Mesh(
196 new THREE.CylinderGeometry(params.bore_r_foot_m, params.bore_r_foot_m, 0.002, 20),
197 holeMat,
198 );
199 foot.position.set(0, -L - 0.0005, 0);
200 g.add(foot);
202 // listener: microphone marker + line from the window
203 const lis = new THREE.Vector3(params.listener_x_m, params.listener_y_m, params.listener_z_m);
204 const micHead = new THREE.Mesh(
205 new THREE.SphereGeometry(0.016, 20, 16),
206 new THREE.MeshStandardMaterial({ color: 0x4dd0e1, emissive: 0x0c3b42, roughness: 0.35 }),
207 );
208 micHead.position.copy(lis);
209 g.add(micHead);
210 const micBody = new THREE.Mesh(
211 new THREE.CylinderGeometry(0.008, 0.008, 0.05, 12),
212 new THREE.MeshStandardMaterial({ color: 0x455a64, roughness: 0.6 }),
213 );
214 micBody.position.copy(lis).add(new THREE.Vector3(0, -0.033, 0));
215 g.add(micBody);
216 const windowPos = new THREE.Vector3(0, 0, 0);
217 const lineGeo = new THREE.BufferGeometry().setFromPoints([windowPos, lis]);
218 const line = new THREE.Line(
219 lineGeo,
220 new THREE.LineDashedMaterial({ color: 0x4dd0e1, dashSize: 0.012, gapSize: 0.009 }),
221 );
222 line.computeLineDistances();
223 g.add(line);
224 const dist = windowPos.distanceTo(lis);
225 const micLabel = makeLabel(`mic ${(dist * 100).toFixed(0)} cm`, '#4dd0e1');
226 micLabel.position.copy(lis).add(new THREE.Vector3(0, 0.045, 0));
227 g.add(micLabel);
229 const winLabel = makeLabel('window', '#e8a33d', 0.8);
230 winLabel.position.set(0.05, 0.012, rHead + 0.01);
231 g.add(winLabel);
233 ctx.scene.add(g);
234 ctx.group = g;
235 }, [
236 params.bore_length_m,
237 params.bore_r_head_m,
238 params.bore_r_foot_m,
239 params.wall_m,
240 params.hole_scale,
241 params.cutup_m,
242 params.window_width_m,
243 params.note1,
244 params.listener_x_m,
245 params.listener_y_m,
246 params.listener_z_m,
247 ]);
249 return (
250 <div className="scene3d" ref={mountRef}>
251 <div className="scene-hint">drag to orbit · scroll to zoom · right-drag to pan</div>
252 </div>
253 );
↑↓ move↵openescclose