1// 3D view of the geometry: the open triangle hanging in a vertical plane,
2// the beater at the strike point (and the second roll contact), and the
3// listener. Model coordinates put the triangle in the x-y plane with the
4// apex up and z normal to the plane; three.js is y-up too, so the mapping
5// is the identity.
7import { useEffect, useRef } from 'react';
8import * as THREE from 'three';
9import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
10import type { TriangleParams } from '../params';
11import { triangleOutline } from '../lib/outline';
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.075 * scale, 0.015 * scale, 1);
27 return sprite;
28}
30interface Props {
31 params: TriangleParams;
32}
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.16, 0.16, 0.34);
55 const controls = new OrbitControls(camera, renderer.domElement);
56 controls.target.set(0, 0.07, 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(0.8, 32, 0x3a4048, 0x262b33);
68 grid.position.y = -0.06;
69 scene.add(grid);
71 // model axes: x red (along the base), y green (up), z blue (plane normal)
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 outline = triangleOutline(params.side_length_m, params.gap_m);
131 const a = params.rod_diameter_m / 2;
132 const steel = new THREE.MeshStandardMaterial({
133 color: 0xb8bec8,
134 roughness: 0.35,
135 metalness: 0.85,
136 });
138 // the rod: one cylinder per leg, spheres closing the two corners and
139 // capping the free ends at the gap
140 for (let k = 0; k < 3; k++) {
141 const p0 = outline.pts[k];
142 const p1 = outline.pts[k + 1];
143 const len = Math.hypot(p1.x - p0.x, p1.y - p0.y);
144 const cyl = new THREE.Mesh(new THREE.CylinderGeometry(a, a, len, 20), steel);
145 cyl.position.set((p0.x + p1.x) / 2, (p0.y + p1.y) / 2, 0);
146 const dirv = new THREE.Vector3(p1.x - p0.x, p1.y - p0.y, 0).normalize();
147 cyl.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), dirv);
148 g.add(cyl);
149 }
150 for (const p of outline.pts) {
151 const cap = new THREE.Mesh(new THREE.SphereGeometry(a, 14, 10), steel);
152 cap.position.set(p.x, p.y, 0);
153 g.add(cap);
154 }
156 // suspension loop at the apex
157 const apexY = (params.side_length_m * Math.sqrt(3)) / 2;
158 const loop = new THREE.Mesh(
159 new THREE.TorusGeometry(0.011, 0.0016, 8, 24),
160 new THREE.MeshStandardMaterial({ color: 0x8d6e63, roughness: 0.8 }),
161 );
162 loop.position.set(0, apexY + 0.013, 0);
163 g.add(loop);
164 const cordGeo = new THREE.BufferGeometry().setFromPoints([
165 new THREE.Vector3(0, apexY + 0.022, 0),
166 new THREE.Vector3(0, apexY + 0.08, 0),
167 ]);
168 g.add(new THREE.Line(cordGeo, new THREE.LineBasicMaterial({ color: 0x6b5a50 })));
170 // beater at the strike point, pointing along the strike direction
171 const addBeater = (frac: number, primary: boolean) => {
172 const pt = outline.at(frac);
173 const ang = (params.strike_angle_deg * Math.PI) / 180;
174 // inward in-plane normal of a counterclockwise outline, tilted out of plane
175 const dirv = new THREE.Vector3(
176 Math.cos(ang) * -pt.ty,
177 Math.cos(ang) * pt.tx,
178 Math.sin(ang),
179 );
180 const tip = new THREE.Vector3(pt.x, pt.y, 0).addScaledVector(
181 dirv,
182 -(a + params.beater_radius_m + 0.001),
183 );
184 const color = primary ? 0xe8a33d : 0x9a7a3d;
185 const head = new THREE.Mesh(
186 new THREE.SphereGeometry(params.beater_radius_m, 16, 12),
187 new THREE.MeshStandardMaterial({ color, roughness: 0.4, metalness: 0.6 }),
188 );
189 head.position.copy(tip);
190 g.add(head);
191 const shaftLen = 0.07;
192 const shaft = new THREE.Mesh(
193 new THREE.CylinderGeometry(0.0022, 0.0022, shaftLen, 10),
194 new THREE.MeshStandardMaterial({ color: 0x777d88, roughness: 0.5, metalness: 0.7 }),
195 );
196 shaft.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), dirv.clone().negate());
197 shaft.position.copy(tip).addScaledVector(dirv, -(params.beater_radius_m + shaftLen / 2));
198 g.add(shaft);
199 if (primary) {
200 const lbl = makeLabel('strike', '#e8a33d');
201 lbl.position.copy(tip).add(new THREE.Vector3(0, -0.02, 0.01));
202 g.add(lbl);
203 }
204 };
205 addBeater(params.strike_pos, true);
206 if (params.strike_count > 1) addBeater(params.roll_pos2, false);
208 // listener: microphone marker + line from the frame centroid
209 const lis = new THREE.Vector3(params.listener_x_m, params.listener_y_m, params.listener_z_m);
210 const micHead = new THREE.Mesh(
211 new THREE.SphereGeometry(0.016, 20, 16),
212 new THREE.MeshStandardMaterial({ color: 0x4dd0e1, emissive: 0x0c3b42, roughness: 0.35 }),
213 );
214 micHead.position.copy(lis);
215 g.add(micHead);
216 const micBody = new THREE.Mesh(
217 new THREE.CylinderGeometry(0.008, 0.008, 0.05, 12),
218 new THREE.MeshStandardMaterial({ color: 0x455a64, roughness: 0.6 }),
219 );
220 micBody.position.copy(lis).add(new THREE.Vector3(0, -0.033, 0));
221 g.add(micBody);
222 const centroid = new THREE.Vector3(0, apexY / 2.2, 0);
223 const lineGeo = new THREE.BufferGeometry().setFromPoints([centroid, lis]);
224 const line = new THREE.Line(
225 lineGeo,
226 new THREE.LineDashedMaterial({ color: 0x4dd0e1, dashSize: 0.012, gapSize: 0.009 }),
227 );
228 line.computeLineDistances();
229 g.add(line);
230 const dist = centroid.distanceTo(lis);
231 const micLabel = makeLabel(`mic ${(dist * 100).toFixed(0)} cm`, '#4dd0e1');
232 micLabel.position.copy(lis).add(new THREE.Vector3(0, 0.045, 0));
233 g.add(micLabel);
235 ctx.scene.add(g);
236 ctx.group = g;
237 }, [
238 params.side_length_m,
239 params.gap_m,
240 params.rod_diameter_m,
241 params.strike_pos,
242 params.strike_angle_deg,
243 params.strike_count,
244 params.roll_pos2,
245 params.beater_radius_m,
246 params.listener_x_m,
247 params.listener_y_m,
248 params.listener_z_m,
249 ]);
251 return (
252 <div className="scene3d" ref={mountRef}>
253 <div className="scene-hint">drag to orbit · scroll to zoom · right-drag to pan</div>
254 </div>
255 );
256}