Beam: free-free ends in the page (Ends 'free'; rigid part of strikes removed and reported; mode displays by Modes.count; bending offsets for ghost and Lyapunov shadow; drawing without supports)
6 changed files+84−36
src/beam/README.mdmodified+1−1View file
@@ -14,7 +14,7 @@ Live site: https://chaos-apps--listening-for-chaos.vault1-sites.magland.org/beam
1414 - SI units. Defaults m = 1 kg, k = 0, κ = 20 N/m, k3 = 4000 N/m^3, a = 0.5 m (drawing only), τ = 540 s, local stretching, clamped ends.
1515 - Resolution: the Parameters values are for the beam with 8 masses (REF_N); Masses (2 to 64) refines the same beam (`refine` in `beam.ts`): same length, rise, drag, strike energies; with spacing ratio r = span(8) / span(N) (span N + 1 spacings between two supports, N for a cantilever) m x r, k / r, κ / r^3, k3 / r^3, a x r, and the loss dashpots / r (τ top is converted so a wave of given length decays alike at any N). N = 8 returns the parameters unchanged (checked, all ends). The derived values show the scaled constants when N is not 8. Before this (to 2026-10-09) more masses made the beam longer at fixed spacing: mode 1 fell as ~1/N^2 (0.211 Hz at N = 8, 0.015 Hz at 32), so at fixed τ the note shrank (7.9 s at N = 8, 0.6 s at 32, 100 Hz pitch). Now (clamped, local, τ 540, τ top 50): f1 0.207 / 0.211 / 0.206 / 0.202 / 0.200 Hz at N = 4 / 8 / 16 / 32 / 64; f2/f1 2.24 / 2.59 / 2.71 / 2.75 / 2.754 and f3/f1 3.37 / 4.68 / 5.20 / 5.35 / 5.39 (beam theory 2.757, 5.404); pinned 3.998, 8.986 at 64 (4, 9); cantilever 6.265, 17.53 (6.27, 17.55); mode-1 decay ~375 s, mode 3 ~122 s; nonlinear scale 176 / 167 / 161 / 158 mJ at 8 / 16 / 32 / 64; default note 7.9 / 7.7 / 7.5 s. Cost: the step falls ~1/N^2 (0.58 ms at 8, 0.087 ms at 32 for 2.51 J), so the default note (with the Lyapunov shadow) took ~1 s to compute at N = 8, 8.2 s at 16 and 55 s at 32 in the pane at step 0.03; at 0.2, 0.3 s, 1.5 s and 7.9 s. Strike and listening are at positions (m), so they mean the same at any N. Default blow at 1.0 m, 2.51 J, clamped (mode-energy shares just after the strike, modes 1 / 2 / 3, and above mode 8; λ over 0 to 300 s with τ 540 s): N = 8 5.1 / 18.8 / 26.2 %, 0 %, λ 0.13 to 0.15 /s; N = 16 8.4 / 26.5 / 30.8 %, 2.7 %, 0.12 to 0.13; N = 32 8.4 / 26.2 / 30.6 %, 2.9 %, 0.068; N = 64 8.7 / 26.8 / 30.3 %, 2.8 %, 0.071. N = 8 differs because the bump covers a single mass there. The old blow on mass 2 (to 2026-10-09) moved up the spectrum with N: above mode 8 38 % at N = 16, 89 % at 32, 99 % at 64; λ 0.09, 0.024, 0.011.
1616 - Mallet (strike "mallet at"; `../common/mallet.ts`, beam only): mass M (default 1 kg) arriving with energy E presses into the beam through a Hertz contact, force K δ^1.5 (K default 1000 N/m^1.5), δ the mallet face's depth past the weighted mean displacement under the hammer bump (weights the bump normalized to sum 1; force distributed by the same weights). The contact phase is integrated at a fine step (bounded by the rigid-wall contact time / 400 and the struck masses' contact frequency) until lift-off; drag and loss are left out during it; the note and animation start at lift-off; the readout gives contact time, energy share delivered, peak force. Scan (2.51 J at 1.0 m, M = 1 kg): K 1e2 / 1e3 / 1e4 / 1e5: contact 470 / 150 / 55 / 22 ms (N = 8 and 32 alike), energy in 78-83 / 92-100 / 95-100 / 96-100 %; at N = 32 brightness (energy-weighted mode frequency / f1) just after lift-off 5.4 / 6.7 / 8.5 / 9.3 against 9.7 for the kick (the kick is the hard-mallet limit). Light mallets bounce off (M = 0.25 kg: 40 to 64 % in). In the sound at 2.51 J the difference is small: the chaotic phase redistributes the energy over the modes within a fraction of a second (spectral centroid at 1 s of sound, N = 32: kick 4.3 kHz, mallets 4.0 to 4.8 kHz; with τ top 200 s: kick 363 Hz, mallets 300 to 400 Hz).
17-- Free-free ends (physics in `beam.ts`, step 1 of 2, 2026-10-09; not yet in the Ends menu): no springs or joints beyond the end masses; masses at cell centres (j + 1/2) a, length N a (4 m at the defaults, against 4.5 m between two supports). Rigid motions (slide, tilt) are free, and the rails would make tension and stretching charge for a tilt, so energy and forces are evaluated at Pi u (the least-squares straight line removed) and the forces projected (`removeRigid`); the integrator re-projects u and p each step (removed energy into W). Modes leave out the two rigid ones (`Modes.count` = N - 2). Checked (Node): other ends bit-identical (48 cases); ratios at N = 64 2.755, 5.397, 8.911, 13.289 (free-free beam theory 2.757, 5.404, 8.933, 13.344); slide + tilt changes V by ~1e-15 relative; forces = -grad V to ~5e-11 with zero net force and torque; 200 T1 damped nonlinear runs keep momentum and angular momentum at round-off (1e-16, 1e-15), energy balance <= 8e-4. A kick at 1.0 m puts 22 % of its energy into rigid motion.
17+- Free-free ends ("free" in the Ends menu, 2026-10-09; at least 4 masses): no springs or joints beyond the end masses; masses at cell centres (j + 1/2) a, length N a (4 m at the defaults, against 4.5 m between two supports). Rigid motions (slide, tilt) are free, and the rails would make tension and stretching charge for a tilt, so energy and forces are evaluated at Pi u (the least-squares straight line removed) and the forces projected (`removeRigid`); the integrator re-projects u and p each step (removed energy into W). Modes leave out the two rigid ones (`Modes.count` = N - 2). Checked (Node): other ends bit-identical (48 cases); ratios at N = 64 2.755, 5.397, 8.911, 13.289 (free-free beam theory 2.757, 5.404, 8.933, 13.344); slide + tilt changes V by ~1e-15 relative; forces = -grad V to ~5e-11 with zero net force and torque; 200 T1 damped nonlinear runs keep momentum and angular momentum at round-off (1e-16, 1e-15), energy balance <= 8e-4. A kick at 1.0 m puts 22 % of its energy into rigid motion (Node, half-width 0.25 m); on the page (N = 16, default blow) 35 %, and the default mallet 37 % (55 % into vibration, 9 % leaves with the mallet). Page: strikes (kick, mallet) have their rigid part removed (`freeFrame`), with the shares in the readout; mode-based displays (bars, histories, energies, decay times, mode strike) use `Modes.count`; the ghost and the Lyapunov shadow start from a bending offset (`bendingUnit`: alternating signs, rigid part removed), since an equal offset is a slide; the drawing has masses at cell centres and no supports; the sum of accelerations is silent (no supports). First look (N = 16, local, mode-1 strike 2.51 J): only odd modes excited (symmetry), λ ~0.44 /s, ghost 1e-10 -> 2e-7 m in 16 s; nonlinear scale 16.5 mJ (clamped 167 mJ).
1818 - Masses on rails: only sideways motion u_j, so every axial force is taken by the rails and tension and stretching are defined at a free end too.
1919 - Potential: springs between neighbours (and to each support), k d^2 / 2 with d = u_j - u_{j-1}; bending κ c^2 / 2 at each mass with both neighbours, c = u_{j-1} - 2 u_j + u_{j+1} (supports at u = 0). Pinned support: no extra term (ghost u_{-2} = -u_0 gives c = 0 there). Clamped: a joint at the support with ghost u_{-2} = u_0, c = 2 u_0, energy 2 κ u_0^2. Free end: no spring or joint beyond the last mass.
2020 - Stretching: "local", k3 d^4 / 4 per spring (FPUT); or "Kirchhoff", k3 S^2 / (4 nS) with S the sum of d^2 over the nS springs, which acts as one tension k3 S / nS shared by all springs. They agree when all springs are stretched alike.
src/beam/beam.tsmodified+10−0View file
@@ -143,6 +143,16 @@ export function removeRigid(v: Float64Array): number {
143143 }
144144 return removed;
145145 }
146+// A unit vector that bends the beam (no rigid part): alternating signs with
147+// the slide and tilt removed. For offsets that must not be projected away
148+// (ghost, Lyapunov shadow) on a free-free beam.
149+export function bendingUnit(N: number): Float64Array {
150+ const v = Float64Array.from({ length: N }, (_, j) => (j % 2 ? -1 : 1));
151+ removeRigid(v);
152+ const n = Math.hypot(...v);
153+ return v.map((x) => x / n);
154+}
155+
146156 // Scratch for the projected displacements (forces runs every step).
147157 let rigidScratch = new Float64Array(0);
148158 function deformed(P: Params, u: Float64Array): Float64Array {
src/beam/index.htmlmodified+2−1View file
@@ -38,6 +38,7 @@
3838 <option value="clamped" selected title="clamped at both ends">clamped</option>
3939 <option value="pinned" title="pinned at both ends">pinned</option>
4040 <option value="cantilever" title="clamped at the left, free at the right">cantilever</option>
41+ <option value="free" title="free at both ends (rigid motion removed)">free</option>
4142 </select>
4243 <span class="muted" style="margin-left: 12px">Speed</span>
4344 <select id="speed">
@@ -141,7 +142,7 @@
141142 <template id="help-beam">
142143 <h2>The beam</h2>
143144 <p>Equal masses on vertical rails, moving sideways only, shown in real time (displacements scaled to fit). Neighbours are joined by springs (tension k), and each mass resists bending (κ, on how far it sits off the line through its neighbours). The springs also stiffen as they stretch (k3): this is the nonlinearity. Strike gives the energy on the slider as a hammer blow at a point (m from the left support; a short triangular bump of velocity, one spacing of the 8-mass beam wide on each side, so the blow is the same at any number of masses), with a mallet at a point, or in the shape of one normal mode. A mallet (mass M, contact stiffness K in Parameters) arrives with the strike energy and presses into the beam over the same bump through a Hertz contact (force K δ^1.5), then lifts off; the readout gives the contact time, the share of the energy that went into the beam, and the peak force. The motion is shown from lift-off.</p>
144- <p>The ends are clamped (fixed, and held level), pinned (fixed, free to turn), or a cantilever: clamped at the left and free at the right. Bending makes the overtones inharmonic, as in a bar or a cymbal. Weak strikes ring as independent normal modes; strong ones trade energy between them. Ghost adds a second beam started a tiny distance away, drawn in orange: in chaotic motion the two separate exponentially. The space bar pauses.</p>
145+ <p>The ends are clamped (fixed, and held level), pinned (fixed, free to turn), a cantilever (clamped at the left and free at the right), or free at both ends. A free beam would also slide and tilt as a whole; that motion is removed (the beam is followed in its own frame), the readout says how much of a strike's energy it took, and the modes listed are the bending ones (two fewer than the masses; at least 4 masses). The sum of accelerations is silent for a free beam (nothing holds it). Bending makes the overtones inharmonic, as in a bar or a cymbal. Weak strikes ring as independent normal modes; strong ones trade energy between them. Ghost adds a second beam started a tiny distance away, drawn in orange: in chaotic motion the two separate exponentially. The space bar pauses.</p>
145146 </template>
146147 <template id="help-params">
147148 <h2>Parameters</h2>
src/beam/lyapunov.tsmodified+15−6View file
@@ -7,18 +7,27 @@
77 // momentum of mode 1 at that size), so both count comparably.
88
99 import { D0, type Shadow } from "../common/lyapunov";
10-import { Verlet, copyState, nonlinearAmplitude, type Modes, type Params, type State } from "./beam";
10+import { Verlet, bendingUnit, copyState, nonlinearAmplitude, type Modes, type Params, type State } from "./beam";
1111
1212 // A shadow of the beam's state s, for Lyapunov.start. s is read at every
1313 // step, so it must stay the beam's state object until the next start.
1414 export function shadowOf(P: Params, modes: Modes, s: State): Shadow {
1515 const ur = nonlinearAmplitude(P, modes) / Math.sqrt(P.N), pr = P.m * modes.omega[0] * ur;
1616 const sh = copyState(s);
17- // Initial separation: equal parts in all 2N coordinates.
18- const e = D0 / Math.sqrt(2 * P.N);
19- for (let j = 0; j < P.N; j++) {
20- sh.u[j] += e * ur;
21- sh.p[j] += e * pr;
17+ // Initial separation: equal parts in all 2N coordinates (free-free: a
18+ // bending pattern, since equal parts would be a rigid slide, projected away).
19+ if (P.ends === "free") {
20+ const b = bendingUnit(P.N), e = D0 / Math.SQRT2;
21+ for (let j = 0; j < P.N; j++) {
22+ sh.u[j] += e * b[j] * ur;
23+ sh.p[j] += e * b[j] * pr;
24+ }
25+ } else {
26+ const e = D0 / Math.sqrt(2 * P.N);
27+ for (let j = 0; j < P.N; j++) {
28+ sh.u[j] += e * ur;
29+ sh.p[j] += e * pr;
30+ }
2231 }
2332 const verlet = new Verlet(P, sh);
2433
src/beam/main.tsmodified+54−26View file
@@ -1,7 +1,7 @@
11 import "../common/style.css";
22 import "./style.css";
33 import {
4- Modes, REF_N, Verlet, beamLength, copyState, decayTimes, energy, forces, hammerShape, isDamped, newState, nonlinearScale, refine, restShape, stepFor, strikeAt, strikeMode,
4+ Modes, REF_N, Verlet, beamLength, bendingUnit, copyState, decayTimes, energy, forces, hammerShape, isDamped, newState, nonlinearScale, refine, removeRigid, restShape, stepFor, strikeAt, strikeMode,
55 type Ends, type Params, type State, type Stretch,
66 } from "./beam";
77 import { fit, indexColor } from "../common/canvas";
@@ -85,7 +85,7 @@ document.addEventListener("keydown", (e) => {
8585 setRunning(!running);
8686 });
8787
88-const strikeIndex = () => Math.min(P.N, Math.max(1, Math.round(Number(indexInput.value) || 1)));
88+const strikeIndex = () => Math.min(modes.count, Math.max(1, Math.round(Number(indexInput.value) || 1)));
8989 // A position along the beam (m from the left support), kept on the beam.
9090 const position = (el: HTMLInputElement) => Math.min(beamLength(P), Math.max(0, Number(el.value) || 0));
9191 // Strike in a mode (index) or at a point (position).
@@ -97,6 +97,13 @@ const showStrikeKind = () => {
9797 kindSelect.addEventListener("change", showStrikeKind);
9898 showStrikeKind();
9999
100+// Free-free: follow the beam in its own frame, without the slide and tilt a
101+// strike gives it.
102+function freeFrame(st: State) {
103+ removeRigid(st.u);
104+ removeRigid(st.p);
105+}
106+
100107 // label names the strike in saved files.
101108 let strikeLabel = "";
102109 document.getElementById("strike")!.addEventListener("click", () => {
@@ -110,7 +117,13 @@ document.getElementById("strike")!.addEventListener("click", () => {
110117 // the blow is the same at any N (at REF_N, on a mass, only that mass moves).
111118 strikeInfo.textContent = "";
112119 if (mode) s = strikeMode(P, modes, strikeIndex(), E);
113- else if (kindSelect.value === "at") s = strikeAt(P, x, base.a, E);
120+ else if (kindSelect.value === "at") {
121+ s = strikeAt(P, x, base.a, E);
122+ if (P.ends === "free") {
123+ freeFrame(s);
124+ strikeInfo.textContent = `kick: ${(100 * (1 - energy(P, s) / E)).toFixed(0)}% of the energy went into sliding and tilting the beam (removed)`;
125+ }
126+ }
114127 else {
115128 // The mallet's face presses on the same bump (as a weighted mean of the
116129 // displacements under it); the note starts at lift-off.
@@ -120,10 +133,14 @@ document.getElementById("strike")!.addEventListener("click", () => {
120133 mallet, E,
121134 );
122135 s = { u: r.u, p: r.p, W: 0 };
123- const share = energy(P, s) / E;
136+ const whole = energy(P, s) / E; // including any rigid motion
137+ if (P.ends === "free") freeFrame(s);
138+ const share = energy(P, s) / E, rigid = whole - share;
124139 strikeInfo.textContent =
125- `mallet: contact ${(1000 * r.contactTime).toPrecision(3)} ms, ${(100 * share).toFixed(0)}% of the energy into the beam` +
126- `${share < 0.995 ? " (the rest leaves with the mallet)" : ""}, peak force ${r.peakForce.toPrecision(3)} N; shown from lift-off`;
140+ `mallet: contact ${(1000 * r.contactTime).toPrecision(3)} ms, ${(100 * share).toFixed(0)}% of the energy into the beam's vibration` +
141+ `${whole < 0.995 ? `, ${(100 * (1 - whole)).toFixed(0)}% leaves with the mallet` : ""}` +
142+ `${rigid > 0.005 ? `, ${(100 * rigid).toFixed(0)}% into sliding and tilting the beam (removed)` : ""}` +
143+ `; peak force ${r.peakForce.toPrecision(3)} N; shown from lift-off`;
127144 }
128145 lastStrike = copyState(s);
129146 verlet = new Verlet(P, s);
@@ -137,7 +154,7 @@ document.getElementById("strike")!.addEventListener("click", () => {
137154 modes.energies(s, Ek);
138155 traces.clear();
139156 traces.sample(t);
140- liveModes.clear(P.N);
157+ liveModes.clear(modes.count);
141158 liveModes.sample(t, Ek);
142159 replayButton.disabled = false;
143160 renderNote();
@@ -168,8 +185,14 @@ function resetGhost() {
168185 ghost = null;
169186 if (!ghostUI.on || energy(P, s) === 0) return;
170187 const g = copyState(s);
171- const each = ghostUI.offset / Math.sqrt(P.N);
172- for (let j = 0; j < P.N; j++) g.u[j] += each;
188+ if (P.ends === "free") {
189+ // A bending offset: an equal one would be a rigid slide, projected away.
190+ const b = bendingUnit(P.N);
191+ for (let j = 0; j < P.N; j++) g.u[j] += ghostUI.offset * b[j];
192+ } else {
193+ const each = ghostUI.offset / Math.sqrt(P.N);
194+ for (let j = 0; j < P.N; j++) g.u[j] += each;
195+ }
173196 ghost = { s: g, verlet: new Verlet(P, g) };
174197 ghostT0 = t;
175198 }
@@ -204,7 +227,7 @@ function buildTraces(shown: string[]) {
204227 {
205228 // Energies far below the strike's (round-off level) are left out.
206229 label: "Mode energies", unit: "J", log: true, window: 60, color: indexColor,
207- series: Array.from({ length: P.N }, (_, n) => ({
230+ series: Array.from({ length: modes.count }, (_, n) => ({
208231 label: `E${n + 1}`, value: () => (Ek[n] > 1e-12 * E0 ? Ek[n] : NaN),
209232 })),
210233 },
@@ -224,11 +247,11 @@ const barsFig = document.getElementById("bars")!;
224247 barsToggle.addEventListener("change", () => (barsFig.hidden = !barsToggle.checked));
225248 function drawModes() {
226249 if (E0 > 0) modes.energies(s, Ek);
227- const shared = drawModeBars(modesCanvas, E0 > 0 ? Ek : null, E0, P.N);
250+ const shared = drawModeBars(modesCanvas, E0 > 0 ? Ek : null, E0, modes.count);
228251 const hz = (w: number) => (w / (2 * Math.PI)).toPrecision(3);
229252 modeStats.textContent =
230253 (shared > 0 ? `modes sharing the energy ${shared.toFixed(1)} · ` : "") +
231- `modes ${hz(modes.omega[0])} … ${hz(modes.omega[P.N - 1])} Hz`;
254+ `modes ${hz(modes.omega[0])} … ${hz(modes.omega[modes.count - 1])} Hz`;
232255 }
233256 // Mode history (chip after Mode bars): each mode's share of the energy over
234257 // the last 300 s, as a heat map.
@@ -269,19 +292,21 @@ function decayText(): string {
269292 if (!damped()) return "none (no damping)";
270293 if (P.tauTop === 0) return `${P.tau} s (amplitude, all modes)`;
271294 const d = decayTimes(P, modes), f = (x: number) => fmtDuration(x);
272- const list = P.N <= 8 ? Array.from(d, f).join(", ") : `${f(d[0])} … ${f(d[P.N - 1])}`;
295+ const nm = modes.count;
296+ const list = nm <= 8 ? Array.from(d, f).join(", ") : `${f(d[0])} … ${f(d[nm - 1])}`;
273297 return `${list} (amplitude, by mode)`;
274298 }
275299 // Values that follow from the parameters (and, for the step, the last strike).
276300 function showDerived() {
277301 const hz = Array.from(modes.omega, (w) => w / (2 * Math.PI));
278- const list = P.N <= 8 ? hz.map((f) => f.toPrecision(3)).join(", ") : `${hz[0].toPrecision(3)} … ${hz[P.N - 1].toPrecision(3)}`;
302+ const nm = modes.count;
303+ const list = nm <= 8 ? hz.map((f) => f.toPrecision(3)).join(", ") : `${hz[0].toPrecision(3)} … ${hz[nm - 1].toPrecision(3)}`;
279304 const ratios = hz.slice(1, 6).map((f) => (f / hz[0]).toFixed(2)).join(", ");
280305 const g = (x: number) => (x === 0 ? "0" : x.toPrecision(4));
281306 document.getElementById("params")!.textContent =
282307 (P.N === REF_N ? "" : `${`at ${P.N} masses`.padEnd(18)}m ${g(P.m)} kg, k ${g(P.k)}, κ ${g(P.kappa)} N/m, k3 ${g(P.k3)} N/m³, a ${g(P.a)} m\n`) +
283308 `normal modes ${list} Hz\n` +
284- `ratios to mode 1 ${ratios}${P.N > 6 ? ", …" : ""}; top ${(hz[P.N - 1] / hz[0]).toFixed(1)}\n` +
309+ `ratios to mode 1 ${ratios}${nm > 6 ? ", …" : ""}; top ${(hz[nm - 1] / hz[0]).toFixed(1)}\n` +
285310 `nonlinear scale ${fmtEnergy(nonlinearScale(P, modes))} (mode 1: stretching = linear energy)\n` +
286311 `decay time ${decayText()}\n` +
287312 `integrator step ${(1000 * H).toPrecision(3)} ms (from the strike energy; ` +
@@ -454,7 +479,7 @@ worker.onmessage = (e: MessageEvent<RenderResult>) => {
454479 const rate = player.sampleRate;
455480 const x = e.data.samples;
456481 Player.normalize(x, rate);
457- note = { x, energy: e.data.energy, lambda: e.data.lambda, modeEnergy: e.data.modeEnergy, N: P.N, rate };
482+ note = { x, energy: e.data.energy, lambda: e.data.lambda, modeEnergy: e.data.modeEnergy, N: modes.count, rate };
458483 fed = 0;
459484 const span = x.length / rate;
460485 // Spectrogram window: 2048 samples down to a lowest-mode pitch of 30 Hz;
@@ -465,7 +490,7 @@ worker.onmessage = (e: MessageEvent<RenderResult>) => {
465490 soundCursor,
466491 );
467492 spec.start(span);
468- modeHistory.start(P.N, span);
493+ modeHistory.start(modes.count, span);
469494 document.getElementById("spectrogram")!.replaceChildren(spec.canvas);
470495 soundTraces.clear();
471496 soundTraces.setFixed({ t0: 0, span });
@@ -483,7 +508,7 @@ replayButton.addEventListener("click", renderNote);
483508 function setParams(next: Params, N: number) {
484509 const shown = traces ? traces.shownLabels() : ["Lyapunov"];
485510 base = next;
486- P = refine(base, N);
511+ P = refine(base, next.ends === "free" ? Math.max(4, N) : N); // free-free: at least 2 bending modes
487512 modes = new Modes(P);
488513 H = stepFor(P, modes, 0);
489514 s = newState(P.N);
@@ -493,11 +518,11 @@ function setParams(next: Params, N: number) {
493518 ampl = 0;
494519 lyap.stop();
495520 ghost = null;
496- Ek = new Float64Array(P.N);
521+ Ek = new Float64Array(modes.count);
497522 buildTraces(shown);
498- liveModes.clear(P.N);
523+ liveModes.clear(modes.count);
499524 nInput.value = String(P.N);
500- indexInput.max = String(P.N);
525+ indexInput.max = String(modes.count);
501526 indexInput.value = String(strikeIndex());
502527 pauseButton.disabled = true;
503528 setRunning(false);
@@ -554,9 +579,10 @@ function drawBeam() {
554579 const W = beamCanvas.clientWidth, Ht = beamCanvas.clientHeight;
555580 ctx.clearRect(0, 0, W, Ht);
556581 const { N } = P;
557- const free = P.ends === "cantilever";
582+ const free = P.ends === "cantilever" || P.ends === "free", freeL = P.ends === "free";
558583 const m = 28, dx = (W - 2 * m) / (free ? N : N + 1);
559- const x = (i: number) => m + (i + 1) * dx; // node i: masses 0..N-1, supports -1 and N
584+ // Node i: masses 0..N-1, supports -1 and N (free-free: masses at cell centres).
585+ const x = (i: number) => m + (i + (freeL ? 0.5 : 1)) * dx;
560586 const top = 34, bottom = Ht - 14, y0 = (top + bottom) / 2, half = 0.45 * (bottom - top);
561587 const scale = ampl > 0 ? half / ampl : 0; // px per m
562588
@@ -570,7 +596,8 @@ function drawBeam() {
570596 ctx.moveTo(x(j), top);
571597 ctx.lineTo(x(j), bottom);
572598 }
573- ctx.moveTo(m, y0);
599+ if (freeL) ctx.moveTo(x(0), y0 - scale * w0[0]);
600+ else ctx.moveTo(m, y0);
574601 for (let j = 0; j < N; j++) ctx.lineTo(x(j), y0 - scale * w0[j]);
575602 if (!free) ctx.lineTo(W - m, y0);
576603 ctx.stroke();
@@ -603,7 +630,7 @@ function drawBeam() {
603630 if (P.ends === "pinned") {
604631 pin(x(-1));
605632 pin(x(N));
606- } else {
633+ } else if (!freeL) {
607634 clamp(x(-1), -1);
608635 if (P.ends === "clamped") clamp(x(N), 1);
609636 }
@@ -616,7 +643,8 @@ function drawBeam() {
616643 ctx.lineWidth = 2.5;
617644 ctx.lineJoin = "round";
618645 ctx.beginPath();
619- ctx.moveTo(x(-1), y0);
646+ if (freeL) ctx.moveTo(x(0), y(0));
647+ else ctx.moveTo(x(-1), y0);
620648 for (let j = 0; j < N; j++) ctx.lineTo(x(j), y(j));
621649 if (!free) ctx.lineTo(x(N), y0);
622650 ctx.stroke();
src/beam/render-worker.tsmodified+2−2View file
@@ -31,7 +31,7 @@ self.onmessage = (e: MessageEvent<RenderRequest>) => {
3131 const modes = new Modes(P);
3232 const verlet = new Verlet(P, s);
3333 const signal = signalByKey(r.signal);
34- const Ek = new Float64Array(P.N);
34+ const Ek = new Float64Array(modes.count);
3535 const modeE: number[] = [];
3636 const note = renderNote(
3737 {
@@ -47,7 +47,7 @@ self.onmessage = (e: MessageEvent<RenderRequest>) => {
4747 },
4848 { speedup: r.speedup, sampleRate: r.sampleRate, damped: isDamped(P), maxLength: r.maxLength, lyapunov: r.lyapunov },
4949 );
50- const result: RenderResult = { id: r.id, ...note, modeEnergy: Float32Array.from(modeE.slice(0, note.energy.length * P.N)) };
50+ const result: RenderResult = { id: r.id, ...note, modeEnergy: Float32Array.from(modeE.slice(0, note.energy.length * modes.count)) };
5151 (self as unknown as Worker).postMessage(result, [
5252 result.samples.buffer, result.energy.buffer, result.lambda.buffer, result.modeEnergy.buffer,
5353 ]);