de6cf45cymroom: load a .cymsim file and show every part of it in a 3D scene; the room acoustics solver is not written yetJeremy Magland 1// The 3D scene: the cymbal built from the mesh in the .cymsim file (positions xy/z0, triangles),
2// deformed by a displacement frame and tinted by a per-node field during playback, with
3// toggleable overlays (wireframe, boundary loop, nodal normals, hit markers, listener, grid,
4// axes) and node picking for the probe. cymsim's frame is z-up; everything cymsim-space lives in
5// a group rotated so that z maps to three.js's y-up.
6import * as THREE from "three";
7import { OrbitControls } from "three/addons/controls/OrbitControls.js";
8import type { CymsimRun } from "./io/cymsimFile.ts";
10export type ColorMode = "bronze" | "thickness" | "z0";
11export type OverlayName = "wireframe" | "boundary" | "normals" | "hits" | "listener" | "grid" | "axes";
13const BRONZE = new THREE.Color(0.72, 0.55, 0.28);
14const POS_TINT = new THREE.Color(1.0, 0.2, 0.15); // field > 0
15const NEG_TINT = new THREE.Color(0.15, 0.35, 1.0); // field < 0
17/** Small viridis-like ramp for scalar surface colors (thickness, rest height). */
18function ramp(t: number, out: THREE.Color): THREE.Color {
19 const stops = [[0.27, 0.0, 0.33], [0.21, 0.36, 0.55], [0.13, 0.57, 0.55], [0.37, 0.79, 0.38], [0.99, 0.91, 0.14]];
20 const x = Math.min(0.9999, Math.max(0, t)) * (stops.length - 1), i = Math.floor(x), f = x - i;
21 const a = stops[i], b = stops[i + 1];
22 return out.setRGB(a[0] + f * (b[0] - a[0]), a[1] + f * (b[1] - a[1]), a[2] + f * (b[2] - a[2]));
23}
25export class SceneView {
26 private renderer: THREE.WebGLRenderer;
27 private scene = new THREE.Scene();
28 private camera: THREE.PerspectiveCamera;
29 private controls: OrbitControls;
30 private world = new THREE.Group(); // cymsim frame (z up)
31 private cymbal: THREE.Mesh | null = null;
32 private wireframe: THREE.Mesh | null = null;
33 private boundary: THREE.LineLoop | null = null;
34 private normalsLines: THREE.LineSegments | null = null;
35 private hitMarkers = new THREE.Group();
36 private listenerMarker: THREE.Object3D | null = null;
37 private grid: THREE.GridHelper;
38 private axes = new THREE.AxesHelper(0.3);
39 private probeMarker: THREE.Mesh;
41 private run: CymsimRun | null = null;
42 private mode: ColorMode = "bronze";
43 private exag = 1;
44 private w: Float32Array | null = null; // current displacement frame [m]
45 private field: Float32Array | null = null; // current tint field
46 private fieldScale = 1;
47 private probeNode: number | null = null;
48 private down: { x: number; y: number } | null = null;
50 private onPick: (node: number) => void;
52 constructor(canvas: HTMLCanvasElement, onPick: (node: number) => void) {
53 this.onPick = onPick;
54 this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
55 this.renderer.setPixelRatio(Math.min(2, window.devicePixelRatio));
56 this.camera = new THREE.PerspectiveCamera(45, 1, 0.001, 100);
57 this.camera.position.set(0.35, 0.3, 0.42);
58 this.controls = new OrbitControls(this.camera, canvas);
59 this.controls.enableDamping = true;
60 this.controls.target.set(0, 0.01, 0);
61 this.scene.background = new THREE.Color(0x14161a);
62 this.scene.add(new THREE.HemisphereLight(0xdde4f0, 0x30281c, 1.1));
63 const sun = new THREE.DirectionalLight(0xffffff, 1.6);
64 sun.position.set(0.5, 1.0, 0.6);
65 this.scene.add(sun);
66 this.world.rotation.x = -Math.PI / 2; // cymsim z (up) -> three y (up)
67 this.scene.add(this.world);
68 this.world.add(this.hitMarkers);
69 this.grid = new THREE.GridHelper(2, 20, 0x3a4150, 0x252a33);
70 this.grid.position.y = -0.3;
71 this.scene.add(this.grid);
72 this.axes.visible = false;
73 this.world.add(this.axes);
74 this.probeMarker = new THREE.Mesh(new THREE.SphereGeometry(0.004, 12, 8), new THREE.MeshBasicMaterial({ color: 0x40ff90 }));
75 this.probeMarker.visible = false;
76 this.world.add(this.probeMarker);
78 new ResizeObserver(() => this.resize()).observe(canvas.parentElement as HTMLElement);
79 this.resize();
80 canvas.addEventListener("pointerdown", (e) => { this.down = { x: e.clientX, y: e.clientY }; });
81 canvas.addEventListener("pointerup", (e) => {
82 if (this.down && Math.hypot(e.clientX - this.down.x, e.clientY - this.down.y) < 5) this.pick(e);
83 this.down = null;
84 });
85 this.renderer.setAnimationLoop(() => { this.controls.update(); this.renderer.render(this.scene, this.camera); });
86 }
88 private resize() {
89 const el = this.renderer.domElement.parentElement as HTMLElement;
90 const w = Math.max(1, el.clientWidth), h = Math.max(1, el.clientHeight);
91 this.renderer.setSize(w, h, false);
92 this.camera.aspect = w / h;
93 this.camera.updateProjectionMatrix();
94 }
96 setRun(run: CymsimRun) {
97 this.run = run; this.w = null; this.field = null; this.probeNode = null; this.probeMarker.visible = false;
98 for (const o of [this.cymbal, this.wireframe, this.boundary, this.normalsLines, this.listenerMarker]) if (o) this.world.remove(o);
99 this.hitMarkers.clear();
101 const m = run.mesh, N = m.nNodes;
102 const geo = new THREE.BufferGeometry();
103 geo.setAttribute("position", new THREE.BufferAttribute(new Float32Array(3 * N), 3));
104 geo.setAttribute("color", new THREE.BufferAttribute(new Float32Array(3 * N), 3));
105 geo.setIndex(new THREE.BufferAttribute(Uint32Array.from(m.triangles), 1));
106 this.cymbal = new THREE.Mesh(geo, new THREE.MeshStandardMaterial({
107 vertexColors: true, metalness: 0.55, roughness: 0.4, side: THREE.DoubleSide,
108 }));
109 this.world.add(this.cymbal);
110 this.wireframe = new THREE.Mesh(geo, new THREE.MeshBasicMaterial({ wireframe: true, color: 0x0e1013, transparent: true, opacity: 0.5 }));
111 this.wireframe.visible = false;
112 this.world.add(this.wireframe);
114 const bGeo = new THREE.BufferGeometry();
115 bGeo.setAttribute("position", new THREE.BufferAttribute(new Float32Array(3 * m.boundary.length), 3));
116 this.boundary = new THREE.LineLoop(bGeo, new THREE.LineBasicMaterial({ color: 0xd9a441 }));
117 this.world.add(this.boundary);
119 const nGeo = new THREE.BufferGeometry(), quills = new Float32Array(6 * N), L = 0.006;
120 for (let i = 0; i < N; i++) {
121 const x = m.xy[2 * i], y = m.xy[2 * i + 1], z = m.z0[i];
122 quills.set([x, y, z, x + L * m.normal[3 * i], y + L * m.normal[3 * i + 1], z + L * m.normal[3 * i + 2]], 6 * i);
123 }
124 nGeo.setAttribute("position", new THREE.BufferAttribute(quills, 3));
125 this.normalsLines = new THREE.LineSegments(nGeo, new THREE.LineBasicMaterial({ color: 0x4fc3f7, transparent: true, opacity: 0.7 }));
126 this.normalsLines.visible = false;
127 this.world.add(this.normalsLines);
129 for (const hit of run.config.hits) {
130 const x = hit.r * Math.cos(hit.phi), y = hit.r * Math.sin(hit.phi);
131 const marker = new THREE.Mesh(new THREE.SphereGeometry(0.006, 16, 12),
132 new THREE.MeshBasicMaterial({ color: hit.exciter.kind === "stick" ? 0xffa030 : 0xff50d0 }));
133 marker.position.set(x, y, this.surfaceZ(x, y) + 0.012);
134 this.hitMarkers.add(marker);
135 }
137 const lp = run.config.listener.position;
138 this.listenerMarker = new THREE.Mesh(new THREE.SphereGeometry(0.02, 20, 14), new THREE.MeshStandardMaterial({ color: 0x4fc3f7 }));
139 this.listenerMarker.position.set(lp[0], lp[1], lp[2]);
140 this.world.add(this.listenerMarker);
142 this.applyPositions();
143 this.applyColors();
144 const zs = Array.from(m.z0);
145 this.controls.target.set(0, (Math.min(...zs) + Math.max(...zs)) / 2, 0);
146 const R = run.config.profile.radius;
147 this.camera.position.set(1.5 * R, 1.3 * R, 1.9 * R);
148 this.grid.position.y = -1.3 * R;
149 }
151 /** Rest height of the node nearest to (x, y), for placing markers on the surface. */
152 private surfaceZ(x: number, y: number): number {
153 const m = this.run!.mesh;
154 let best = 0, bd = Infinity;
155 for (let i = 0; i < m.nNodes; i++) {
156 const d = (m.xy[2 * i] - x) ** 2 + (m.xy[2 * i + 1] - y) ** 2;
157 if (d < bd) { bd = d; best = i; }
158 }
159 return m.z0[best];
160 }
162 setColorMode(mode: ColorMode) { this.mode = mode; this.applyColors(); }
163 setExaggeration(x: number) { this.exag = x; this.applyPositions(); }
165 /** One playback step: ``w`` deforms the surface (or null for rest), ``field`` tints it. */
166 setFrame(w: Float32Array | null, field: Float32Array | null, fieldScale: number) {
167 this.w = w; this.field = field; this.fieldScale = fieldScale > 0 ? fieldScale : 1;
168 this.applyPositions(); this.applyColors();
169 }
171 setOverlay(name: OverlayName, on: boolean) {
172 const o: Partial<Record<OverlayName, THREE.Object3D | null>> = {
173 wireframe: this.wireframe, boundary: this.boundary, normals: this.normalsLines,
174 hits: this.hitMarkers, listener: this.listenerMarker, grid: this.grid, axes: this.axes,
175 };
176 const obj = o[name];
177 if (obj) obj.visible = on;
178 }
180 private applyPositions() {
181 if (!this.run || !this.cymbal) return;
182 const m = this.run.mesh, N = m.nNodes;
183 const pos = this.cymbal.geometry.getAttribute("position") as THREE.BufferAttribute;
184 const a = pos.array as Float32Array;
185 for (let i = 0; i < N; i++) {
186 a[3 * i] = m.xy[2 * i]; a[3 * i + 1] = m.xy[2 * i + 1];
187 a[3 * i + 2] = m.z0[i] + (this.w ? this.exag * this.w[i] : 0);
188 }
189 pos.needsUpdate = true;
190 this.cymbal.geometry.computeVertexNormals();
191 this.cymbal.geometry.computeBoundingSphere();
192 if (this.boundary) {
193 const bp = this.boundary.geometry.getAttribute("position") as THREE.BufferAttribute, b = bp.array as Float32Array;
194 m.boundary.forEach((n, k) => b.set([a[3 * n], a[3 * n + 1], a[3 * n + 2]], 3 * k));
195 bp.needsUpdate = true;
196 }
197 if (this.probeNode !== null) {
198 this.probeMarker.position.set(a[3 * this.probeNode], a[3 * this.probeNode + 1], a[3 * this.probeNode + 2]);
199 }
200 }
202 private applyColors() {
203 if (!this.run || !this.cymbal) return;
204 const m = this.run.mesh, N = m.nNodes;
205 const attr = this.cymbal.geometry.getAttribute("color") as THREE.BufferAttribute;
206 const c = attr.array as Float32Array, base = new THREE.Color(), tmp = new THREE.Color();
207 let lo = 0, hi = 1;
208 if (this.mode === "thickness") { lo = Infinity; hi = -Infinity; for (const t of m.thickness) { lo = Math.min(lo, t); hi = Math.max(hi, t); } }
209 if (this.mode === "z0") { lo = Infinity; hi = -Infinity; for (const z of m.z0) { lo = Math.min(lo, z); hi = Math.max(hi, z); } }
210 const span = hi > lo ? hi - lo : 1;
211 for (let i = 0; i < N; i++) {
212 if (this.mode === "thickness") ramp((m.thickness[i] - lo) / span, base);
213 else if (this.mode === "z0") ramp((m.z0[i] - lo) / span, base);
214 else base.copy(BRONZE);
215 if (this.field) {
216 const t = Math.max(-1, Math.min(1, this.field[i] / this.fieldScale));
217 base.lerp(tmp.copy(t > 0 ? POS_TINT : NEG_TINT), Math.abs(t));
218 }
219 c[3 * i] = base.r; c[3 * i + 1] = base.g; c[3 * i + 2] = base.b;
220 }
221 attr.needsUpdate = true;
222 }
224 setListenerPosition(p: [number, number, number]) { this.listenerMarker?.position.set(p[0], p[1], p[2]); }
226 setProbe(node: number | null) {
227 this.probeNode = node;
228 this.probeMarker.visible = node !== null;
229 if (node !== null && this.cymbal) {
230 const a = (this.cymbal.geometry.getAttribute("position") as THREE.BufferAttribute).array as Float32Array;
231 this.probeMarker.position.set(a[3 * node], a[3 * node + 1], a[3 * node + 2]);
232 }
233 }
235 private pick(e: PointerEvent) {
236 if (!this.cymbal) return;
237 const rect = this.renderer.domElement.getBoundingClientRect();
238 const ndc = new THREE.Vector2(((e.clientX - rect.left) / rect.width) * 2 - 1, -((e.clientY - rect.top) / rect.height) * 2 + 1);
239 const ray = new THREE.Raycaster();
240 ray.setFromCamera(ndc, this.camera);
241 const hit = ray.intersectObject(this.cymbal, false)[0];
242 if (!hit || !hit.face) return;
243 const pos = this.cymbal.geometry.getAttribute("position") as THREE.BufferAttribute;
244 const v = new THREE.Vector3();
245 let best = hit.face.a, bd = Infinity;
246 for (const idx of [hit.face.a, hit.face.b, hit.face.c]) {
247 v.fromBufferAttribute(pos, idx);
248 this.cymbal.localToWorld(v);
249 const d = v.distanceToSquared(hit.point);
250 if (d < bd) { bd = d; best = idx; }
251 }
252 this.onPick(best);
253 }
254}