1// 3D view of the geometry: block, cavity hint, slit, strike point + mallet,
2// listener, radiating surface elements. Model coordinates are block-centred
3// with +z up out of the struck face; three.js is y-up, so we map
4// (x, y, z)_model -> (x, z, -y)_three, a proper rotation.
6import { useEffect, useRef } from 'react';
7import * as THREE from 'three';
8import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
9import type { WoodblockParams } from '../params';
11function m2t(x: number, y: number, z: number): [number, number, number] {
12 return [x, z, -y];
13}
15function makeLabel(text: string, color: string): THREE.Sprite {
16 const canvas = document.createElement('canvas');
17 canvas.width = 320;
18 canvas.height = 64;
19 const ctx = canvas.getContext('2d')!;
20 ctx.font = '600 36px system-ui, sans-serif';
21 ctx.fillStyle = color;
22 ctx.textAlign = 'center';
23 ctx.textBaseline = 'middle';
24 ctx.fillText(text, 160, 32);
25 const tex = new THREE.CanvasTexture(canvas);
26 const mat = new THREE.SpriteMaterial({ map: tex, depthTest: false, transparent: true });
27 const sprite = new THREE.Sprite(mat);
28 sprite.scale.set(0.15, 0.03, 1);
29 return sprite;
30}
32interface Props {
33 params: WoodblockParams;
34}
36export default function Scene3D({ params }: Props) {
37 const mountRef = useRef<HTMLDivElement | null>(null);
38 const sceneRef = useRef<{
39 scene: THREE.Scene;
40 group: THREE.Group | null;
41 renderer: THREE.WebGLRenderer;
42 camera: THREE.PerspectiveCamera;
43 } | null>(null);
45 // one-time scene setup
46 useEffect(() => {
47 const mount = mountRef.current!;
48 const renderer = new THREE.WebGLRenderer({ antialias: true });
49 renderer.setPixelRatio(window.devicePixelRatio);
50 renderer.setClearColor(0x14161a);
51 mount.appendChild(renderer.domElement);
53 const scene = new THREE.Scene();
54 const camera = new THREE.PerspectiveCamera(45, 1, 0.01, 20);
55 camera.position.set(0.45, 0.35, 0.6);
57 const controls = new OrbitControls(camera, renderer.domElement);
58 controls.target.set(0, 0.06, 0);
59 controls.enableDamping = true;
61 scene.add(new THREE.AmbientLight(0xffffff, 0.55));
62 const dir = new THREE.DirectionalLight(0xffffff, 1.1);
63 dir.position.set(0.5, 1.0, 0.6);
64 scene.add(dir);
65 const dir2 = new THREE.DirectionalLight(0x8899ff, 0.3);
66 dir2.position.set(-0.6, 0.3, -0.5);
67 scene.add(dir2);
69 const grid = new THREE.GridHelper(2, 40, 0x3a4048, 0x262b33);
70 grid.position.y = -0.101;
71 scene.add(grid);
73 // model axes: x red (along bar), y green (across width), z blue (up)
74 const axisLen = 0.14;
75 const axes: [THREE.Vector3, number, string][] = [
76 [new THREE.Vector3(...m2t(axisLen, 0, 0)), 0xe57373, '+x'],
77 [new THREE.Vector3(...m2t(0, axisLen, 0)), 0x81c784, '+y'],
78 [new THREE.Vector3(...m2t(0, 0, axisLen)), 0x64b5f6, '+z'],
79 ];
80 for (const [v, color, label] of axes) {
81 const geo = new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(0, 0, 0), v]);
82 scene.add(new THREE.Line(geo, new THREE.LineBasicMaterial({ color })));
83 const s = makeLabel(label, '#' + color.toString(16).padStart(6, '0'));
84 s.position.copy(v).multiplyScalar(1.15);
85 scene.add(s);
86 }
88 sceneRef.current = { scene, group: null, renderer, camera };
90 let raf = 0;
91 const animate = () => {
92 raf = requestAnimationFrame(animate);
93 controls.update();
94 renderer.render(scene, camera);
95 };
96 animate();
98 const resize = () => {
99 const w = mount.clientWidth;
100 const h = mount.clientHeight;
101 renderer.setSize(w, h, false);
102 camera.aspect = w / h;
103 camera.updateProjectionMatrix();
104 };
105 resize();
106 const ro = new ResizeObserver(resize);
107 ro.observe(mount);
109 return () => {
110 cancelAnimationFrame(raf);
111 ro.disconnect();
112 controls.dispose();
113 renderer.dispose();
114 mount.removeChild(renderer.domElement);
115 sceneRef.current = null;
116 };
117 }, []);
119 // rebuild the geometry group whenever relevant parameters change
120 useEffect(() => {
121 const ctx = sceneRef.current;
122 if (!ctx) return;
123 if (ctx.group) {
124 ctx.scene.remove(ctx.group);
125 ctx.group.traverse((o) => {
126 const mesh = o as THREE.Mesh;
127 if (mesh.geometry) mesh.geometry.dispose();
128 });
129 }
131 const g = new THREE.Group();
132 const L = params.length_m;
133 const W = params.width_m;
134 const Hb = params.block_height_m;
136 // block body
137 const wood = new THREE.MeshStandardMaterial({
138 color: 0xa06f45,
139 roughness: 0.65,
140 metalness: 0.0,
141 transparent: true,
142 opacity: 0.92,
143 });
144 const block = new THREE.Mesh(new THREE.BoxGeometry(L, Hb, W), wood);
145 g.add(block);
146 const edges = new THREE.LineSegments(
147 new THREE.EdgesGeometry(new THREE.BoxGeometry(L, Hb, W)),
148 new THREE.LineBasicMaterial({ color: 0x5c4026 }),
149 );
150 g.add(edges);
152 // cavity hint: darker inner box over the cavity wall span
153 const lo = params.cavity_span_lo;
154 const hi = params.cavity_span_hi;
155 const cavLen = Math.max(0.001, (hi - lo) * L);
156 const cavCentre = ((lo + hi) / 2 - 0.5) * L;
157 const cavity = new THREE.Mesh(
158 new THREE.BoxGeometry(cavLen, Hb * 0.55, W * 0.72),
159 new THREE.MeshStandardMaterial({ color: 0x241a10, roughness: 0.9 }),
160 );
161 cavity.position.set(...m2t(cavCentre, 0, 0));
162 g.add(cavity);
164 // slit opening marker: dark patch + small sphere at the monopole point
165 const slitPos = new THREE.Vector3(...m2t(params.slit_x_m, params.slit_y_m, params.slit_z_m));
166 const slitArea = params.slit_area_m2;
167 const slitLen = Math.min(L * 0.6, Math.sqrt(slitArea * 10)); // display proportions only
168 const slitWid = slitArea / slitLen;
169 const slit = new THREE.Mesh(
170 new THREE.BoxGeometry(slitLen, slitWid, 0.002),
171 new THREE.MeshStandardMaterial({ color: 0x0d0a06, roughness: 1.0 }),
172 );
173 slit.position.copy(slitPos);
174 // orient the patch to lie in the wall nearest the slit point
175 if (Math.abs(params.slit_z_m) >= Math.abs(params.slit_y_m)) slit.rotation.x = Math.PI / 2;
176 g.add(slit);
177 const slitDot = new THREE.Mesh(
178 new THREE.SphereGeometry(0.005, 16, 12),
179 new THREE.MeshStandardMaterial({ color: 0xba68c8, emissive: 0x4a1f55 }),
180 );
181 slitDot.position.copy(slitPos);
182 g.add(slitDot);
183 const slitLabel = makeLabel('slit', '#ba68c8');
184 slitLabel.position.copy(slitPos).add(new THREE.Vector3(0, 0.03, 0));
185 g.add(slitLabel);
187 // strike point + mallet
188 const xc = (params.strike_position - 0.5) * L;
189 const strikeM = m2t(xc, 0, Hb / 2);
190 const strikeDot = new THREE.Mesh(
191 new THREE.SphereGeometry(0.006, 16, 12),
192 new THREE.MeshStandardMaterial({ color: 0xef5350, emissive: 0x5a1210 }),
193 );
194 strikeDot.position.set(...strikeM);
195 g.add(strikeDot);
197 const malletBall = new THREE.Mesh(
198 new THREE.SphereGeometry(0.014, 20, 16),
199 new THREE.MeshStandardMaterial({ color: 0xd7ccc8, roughness: 0.5 }),
200 );
201 malletBall.position.set(strikeM[0], strikeM[1] + 0.05, strikeM[2]);
202 g.add(malletBall);
203 const shaft = new THREE.Mesh(
204 new THREE.CylinderGeometry(0.004, 0.004, 0.16, 12),
205 new THREE.MeshStandardMaterial({ color: 0x8d6e63, roughness: 0.7 }),
206 );
207 shaft.position.set(strikeM[0] + 0.055, strikeM[1] + 0.115, strikeM[2]);
208 shaft.rotation.z = -0.75;
209 g.add(shaft);
210 const strikeLabel = makeLabel('strike', '#ef5350');
211 strikeLabel.position.set(strikeM[0] - 0.01, strikeM[1] + 0.09, strikeM[2]);
212 g.add(strikeLabel);
214 // radiating surface elements on both faces
215 const M = params.radiating_elements;
216 const pts: number[] = [];
217 for (let j = 0; j < M; j++) {
218 const x = ((j + 0.5) / M - 0.5) * L;
219 pts.push(...m2t(x, 0, Hb / 2 + 0.001));
220 pts.push(...m2t(x, 0, -Hb / 2 - 0.001));
221 }
222 const ptsGeo = new THREE.BufferGeometry();
223 ptsGeo.setAttribute('position', new THREE.Float32BufferAttribute(pts, 3));
224 g.add(new THREE.Points(ptsGeo, new THREE.PointsMaterial({ color: 0xffe082, size: 0.004 })));
226 // listener: microphone marker + line from block centre
227 const lis = new THREE.Vector3(
228 ...m2t(params.listener_x_m, params.listener_y_m, params.listener_z_m),
229 );
230 const micHead = new THREE.Mesh(
231 new THREE.SphereGeometry(0.016, 20, 16),
232 new THREE.MeshStandardMaterial({ color: 0x4dd0e1, emissive: 0x0c3b42, roughness: 0.35 }),
233 );
234 micHead.position.copy(lis);
235 g.add(micHead);
236 const micBody = new THREE.Mesh(
237 new THREE.CylinderGeometry(0.008, 0.008, 0.05, 12),
238 new THREE.MeshStandardMaterial({ color: 0x455a64, roughness: 0.6 }),
239 );
240 micBody.position.copy(lis).add(new THREE.Vector3(0, -0.033, 0));
241 g.add(micBody);
242 const lineGeo = new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(0, 0, 0), lis]);
243 const line = new THREE.Line(
244 lineGeo,
245 new THREE.LineDashedMaterial({ color: 0x4dd0e1, dashSize: 0.012, gapSize: 0.009 }),
246 );
247 line.computeLineDistances();
248 g.add(line);
249 const dist = Math.hypot(params.listener_x_m, params.listener_y_m, params.listener_z_m);
250 const micLabel = makeLabel(`mic ${(dist * 100).toFixed(0)} cm`, '#4dd0e1');
251 micLabel.position.copy(lis).add(new THREE.Vector3(0, 0.045, 0));
252 g.add(micLabel);
254 ctx.scene.add(g);
255 ctx.group = g;
256 }, [
257 params.length_m, params.width_m, params.block_height_m,
258 params.cavity_span_lo, params.cavity_span_hi,
259 params.slit_x_m, params.slit_y_m, params.slit_z_m, params.slit_area_m2,
260 params.strike_position, params.radiating_elements,
261 params.listener_x_m, params.listener_y_m, params.listener_z_m,
262 ]);
264 return (
265 <div className="scene3d" ref={mountRef}>
266 <div className="scene-hint">drag to orbit · scroll to zoom · right-drag to pan</div>
267 </div>
268 );
269}