// The 3D scene: the cymbal built from the mesh in the .cymsim file (positions xy/z0, triangles), // deformed by a displacement frame and tinted by a per-node field during playback, with // toggleable overlays (wireframe, boundary loop, nodal normals, hit markers, listener, grid, // axes) and node picking for the probe. cymsim's frame is z-up; everything cymsim-space lives in // a group rotated so that z maps to three.js's y-up. import * as THREE from "three"; import { OrbitControls } from "three/addons/controls/OrbitControls.js"; import type { CymsimRun } from "./io/cymsimFile.ts"; export type ColorMode = "bronze" | "thickness" | "z0"; export type OverlayName = "wireframe" | "boundary" | "normals" | "hits" | "listener" | "grid" | "axes"; const BRONZE = new THREE.Color(0.72, 0.55, 0.28); const POS_TINT = new THREE.Color(1.0, 0.2, 0.15); // field > 0 const NEG_TINT = new THREE.Color(0.15, 0.35, 1.0); // field < 0 /** Small viridis-like ramp for scalar surface colors (thickness, rest height). */ function ramp(t: number, out: THREE.Color): THREE.Color { 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]]; const x = Math.min(0.9999, Math.max(0, t)) * (stops.length - 1), i = Math.floor(x), f = x - i; const a = stops[i], b = stops[i + 1]; return out.setRGB(a[0] + f * (b[0] - a[0]), a[1] + f * (b[1] - a[1]), a[2] + f * (b[2] - a[2])); } export class SceneView { private renderer: THREE.WebGLRenderer; private scene = new THREE.Scene(); private camera: THREE.PerspectiveCamera; private controls: OrbitControls; private world = new THREE.Group(); // cymsim frame (z up) private cymbal: THREE.Mesh | null = null; private wireframe: THREE.Mesh | null = null; private boundary: THREE.LineLoop | null = null; private normalsLines: THREE.LineSegments | null = null; private hitMarkers = new THREE.Group(); private listenerMarker: THREE.Object3D | null = null; private grid: THREE.GridHelper; private axes = new THREE.AxesHelper(0.3); private probeMarker: THREE.Mesh; private run: CymsimRun | null = null; private mode: ColorMode = "bronze"; private exag = 1; private w: Float32Array | null = null; // current displacement frame [m] private field: Float32Array | null = null; // current tint field private fieldScale = 1; private probeNode: number | null = null; private down: { x: number; y: number } | null = null; private onPick: (node: number) => void; constructor(canvas: HTMLCanvasElement, onPick: (node: number) => void) { this.onPick = onPick; this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true }); this.renderer.setPixelRatio(Math.min(2, window.devicePixelRatio)); this.camera = new THREE.PerspectiveCamera(45, 1, 0.001, 100); this.camera.position.set(0.35, 0.3, 0.42); this.controls = new OrbitControls(this.camera, canvas); this.controls.enableDamping = true; this.controls.target.set(0, 0.01, 0); this.scene.background = new THREE.Color(0x14161a); this.scene.add(new THREE.HemisphereLight(0xdde4f0, 0x30281c, 1.1)); const sun = new THREE.DirectionalLight(0xffffff, 1.6); sun.position.set(0.5, 1.0, 0.6); this.scene.add(sun); this.world.rotation.x = -Math.PI / 2; // cymsim z (up) -> three y (up) this.scene.add(this.world); this.world.add(this.hitMarkers); this.grid = new THREE.GridHelper(2, 20, 0x3a4150, 0x252a33); this.grid.position.y = -0.3; this.scene.add(this.grid); this.axes.visible = false; this.world.add(this.axes); this.probeMarker = new THREE.Mesh(new THREE.SphereGeometry(0.004, 12, 8), new THREE.MeshBasicMaterial({ color: 0x40ff90 })); this.probeMarker.visible = false; this.world.add(this.probeMarker); new ResizeObserver(() => this.resize()).observe(canvas.parentElement as HTMLElement); this.resize(); canvas.addEventListener("pointerdown", (e) => { this.down = { x: e.clientX, y: e.clientY }; }); canvas.addEventListener("pointerup", (e) => { if (this.down && Math.hypot(e.clientX - this.down.x, e.clientY - this.down.y) < 5) this.pick(e); this.down = null; }); this.renderer.setAnimationLoop(() => { this.controls.update(); this.renderer.render(this.scene, this.camera); }); } private resize() { const el = this.renderer.domElement.parentElement as HTMLElement; const w = Math.max(1, el.clientWidth), h = Math.max(1, el.clientHeight); this.renderer.setSize(w, h, false); this.camera.aspect = w / h; this.camera.updateProjectionMatrix(); } setRun(run: CymsimRun) { this.run = run; this.w = null; this.field = null; this.probeNode = null; this.probeMarker.visible = false; for (const o of [this.cymbal, this.wireframe, this.boundary, this.normalsLines, this.listenerMarker]) if (o) this.world.remove(o); this.hitMarkers.clear(); const m = run.mesh, N = m.nNodes; const geo = new THREE.BufferGeometry(); geo.setAttribute("position", new THREE.BufferAttribute(new Float32Array(3 * N), 3)); geo.setAttribute("color", new THREE.BufferAttribute(new Float32Array(3 * N), 3)); geo.setIndex(new THREE.BufferAttribute(Uint32Array.from(m.triangles), 1)); this.cymbal = new THREE.Mesh(geo, new THREE.MeshStandardMaterial({ vertexColors: true, metalness: 0.55, roughness: 0.4, side: THREE.DoubleSide, })); this.world.add(this.cymbal); this.wireframe = new THREE.Mesh(geo, new THREE.MeshBasicMaterial({ wireframe: true, color: 0x0e1013, transparent: true, opacity: 0.5 })); this.wireframe.visible = false; this.world.add(this.wireframe); const bGeo = new THREE.BufferGeometry(); bGeo.setAttribute("position", new THREE.BufferAttribute(new Float32Array(3 * m.boundary.length), 3)); this.boundary = new THREE.LineLoop(bGeo, new THREE.LineBasicMaterial({ color: 0xd9a441 })); this.world.add(this.boundary); const nGeo = new THREE.BufferGeometry(), quills = new Float32Array(6 * N), L = 0.006; for (let i = 0; i < N; i++) { const x = m.xy[2 * i], y = m.xy[2 * i + 1], z = m.z0[i]; 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); } nGeo.setAttribute("position", new THREE.BufferAttribute(quills, 3)); this.normalsLines = new THREE.LineSegments(nGeo, new THREE.LineBasicMaterial({ color: 0x4fc3f7, transparent: true, opacity: 0.7 })); this.normalsLines.visible = false; this.world.add(this.normalsLines); for (const hit of run.config.hits) { const x = hit.r * Math.cos(hit.phi), y = hit.r * Math.sin(hit.phi); const marker = new THREE.Mesh(new THREE.SphereGeometry(0.006, 16, 12), new THREE.MeshBasicMaterial({ color: hit.exciter.kind === "stick" ? 0xffa030 : 0xff50d0 })); marker.position.set(x, y, this.surfaceZ(x, y) + 0.012); this.hitMarkers.add(marker); } const lp = run.config.listener.position; this.listenerMarker = new THREE.Mesh(new THREE.SphereGeometry(0.02, 20, 14), new THREE.MeshStandardMaterial({ color: 0x4fc3f7 })); this.listenerMarker.position.set(lp[0], lp[1], lp[2]); this.world.add(this.listenerMarker); this.applyPositions(); this.applyColors(); const zs = Array.from(m.z0); this.controls.target.set(0, (Math.min(...zs) + Math.max(...zs)) / 2, 0); const R = run.config.profile.radius; this.camera.position.set(1.5 * R, 1.3 * R, 1.9 * R); this.grid.position.y = -1.3 * R; } /** Rest height of the node nearest to (x, y), for placing markers on the surface. */ private surfaceZ(x: number, y: number): number { const m = this.run!.mesh; let best = 0, bd = Infinity; for (let i = 0; i < m.nNodes; i++) { const d = (m.xy[2 * i] - x) ** 2 + (m.xy[2 * i + 1] - y) ** 2; if (d < bd) { bd = d; best = i; } } return m.z0[best]; } setColorMode(mode: ColorMode) { this.mode = mode; this.applyColors(); } setExaggeration(x: number) { this.exag = x; this.applyPositions(); } /** One playback step: ``w`` deforms the surface (or null for rest), ``field`` tints it. */ setFrame(w: Float32Array | null, field: Float32Array | null, fieldScale: number) { this.w = w; this.field = field; this.fieldScale = fieldScale > 0 ? fieldScale : 1; this.applyPositions(); this.applyColors(); } setOverlay(name: OverlayName, on: boolean) { const o: Partial> = { wireframe: this.wireframe, boundary: this.boundary, normals: this.normalsLines, hits: this.hitMarkers, listener: this.listenerMarker, grid: this.grid, axes: this.axes, }; const obj = o[name]; if (obj) obj.visible = on; } private applyPositions() { if (!this.run || !this.cymbal) return; const m = this.run.mesh, N = m.nNodes; const pos = this.cymbal.geometry.getAttribute("position") as THREE.BufferAttribute; const a = pos.array as Float32Array; for (let i = 0; i < N; i++) { a[3 * i] = m.xy[2 * i]; a[3 * i + 1] = m.xy[2 * i + 1]; a[3 * i + 2] = m.z0[i] + (this.w ? this.exag * this.w[i] : 0); } pos.needsUpdate = true; this.cymbal.geometry.computeVertexNormals(); this.cymbal.geometry.computeBoundingSphere(); if (this.boundary) { const bp = this.boundary.geometry.getAttribute("position") as THREE.BufferAttribute, b = bp.array as Float32Array; m.boundary.forEach((n, k) => b.set([a[3 * n], a[3 * n + 1], a[3 * n + 2]], 3 * k)); bp.needsUpdate = true; } if (this.probeNode !== null) { this.probeMarker.position.set(a[3 * this.probeNode], a[3 * this.probeNode + 1], a[3 * this.probeNode + 2]); } } private applyColors() { if (!this.run || !this.cymbal) return; const m = this.run.mesh, N = m.nNodes; const attr = this.cymbal.geometry.getAttribute("color") as THREE.BufferAttribute; const c = attr.array as Float32Array, base = new THREE.Color(), tmp = new THREE.Color(); let lo = 0, hi = 1; if (this.mode === "thickness") { lo = Infinity; hi = -Infinity; for (const t of m.thickness) { lo = Math.min(lo, t); hi = Math.max(hi, t); } } if (this.mode === "z0") { lo = Infinity; hi = -Infinity; for (const z of m.z0) { lo = Math.min(lo, z); hi = Math.max(hi, z); } } const span = hi > lo ? hi - lo : 1; for (let i = 0; i < N; i++) { if (this.mode === "thickness") ramp((m.thickness[i] - lo) / span, base); else if (this.mode === "z0") ramp((m.z0[i] - lo) / span, base); else base.copy(BRONZE); if (this.field) { const t = Math.max(-1, Math.min(1, this.field[i] / this.fieldScale)); base.lerp(tmp.copy(t > 0 ? POS_TINT : NEG_TINT), Math.abs(t)); } c[3 * i] = base.r; c[3 * i + 1] = base.g; c[3 * i + 2] = base.b; } attr.needsUpdate = true; } setListenerPosition(p: [number, number, number]) { this.listenerMarker?.position.set(p[0], p[1], p[2]); } setProbe(node: number | null) { this.probeNode = node; this.probeMarker.visible = node !== null; if (node !== null && this.cymbal) { const a = (this.cymbal.geometry.getAttribute("position") as THREE.BufferAttribute).array as Float32Array; this.probeMarker.position.set(a[3 * node], a[3 * node + 1], a[3 * node + 2]); } } private pick(e: PointerEvent) { if (!this.cymbal) return; const rect = this.renderer.domElement.getBoundingClientRect(); const ndc = new THREE.Vector2(((e.clientX - rect.left) / rect.width) * 2 - 1, -((e.clientY - rect.top) / rect.height) * 2 + 1); const ray = new THREE.Raycaster(); ray.setFromCamera(ndc, this.camera); const hit = ray.intersectObject(this.cymbal, false)[0]; if (!hit || !hit.face) return; const pos = this.cymbal.geometry.getAttribute("position") as THREE.BufferAttribute; const v = new THREE.Vector3(); let best = hit.face.a, bd = Infinity; for (const idx of [hit.face.a, hit.face.b, hit.face.c]) { v.fromBufferAttribute(pos, idx); this.cymbal.localToWorld(v); const d = v.distanceToSquared(hit.point); if (d < bd) { bd = d; best = idx; } } this.onPick(best); } }