// Time integration of the bead system. // // The conservative part (springs, external force pulses) is integrated with // velocity Verlet, optionally composed into Yoshida's fourth-order scheme. // Damping (friction and dashpots) is linear in the velocities, dv/dt = -A v, // and is applied as a separate exact step v <- exp(-tau A) v, once per audio // sample (Strang splitting over the sample interval Delta = 1/fs). The // splitting error is second order in Delta and scales with the damping rate, // which is small compared with the oscillation frequencies; the conservative // part is second order (Verlet) or fourth order (Yoshida) in the step h. import { angleDiff, hingeAngle } from "./hinge"; import { prepare } from "./prepare"; import type { BeadSystem, Method, Modes, SimResult } from "./types"; const YOSHIDA_W1 = 1 / (2 - Math.cbrt(2)); const YOSHIDA_W0 = -Math.cbrt(2) * YOSHIDA_W1; export function stepCoefficients(method: Method): number[] { return method === "yoshida4" ? [YOSHIDA_W1, YOSHIDA_W0, YOSHIDA_W1] : [1]; } // Largest h*omega for which the method is stable on x'' = -omega^2 x. export function stabilityLimit(method: Method): number { if (method === "verlet") return 2; const cs = stepCoefficients(method); const stable = (h: number) => { // transfer matrix of one full step for omega = 1 let m = [1, 0, 0, 1]; // [x<-x, x<-v, v<-x, v<-v] const mul = (a: number[], b: number[]) => [ a[0] * b[0] + a[1] * b[2], a[0] * b[1] + a[1] * b[3], a[2] * b[0] + a[3] * b[2], a[2] * b[1] + a[3] * b[3], ]; for (const c of cs) { const kick = [1, 0, (-c * h) / 2, 1]; const drift = [1, c * h, 0, 1]; m = mul(kick, mul(drift, mul(kick, m))); } return Math.abs(m[0] + m[3]) < 2; }; let h = 1e-3; while (stable(h + 1e-3)) h += 1e-3; return h; } // Error of the numerical oscillation frequency for x'' = -omega^2 x, in cents // (positive: the simulated tone is sharp). export function frequencyErrorCents(method: Method, omega: number, h: number): number { let m = [1, 0, 0, 1]; const mul = (a: number[], b: number[]) => [ a[0] * b[0] + a[1] * b[2], a[0] * b[1] + a[1] * b[3], a[2] * b[0] + a[3] * b[2], a[2] * b[1] + a[3] * b[3], ]; for (const c of stepCoefficients(method)) { const kick = [1, 0, (-c * h * omega * omega) / 2, 1]; const drift = [1, c * h, 0, 1]; m = mul(kick, mul(drift, mul(kick, m))); } const half = (m[0] + m[3]) / 2; if (Math.abs(half) >= 1) return NaN; const omegaNum = Math.acos(half) / h; return 1200 * Math.log2(omegaNum / omega); } export interface SimOptions { modes?: Modes | null; onProgress?: (t: number) => void; // called at every frame record with the full-precision state (do not keep the arrays) onFrame?: (frame: number, t: number, x: Float64Array, v: Float64Array) => void; maxAnimBytes?: number; } export function simulate(sys: BeadSystem, opts: SimOptions = {}): SimResult { const t0 = performance.now(); const warnings: string[] = []; const { n, mass, pinned } = sys; const N3 = 3 * n; const x = new Float64Array(sys.pos0); const v = new Float64Array(N3); const F = new Float64Array(N3); const invm = new Float64Array(n); for (let i = 0; i < n; i++) invm[i] = pinned[i] ? 0 : 1 / mass[i]; // ---- forces ------------------------------------------------------------ const { si, sj, sk, sL0, sk3, bi, bj, bk, bkb, bcb, hi, hj, hk, hl, hkh, hch, hth0 } = sys; const nhg = hi.length; const hg = new Float64Array(12 * nhg); // hinge gradients, refreshed where needed const hb = (h: number) => [hi[h], hj[h], hk[h], hl[h]]; // the hinge's beads, in gradient order const ns = si.length; const nb = bi.length; const internalForces = (): number => { F.fill(0); let pe = 0; for (let s = 0; s < ns; s++) { const i3 = 3 * si[s]; const j3 = 3 * sj[s]; const dx = x[j3] - x[i3]; const dy = x[j3 + 1] - x[i3 + 1]; const dz = x[j3 + 2] - x[i3 + 2]; const L = Math.sqrt(dx * dx + dy * dy + dz * dz); const e = L - sL0[s]; const k = sk[s]; const k3 = sk3[s]; const e2 = e * e; pe += 0.5 * k * e2 + 0.25 * k3 * e2 * e2; const g = (k * e + k3 * e2 * e) / L; F[i3] += g * dx; F[i3 + 1] += g * dy; F[i3 + 2] += g * dz; F[j3] -= g * dx; F[j3 + 1] -= g * dy; F[j3 + 2] -= g * dz; } for (let b = 0; b < nb; b++) { const i3 = 3 * bi[b]; const j3 = 3 * bj[b]; const k3 = 3 * bk[b]; const kb = bkb[b]; const d0 = x[i3] - 2 * x[j3] + x[k3]; const d1 = x[i3 + 1] - 2 * x[j3 + 1] + x[k3 + 1]; const d2 = x[i3 + 2] - 2 * x[j3 + 2] + x[k3 + 2]; pe += 0.5 * kb * (d0 * d0 + d1 * d1 + d2 * d2); F[i3] -= kb * d0; F[i3 + 1] -= kb * d1; F[i3 + 2] -= kb * d2; F[j3] += 2 * kb * d0; F[j3 + 1] += 2 * kb * d1; F[j3 + 2] += 2 * kb * d2; F[k3] -= kb * d0; F[k3 + 1] -= kb * d1; F[k3 + 2] -= kb * d2; } for (let h = 0; h < nhg; h++) { const o = 12 * h; const d = angleDiff(hingeAngle(x, hi[h], hj[h], hk[h], hl[h], hg, o), hth0[h]); pe += 0.5 * hkh[h] * d * d; const s = hkh[h] * d; const i3 = 3 * hi[h], j3 = 3 * hj[h], k3 = 3 * hk[h], l3 = 3 * hl[h]; F[i3] -= s * hg[o]; F[i3 + 1] -= s * hg[o + 1]; F[i3 + 2] -= s * hg[o + 2]; F[j3] -= s * hg[o + 3]; F[j3 + 1] -= s * hg[o + 4]; F[j3 + 2] -= s * hg[o + 5]; F[k3] -= s * hg[o + 6]; F[k3 + 1] -= s * hg[o + 7]; F[k3 + 2] -= s * hg[o + 8]; F[l3] -= s * hg[o + 9]; F[l3 + 1] -= s * hg[o + 10]; F[l3 + 2] -= s * hg[o + 11]; } return pe; }; const pulses = sys.hits.filter(h => h.duration > 0); const externalForces = (t: number) => { for (const p of pulses) { const u = (t - p.t) / p.duration; if (u <= 0 || u >= 1) continue; const w = 0.5 * (1 - Math.cos(2 * Math.PI * u)); for (const b of p.beads) { F[3 * b] += w * p.force[0]; F[3 * b + 1] += w * p.force[1]; F[3 * b + 2] += w * p.force[2]; } } }; // ---- rest state checks and time step (shared with the GPU solver) ---------- let pe = internalForces(); const prep = prepare(sys); const { peRest, residualForce, maxTension, omegaMax, substeps, h, nSamples } = prep; warnings.push(...prep.warnings); const fs = sys.sampleRate; const nSteps = nSamples * substeps; const cs = stepCoefficients(sys.method); // ---- damping: v <- exp(-tau A) v ------------------------------------------ // dashpots with c = ca = 0 are dropped const keepD = Array.from(sys.dc.keys()).filter(d => sys.dc[d] !== 0 || sys.dca[d] !== 0); const di = Int32Array.from(keepD, d => sys.di[d]); const dj = Int32Array.from(keepD, d => sys.dj[d]); const dc = Float64Array.from(keepD, d => sys.dc[d]); const dca = Float64Array.from(keepD, d => sys.dca[d]); const axialDamped = dca.some(c => c > 0); const dd = new Float64Array(3 * dca.length); // dashpot directions, refreshed where needed const { friction } = sys; const nd = di.length; let normA = 0; // infinity-norm bound of A { const row = new Float64Array(n); for (let i = 0; i < n; i++) row[i] = friction[i]; for (let d = 0; d < nd; d++) { row[di[d]] += 2 * dc[d]; row[dj[d]] += 2 * dc[d]; row[di[d]] += 2 * dca[d]; row[dj[d]] += 2 * dca[d]; } for (let b = 0; b < nb; b++) { row[bi[b]] += 4 * bcb[b]; row[bj[b]] += 8 * bcb[b]; row[bk[b]] += 4 * bcb[b]; } for (let i = 0; i < n; i++) normA = Math.max(normA, row[i] * invm[i]); } const hingeDamped = hch.some(c => c > 0); const hasDamping = normA > 0 || hingeDamped; const frictionOnly = dc.every(c => c === 0) && !axialDamped && bcb.every(c => c === 0) && !hingeDamped; // then exp(-tau A) is diagonal // Hinge damping depends on the configuration through grad theta, so its // gradients (and its part of the norm bound) are refreshed at the current // positions before each damping step; positions do not change during it. let normAh = 0; const hingeRow = new Float64Array(n); const refreshHinges = () => { if (axialDamped) { for (let d = 0; d < dca.length; d++) { const i3 = 3 * di[d]; const j3 = 3 * dj[d]; const e0 = x[j3] - x[i3]; const e1 = x[j3 + 1] - x[i3 + 1]; const e2 = x[j3 + 2] - x[i3 + 2]; const L = Math.sqrt(e0 * e0 + e1 * e1 + e2 * e2) || 1; dd[3 * d] = e0 / L; dd[3 * d + 1] = e1 / L; dd[3 * d + 2] = e2 / L; } } if (!hingeDamped) return; hingeRow.fill(0); const row = hingeRow; for (let h = 0; h < nhg; h++) { const o = 12 * h; hingeAngle(x, hi[h], hj[h], hk[h], hl[h], hg, o); let total = 0; for (let q = 0; q < 12; q++) total += Math.abs(hg[o + q]); const c = hch[h] * total; row[hi[h]] += c * (Math.abs(hg[o]) + Math.abs(hg[o + 1]) + Math.abs(hg[o + 2])); row[hj[h]] += c * (Math.abs(hg[o + 3]) + Math.abs(hg[o + 4]) + Math.abs(hg[o + 5])); row[hk[h]] += c * (Math.abs(hg[o + 6]) + Math.abs(hg[o + 7]) + Math.abs(hg[o + 8])); row[hl[h]] += c * (Math.abs(hg[o + 9]) + Math.abs(hg[o + 10]) + Math.abs(hg[o + 11])); } normAh = 0; for (let i = 0; i < n; i++) normAh = Math.max(normAh, row[i] * invm[i]); }; const term = new Float64Array(N3); const tmp = new Float64Array(N3); const fa = new Float64Array(n); for (let i = 0; i < n; i++) fa[i] = friction[i] * invm[i]; const applyA = (w: Float64Array, out: Float64Array) => { for (let i = 0; i < n; i++) { const a = fa[i]; out[3 * i] = a * w[3 * i]; out[3 * i + 1] = a * w[3 * i + 1]; out[3 * i + 2] = a * w[3 * i + 2]; } for (let d = 0; d < nd; d++) { const i3 = 3 * di[d]; const j3 = 3 * dj[d]; const r0 = w[i3] - w[j3]; const r1 = w[i3 + 1] - w[j3 + 1]; const r2 = w[i3 + 2] - w[j3 + 2]; // force on i: -c r - ca (d . r) d, with r = w_i - w_j let f0 = dc[d] * r0; let f1 = dc[d] * r1; let f2 = dc[d] * r2; if (dca[d] !== 0) { const u0 = dd[3 * d], u1 = dd[3 * d + 1], u2 = dd[3 * d + 2]; const s = dca[d] * (u0 * r0 + u1 * r1 + u2 * r2); f0 += s * u0; f1 += s * u1; f2 += s * u2; } const mi = invm[di[d]]; const mj = invm[dj[d]]; out[i3] += mi * f0; out[i3 + 1] += mi * f1; out[i3 + 2] += mi * f2; out[j3] -= mj * f0; out[j3 + 1] -= mj * f1; out[j3 + 2] -= mj * f2; } for (let b = 0; b < nb; b++) { const cb = bcb[b]; if (cb === 0) continue; const i3 = 3 * bi[b]; const j3 = 3 * bj[b]; const k3 = 3 * bk[b]; const ci = cb * invm[bi[b]]; const cj = -2 * cb * invm[bj[b]]; const ck = cb * invm[bk[b]]; const r0 = w[i3] - 2 * w[j3] + w[k3]; const r1 = w[i3 + 1] - 2 * w[j3 + 1] + w[k3 + 1]; const r2 = w[i3 + 2] - 2 * w[j3 + 2] + w[k3 + 2]; out[i3] += ci * r0; out[i3 + 1] += ci * r1; out[i3 + 2] += ci * r2; out[j3] += cj * r0; out[j3 + 1] += cj * r1; out[j3 + 2] += cj * r2; out[k3] += ck * r0; out[k3 + 1] += ck * r1; out[k3 + 2] += ck * r2; } if (hingeDamped) { for (let h = 0; h < nhg; h++) { const ch = hch[h]; if (ch === 0) continue; const o = 12 * h; const i3 = 3 * hi[h], j3 = 3 * hj[h], k3 = 3 * hk[h], l3 = 3 * hl[h]; let s = hg[o] * w[i3] + hg[o + 1] * w[i3 + 1] + hg[o + 2] * w[i3 + 2] + hg[o + 3] * w[j3] + hg[o + 4] * w[j3 + 1] + hg[o + 5] * w[j3 + 2] + hg[o + 6] * w[k3] + hg[o + 7] * w[k3 + 1] + hg[o + 8] * w[k3 + 2] + hg[o + 9] * w[l3] + hg[o + 10] * w[l3 + 1] + hg[o + 11] * w[l3 + 2]; s *= ch; const ai = s * invm[hi[h]], aj = s * invm[hj[h]], ak = s * invm[hk[h]], al = s * invm[hl[h]]; out[i3] += ai * hg[o]; out[i3 + 1] += ai * hg[o + 1]; out[i3 + 2] += ai * hg[o + 2]; out[j3] += aj * hg[o + 3]; out[j3 + 1] += aj * hg[o + 4]; out[j3 + 2] += aj * hg[o + 5]; out[k3] += ak * hg[o + 6]; out[k3 + 1] += ak * hg[o + 7]; out[k3 + 2] += ak * hg[o + 8]; out[l3] += al * hg[o + 9]; out[l3 + 1] += al * hg[o + 10]; out[l3 + 2] += al * hg[o + 11]; } } }; const kinetic = () => { let ke = 0; for (let i = 0; i < n; i++) { ke += 0.5 * mass[i] * (v[3 * i] ** 2 + v[3 * i + 1] ** 2 + v[3 * i + 2] ** 2); } return ke; }; let dissipated = 0; const applyDamping = (tau: number) => { if (!hasDamping || tau === 0) return; const keBefore = kinetic(); if (frictionOnly) { for (let i = 0; i < n; i++) { const f = Math.exp(-tau * friction[i] * invm[i]); v[3 * i] *= f; v[3 * i + 1] *= f; v[3 * i + 2] *= f; } } else { // Taylor series with scaling; |tau_s| * normA <= 0.5 so terms shrink fast. refreshHinges(); const nsub = Math.max(1, Math.ceil((Math.abs(tau) * (normA + normAh)) / 0.5)); const ts = tau / nsub; for (let r = 0; r < nsub; r++) { let vmax = 0; for (let q = 0; q < N3; q++) { term[q] = v[q]; vmax = Math.max(vmax, Math.abs(v[q])); } if (vmax === 0) break; for (let k = 1; k < 40; k++) { applyA(term, tmp); const c = -ts / k; let tmax = 0; for (let q = 0; q < N3; q++) { const val = c * tmp[q]; term[q] = val; v[q] += val; const a = Math.abs(val); if (a > tmax) tmax = a; } if (tmax <= 1e-17 * vmax) break; } } } dissipated += keBefore - kinetic(); }; // ---- recording ------------------------------------------------------------- const audio = new Float32Array(nSamples); const lb = sys.listenBeads; const [ldx, ldy, ldz] = sys.listenDir; const pos0 = sys.pos0; // Acceleration is (F - damping force) / m on each listener bead, with F the // spring, bending and pulse forces at the current positions and time (the // force array is current at every sample boundary). The damping force uses // the dashpots and bending triplets that touch each listener bead. const touchD = Array.from(lb, b => Array.from(di.keys()).filter(d => di[d] === b || dj[d] === b)); const touchB = Array.from(lb, b => Array.from(bi.keys()).filter(t => (bcb[t] !== 0 && (bi[t] === b || bj[t] === b || bk[t] === b)))); const touchH = Array.from(lb, b => Array.from(hi.keys()).filter(h => hch[h] !== 0 && hb(h).includes(b))); const dampForce = (v: Float64Array, q: number, b: number, c: number) => { let f = -friction[b] * v[3 * b + c]; for (const d of touchD[q]) { const other = di[d] === b ? dj[d] : di[d]; f -= dc[d] * (v[3 * b + c] - v[3 * other + c]); if (dca[d] !== 0) { const rel = [0, 1, 2].reduce((acc, q) => acc + dd[3 * d + q] * (v[3 * b + q] - v[3 * other + q]), 0); f -= dca[d] * rel * dd[3 * d + c]; } } for (const t of touchB[q]) { const r = v[3 * bi[t] + c] - 2 * v[3 * bj[t] + c] + v[3 * bk[t] + c]; const w = bj[t] === b ? -2 : 1; f -= bcb[t] * w * r; } for (const h of touchH[q]) { const o = 12 * h; const beads = hb(h); let s = 0; for (let p = 0; p < 4; p++) { const b3 = 3 * beads[p]; s += hg[o + 3 * p] * v[b3] + hg[o + 3 * p + 1] * v[b3 + 1] + hg[o + 3 * p + 2] * v[b3 + 2]; } f -= hch[h] * s * hg[o + 3 * beads.indexOf(b) + c]; } return f; }; // At a sample boundary the damping for the time pendingD has not been // applied yet; vb = v - pendingD A v brings the velocities to the boundary // (to second order) for the listener's velocity and damping force. const vb = new Float64Array(N3); const Av = new Float64Array(N3); const listen = (pendingD: number) => { let s = 0; if (sys.listenQuantity !== "displacement") { refreshHinges(); // hinge damping gradients at the current positions if (pendingD !== 0 && hasDamping) { applyA(v, Av); for (let q = 0; q < N3; q++) vb[q] = v[q] - pendingD * Av[q]; } else vb.set(v); } lb.forEach((b, q) => { const dir = [ldx, ldy, ldz]; for (let c = 0; c < 3; c++) { if (dir[c] === 0) continue; let val: number; if (sys.listenQuantity === "displacement") val = x[3 * b + c] - pos0[3 * b + c]; else { if (sys.listenQuantity === "velocity") val = vb[3 * b + c]; else val = (F[3 * b + c] + dampForce(vb, q, b, c)) * invm[b]; } s += val * dir[c]; } }); return s / lb.length; }; const frameStride = Math.max(1, Math.round(fs / sys.frameRate)); const nFrames = Math.floor((nSamples - 1) / frameStride) + 1; const maxAnimBytes = opts.maxAnimBytes ?? 150e6; const animStride = frameStride * Math.max(1, Math.ceil((nFrames * N3 * 4) / maxAnimBytes)); const frameTimes = new Float64Array(nFrames); const energy = new Float64Array(nFrames); const dissipatedRec = new Float64Array(nFrames); const animTimes: number[] = []; const animFrames: Float32Array[] = []; const modes = opts.modes ?? null; const nModes = modes ? modes.freqs.length : 0; const modeEnergy = modes ? new Float32Array(nFrames * nModes) : null; let bz: Float64Array | null = null; let bv: Float64Array | null = null; let om2: Float64Array | null = null; if (modes) { bz = new Float64Array(modes.dof.length); bv = new Float64Array(modes.dof.length); om2 = new Float64Array(nModes); for (let k = 0; k < nModes; k++) om2[k] = (2 * Math.PI * modes.freqs[k]) ** 2; } const recordModes = (f: number) => { if (!modes || !modeEnergy || !bz || !bv || !om2) return; const dof = modes.dof; const nf = dof.length; for (let r = 0; r < nf; r++) { const q = dof[r]; const sm = Math.sqrt(mass[(q / 3) | 0]); bz[r] = sm * (x[q] - pos0[q]); bv[r] = sm * v[q]; } const psi = modes.psi; for (let k = 0; k < nModes; k++) { let q = 0; let qd = 0; const off = k * nf; for (let r = 0; r < nf; r++) { q += psi[off + r] * bz[r]; qd += psi[off + r] * bv[r]; } modeEnergy[f * nModes + k] = 0.5 * (qd * qd + om2[k] * q * q); } }; // ---- impulses, by audio sample ---------------------------------------------- const impulsesAt = new Map(); for (const hit of sys.hits) { if (hit.duration > 0) continue; const s = Math.round(hit.t * fs); if (s >= nSamples) continue; if (!impulsesAt.has(s)) impulsesAt.set(s, []); impulsesAt.get(s)!.push(hit); } const tExcitationEnd = Math.max(0, ...sys.hits.map(hh => hh.t + hh.duration)); // ---- main loop --------------------------------------------------------------- // Each audio sample interval Delta = substeps * h is one macro step: // D(Delta/2) [substeps conservative steps] D(Delta/2). The damping halves of // neighboring macro steps are merged unless something needs the velocities // at the boundary (a frame record or an impulse). const Delta = 1 / fs; pe = internalForces(); externalForces(0); let pendingD = 0; let aborted: string | null = null; let frame = 0; let lastProgress = performance.now(); for (let s = 0; s <= nSteps; s++) { const t = s * h; const sample = s % substeps === 0 ? s / substeps : -1; const isFrame = sample >= 0 && sample % frameStride === 0 && frame < nFrames; const imps = sample >= 0 ? impulsesAt.get(sample) : undefined; if ((isFrame || imps) && pendingD !== 0) { applyDamping(pendingD); pendingD = 0; } if (imps) { for (const hit of imps) { for (const b of hit.beads) { if (pinned[b]) continue; v[3 * b] += hit.velocity[0]; v[3 * b + 1] += hit.velocity[1]; v[3 * b + 2] += hit.velocity[2]; } } } if (sample >= 0 && sample < nSamples) audio[sample] = listen(pendingD); if (isFrame) { frameTimes[frame] = t; energy[frame] = kinetic() + pe - peRest; dissipatedRec[frame] = dissipated; recordModes(frame); opts.onFrame?.(frame, t, x, v); if (sample % animStride === 0) { const d = new Float32Array(N3); for (let q = 0; q < N3; q++) d[q] = x[q] - pos0[q]; animTimes.push(t); animFrames.push(d); } frame++; } if (s === nSteps) break; if (sample >= 0) { pendingD += Delta / 2; applyDamping(pendingD); pendingD = 0; } let tj = t; for (const c of cs) { const kh = (c * h) / 2; for (let i = 0; i < n; i++) { const im = invm[i] * kh; v[3 * i] += im * F[3 * i]; v[3 * i + 1] += im * F[3 * i + 1]; v[3 * i + 2] += im * F[3 * i + 2]; } const dh = c * h; for (let q = 0; q < N3; q++) x[q] += dh * v[q]; tj += c * h; pe = internalForces(); externalForces(tj); for (let i = 0; i < n; i++) { const im = invm[i] * kh; v[3 * i] += im * F[3 * i]; v[3 * i + 1] += im * F[3 * i + 1]; v[3 * i + 2] += im * F[3 * i + 2]; } } if ((s + 1) % substeps === 0) pendingD = Delta / 2; if ((s & 1023) === 0) { if (!Number.isFinite(pe)) { aborted = `The solution blew up at t = ${t.toFixed(4)} s. Reduce dt (or safety) or the strike strength.`; break; } const now = performance.now(); if (opts.onProgress && now - lastProgress > 200) { opts.onProgress(t); lastProgress = now; } } } // energy drift after the last excitation let energyDrift = 0; let ref = -1; for (let f = 0; f < frame; f++) { if (frameTimes[f] < tExcitationEnd) continue; const total = energy[f] + dissipatedRec[f]; if (ref < 0) ref = total; else if (ref > 0) energyDrift = Math.max(energyDrift, Math.abs(total - ref) / ref); } return { audio, sampleRate: fs, dt: h, substeps, omegaMax, frameTimes: frameTimes.slice(0, frame), energy: energy.slice(0, frame), dissipated: dissipatedRec.slice(0, frame), animTimes: Float64Array.from(animTimes), animFrames, modeEnergy: modeEnergy ? modeEnergy.slice(0, frame * nModes) : null, residualForce, maxTension, energyDrift, freqErrors: [1000, 2000, 5000, 10000, 20000] .filter(hz => hz < fs / 2) .map(hz => ({ hz, cents: frequencyErrorCents(sys.method, 2 * Math.PI * hz, h) })), elapsedMs: performance.now() - t0, aborted, warnings, }; } // Highest frequency (rad/s) of the system linearized about the rest positions, // by power iteration on M^{-1/2} H M^{-1/2}, with a small margin. export function estimateOmegaMax(sys: BeadSystem): number { const { n, si, sj, sk, sL0, sk3, pos0, mass, pinned } = sys; const ns = si.length; // per-spring 3x3 blocks B = keff d d^T + (f/L)(I - d d^T) const B = new Float64Array(ns * 6); for (let s = 0; s < ns; s++) { const i3 = 3 * si[s]; const j3 = 3 * sj[s]; const dx = pos0[j3] - pos0[i3]; const dy = pos0[j3 + 1] - pos0[i3 + 1]; const dz = pos0[j3 + 2] - pos0[i3 + 2]; const L = Math.sqrt(dx * dx + dy * dy + dz * dz); const e = L - sL0[s]; const keff = sk[s] + 3 * sk3[s] * e * e; const g = (sk[s] * e + sk3[s] * e * e * e) / L; const u = [dx / L, dy / L, dz / L]; const a = keff - g; B[6 * s] = a * u[0] * u[0] + g; B[6 * s + 1] = a * u[1] * u[1] + g; B[6 * s + 2] = a * u[2] * u[2] + g; B[6 * s + 3] = a * u[0] * u[1]; B[6 * s + 4] = a * u[0] * u[2]; B[6 * s + 5] = a * u[1] * u[2]; } const ism = new Float64Array(n); for (let i = 0; i < n; i++) ism[i] = pinned[i] ? 0 : 1 / Math.sqrt(mass[i]); // hinges at rest: stiffness kh grad(theta) grad(theta)^T (theta = theta0 there) const hg0 = new Float64Array(12 * sys.hi.length); for (let h = 0; h < sys.hi.length; h++) hingeAngle(pos0, sys.hi[h], sys.hj[h], sys.hk[h], sys.hl[h], hg0.subarray(12 * h, 12 * h + 12)); let w = new Float64Array(3 * n); let seed = 12345; for (let q = 0; q < 3 * n; q++) { seed = (seed * 1103515245 + 12345) & 0x7fffffff; w[q] = ism[Math.floor(q / 3)] * (seed / 0x7fffffff - 0.5); } const u = new Float64Array(3 * n); const y = new Float64Array(3 * n); let lambda = 0; for (let it = 0; it < 500; it++) { let norm = 0; for (let q = 0; q < 3 * n; q++) norm += w[q] * w[q]; norm = Math.sqrt(norm); if (norm === 0) return 1; for (let q = 0; q < 3 * n; q++) u[q] = (w[q] / norm) * ism[Math.floor(q / 3)]; y.fill(0); for (let s = 0; s < ns; s++) { const i3 = 3 * si[s]; const j3 = 3 * sj[s]; const r0 = u[i3] - u[j3]; const r1 = u[i3 + 1] - u[j3 + 1]; const r2 = u[i3 + 2] - u[j3 + 2]; const b = 6 * s; const f0 = B[b] * r0 + B[b + 3] * r1 + B[b + 4] * r2; const f1 = B[b + 3] * r0 + B[b + 1] * r1 + B[b + 5] * r2; const f2 = B[b + 4] * r0 + B[b + 5] * r1 + B[b + 2] * r2; y[i3] += f0; y[i3 + 1] += f1; y[i3 + 2] += f2; y[j3] -= f0; y[j3 + 1] -= f1; y[j3 + 2] -= f2; } for (let h = 0; h < sys.hi.length; h++) { const o = 12 * h; const beads = [sys.hi[h], sys.hj[h], sys.hk[h], sys.hl[h]]; let s = 0; for (let p = 0; p < 4; p++) for (let c = 0; c < 3; c++) s += hg0[o + 3 * p + c] * u[3 * beads[p] + c]; s *= sys.hkh[h]; for (let p = 0; p < 4; p++) for (let c = 0; c < 3; c++) y[3 * beads[p] + c] += s * hg0[o + 3 * p + c]; } for (let b = 0; b < sys.bi.length; b++) { const i3 = 3 * sys.bi[b]; const j3 = 3 * sys.bj[b]; const k3 = 3 * sys.bk[b]; const kb = sys.bkb[b]; for (let q = 0; q < 3; q++) { const r = kb * (u[i3 + q] - 2 * u[j3 + q] + u[k3 + q]); y[i3 + q] += r; y[j3 + q] -= 2 * r; y[k3 + q] += r; } } let rq = 0; const wNext = new Float64Array(3 * n); for (let q = 0; q < 3 * n; q++) { wNext[q] = y[q] * ism[Math.floor(q / 3)]; rq += (w[q] / norm) * wNext[q]; } w = wNext; if (it > 20 && Math.abs(rq - lambda) < 1e-8 * Math.abs(rq)) { lambda = rq; break; } lambda = rq; } return Math.sqrt(Math.max(lambda, 1e-300)) * 1.02; }