Single excitation panel; rigid-body projection optional while ringing
The Excitation panel selects the type at the top and shows only that
type's parameters. The drumstick force is now balanced at every step by
the inertia-relief acceleration field about the center of the contact
patch, so both excitations are self-equilibrated and the periodic
projection of the rigid-body modes during the ring only removes rounding
drift; it is a Numerics option, off by default. The projection is still
applied every four steps while the sheet is held, where it defines the
holding.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
9 changed files+95−61
README.mdmodified+1−0View file
@@ -21,6 +21,7 @@ Press Start. With the push excitation the sheet is pushed at the bottom-right co
2121
2222 - The sheet is a rectangle by default; the Outline selector makes it the ellipse inscribed in the given width and height. Width and height are entered in inches by default, or in millimeters; everything else is metric. Internally all values are SI.
2323 - The sheet has a stress-free bow (default 3 mm across the width; set the bow shape to Flat for the ideal plate). A negative push amount pushes against the bow.
24+- The Excitation panel shows the parameters of the selected excitation type only. Both excitations are self-equilibrated by inertia relief (the sheet gains no net momentum, as if the thread took it up); the periodic projection of the rigid-body modes during the ring is a Numerics option that is off by default and only removes rounding drift.
2425 - Sheet, initial shape, excitation, and numerics parameters take effect at the next Start. Losses and the listener position can be changed while a simulation runs.
2526 - Pause halts the run temporarily; Stop ends it, keeps the audio and movie, and unlocks the parameter fields. To extend a finished run, raise the duration and press Continue; the state is kept on the GPU.
2627 - Playback level: Absolute maps 1 Pa to full scale, shifted by the gain slider, so different runs can be compared by ear (the peak level in dBFS is shown, with a clipping warning); Loudness and Peak normalize each run instead. The WAV download uses the same setting.
THEORY.mdmodified+4−4View file
@@ -36,15 +36,15 @@ with the bending stiffness $D = Eh^3/(12(1-\nu^2))$. Bending acts on the deviati
3636
3737 The whole edge is free: the bending moment and the Kirchhoff shear vanish, and so does the in-plane traction. Rather than imposing these conditions explicitly, the discretization of Section 4 is derived from a discrete energy, and free edges are then its natural boundary conditions.
3838
39-The thread through the hole near one corner is not modeled as a constraint. At audio frequencies it is a very soft spring compared with the local stiffness of the plate, so it does not pin the hole; it only fixes the average position of the plate. We therefore treat the plate as free and remove the six rigid-body modes (three translations, three rotations) from the displacement and velocity fields by a mass-weighted projection every few time steps. Gravity is dropped: the in-plane stress it induces is about $\rho g L \approx 35$ kPa, a strain of $2\times10^{-7}$.
39+The thread through the hole near one corner is not modeled as a constraint. At audio frequencies it is a very soft spring compared with the local stiffness of the plate, so it does not pin the hole; it only fixes the average position of the plate. We therefore treat the plate as free, and we make every external load self-equilibrated: the reaction to the finger or the stick is distributed over the plate as the inertial load of a rigid body, which is the inertia-relief equilibrium of a free body. The sheet then gains no net momentum or angular momentum, which stands in for the thread taking them up. This is a modeling choice: a sheet hanging freely from a thread and pushed 10 mm at a corner would mostly swing as a pendulum and bend by only a millimeter or two, and a struck sheet would recoil and swing. Gravity is dropped: the in-plane stress it induces is about $\rho g L \approx 35$ kPa, a strain of $2\times10^{-7}$.
4040
41-The same projection defines what holding the sheet means during a push. The finger prescribes the displacement of a small patch, and the reaction to the finger force is distributed over the plate as an inertial load, which is the inertia-relief equilibrium of a free body, so the push produces pure bending of the prescribed amount. This is a modeling choice: a sheet hanging freely from a thread and pushed 10 mm at a corner would mostly swing as a pendulum and bend by only a millimeter or two. The same choice applies to the drumstick, whose force would otherwise set the whole sheet swinging.
41+Inertia relief takes two forms here. During a push the finger prescribes the displacement of a small patch, and the reaction is not known in advance, so the six rigid-body modes (three translations, three rotations) are removed from the displacement and velocity fields by a mass-weighted projection every four time steps while the sheet is held; the push therefore produces pure bending of the prescribed amount. The drumstick force is known at every step, so its reaction is applied directly as the rigid acceleration field that balances the force and its moments about the center (Section 3). Once the sheet rings freely, no load remains that could move it as a whole, apart from rounding and the small non-invariance of the von Karman energy under finite rotation, and the projection is optional there and off by default. Note that a rigid rotation is not exactly a null mode of the von Karman energy: the strains contain $\tfrac12 w_x^2$, so a rotation $\theta$ about an in-plane axis produces a spurious strain $\theta^2/2$. This is the reason the loads have to be self-equilibrated, since a slow drift in rotation would otherwise stress the sheet.
4242
4343 ## 3. Excitation
4444
4545 **Push and release.** The simulation has three phases. During the push (default 0.1 s) the transverse displacement of the nodes within the finger radius follows a smooth ramp to the push amount, and heavy artificial damping ($\sigma_0 = 300\ \mathrm{s^{-1}}$) removes the waves that the ramp launches. During the hold the constraint stays in place until the non-rigid kinetic energy drops below a threshold or a maximum settle time elapses. At the release the clock is set to zero, the damping returns to its physical value, and the recording of audio and movie frames begins. The finger does not let go instantaneously: the reaction force that held each node of the patch is recorded at the end of the hold and then applied as an external force that falls smoothly to zero over the release time (20 ms by default). This matters for the sound. An instantaneous release is a step in the finger force, which excites all modes at once, and since the radiated pressure is proportional to the plate acceleration, the result is a nearly flat pressure spectrum, a cymbal-like click. A release over 20 ms confines the excitation to roughly the first hundred hertz, as a fingertip does. The rigid-body components of the recorded reaction are removed before it is applied, so that the finger force and the distributed holding reaction relax together. The release time can be set to zero to recover the instantaneous case.
4646
47-**Drumstick strike.** The stick tip is a free mass $M$ (default 30 g, the effective mass of a stick in a wrist stroke rather than its full mass) arriving with speed $v_0$ (default 3 m/s) at a point of the sheet. The contact patch consists of the sheet nodes within the tip radius (5 mm) plus the nearest node. Its contact coordinate is the mass-weighted mean transverse displacement $w_c$ of these nodes, the penetration is $\delta = w_c - x$ with $x$ the tip position, and the force is the Hertz law $F = K\,\delta^{\alpha}$ for $\delta > 0$ and zero otherwise, with $\alpha = 1.5$ for a rounded tip and $K = 5\times10^{8}\ \mathrm{N/m^{1.5}}$ for wood on steel. The force is spread over the patch in proportion to the node masses. Because that distribution is exactly conjugate to the mean displacement, the contact has a potential energy and the coupled system is Hamiltonian apart from the losses. The stick obeys $M\ddot x = F$ and is stepped together with the plate; it may bounce and strike again if the sheet comes back to meet it. The recording starts at the impact, with the sheet at rest in its natural shape. Note that a thin sheet is a soft target: its driving-point impedance is $8\sqrt{D\rho h} \approx 59\ \mathrm{N\,s/m}$, so the contact lasts well under a millisecond and the peak force is of the order of a hundred newtons. The excitation is therefore broadband, unlike the push, and the sound is a short bright attack followed by the ring.
47+**Drumstick strike.** The stick tip is a free mass $M$ (default 30 g, the effective mass of a stick in a wrist stroke rather than its full mass) arriving with speed $v_0$ (default 3 m/s) at a point of the sheet. The contact patch consists of the sheet nodes within the tip radius (5 mm) plus the nearest node. Its contact coordinate is the mass-weighted mean transverse displacement $w_c$ of these nodes, the penetration is $\delta = w_c - x$ with $x$ the tip position, and the force is the Hertz law $F = K\,\delta^{\alpha}$ for $\delta > 0$ and zero otherwise, with $\alpha = 1.5$ for a rounded tip and $K = 5\times10^{8}\ \mathrm{N/m^{1.5}}$ for wood on steel. The force is spread over the patch in proportion to the node masses. Because that distribution is exactly conjugate to the mean displacement, the contact has a potential energy and the coupled system is Hamiltonian apart from the losses. At the same time the force is balanced over the whole sheet by the inertia-relief field $a_k = F\,(1/M_s + y_k y_c/I_x + x_k x_c/I_y)$, where $M_s$ is the mass of the sheet, $I_x$ and $I_y$ its moments of inertia about the in-plane axes, and $(x_c, y_c)$ the mass-weighted center of the patch, so that the total force and the two moments of the load vanish (Section 2). The field uses the force of the previous step, a lag of one microsecond whose net impulse over the contact is zero. The stick obeys $M\ddot x = F$ and is stepped together with the plate; it may bounce and strike again if the sheet comes back to meet it. The recording starts at the impact, with the sheet at rest in its natural shape. Note that a thin sheet is a soft target: its driving-point impedance is $8\sqrt{D\rho h} \approx 59\ \mathrm{N\,s/m}$, so the contact lasts well under a millisecond and the peak force is of the order of a hundred newtons. The excitation is therefore broadband, unlike the push, and the sound is a short bright attack followed by the ring.
4848
4949 ## 4. Spatial discretization
5050
@@ -68,7 +68,7 @@ The system is advanced with the symplectic Euler scheme, $v^{n+1} = v^n + \Delta
6868
6969 Single precision needs one precaution. With $w$ of a few millimeters and $\Delta t \approx 1\ \mu$s, a velocity below about $10^{-3}$ m/s produces an increment smaller than the resolution of $w$, and the rounding would act as a broadband noise source. The update of $w$ is therefore compensated: the rounding error of each addition is carried in a separate array and added back at the next step (Kahan summation). The self-test of Section 4 also checks this update.
7070
71-The state is stored twice and each step reads one copy and writes the other. A step is one compute dispatch of 16 by 16 workgroups; a per-step uniform, addressed by a dynamic offset, carries the prescribed patch displacement, the damping, and the audio bookkeeping, so that a whole movie frame of steps is encoded in one pass.
71+The state is stored twice and each step reads one copy and writes the other. A step is one compute dispatch of 16 by 16 workgroups; a per-step uniform, addressed by a dynamic offset, carries the prescribed patch displacement, the damping, and the audio bookkeeping, so that a whole movie frame of steps is encoded in one pass. The mass-weighted sums over the sheet (rigid-body components, kinetic energy, peak displacement) run in one workgroup every four steps while the sheet is held and once per frame otherwise, or every 32 steps when the optional projection of Section 2 is on.
7272
7373 ## 6. Radiation to the listener
7474
src/params.tsmodified+9−5View file
@@ -45,6 +45,7 @@ export interface PhysParams {
4545 listenerElevation: number; // [deg]
4646 // Numerics and output
4747 spacing: number; // requested grid spacing [m]
48+ rigidProjection: boolean; // remove the six rigid-body modes every RB_INTERVAL steps while ringing
4849 fs: number; // audio sample rate [Hz]
4950 fps: number; // movie frame rate, also the batch rate [Hz]
5051 duration: number; // recorded duration after the release or the impact [s]
@@ -83,6 +84,7 @@ export const defaultParams: PhysParams = {
8384 listenerAzimuth: 0,
8485 listenerElevation: 0,
8586 spacing: 0.008,
87+ rigidProjection: false,
8688 fs: 48000,
8789 fps: 120,
8890 duration: 4,
@@ -304,9 +306,10 @@ export function initialShape(p: PhysParams, g: Grid): Float32Array<ArrayBuffer>
304306 * point, plus every sheet node within the contact radius. The contact force
305307 * is spread over these nodes in proportion to their mass, and the contact
306308 * coordinate is their mass-weighted mean displacement, so the two are
307- * conjugate and the contact has a potential energy.
309+ * conjugate and the contact has a potential energy. The mass-weighted center
310+ * of the patch is where the inertia-relief reaction to the force is taken.
308311 */
309-export function contactPatch(p: PhysParams, g: Grid): { nodes: number[]; nearest: number; mass: number; box: [number, number, number, number] } {
312+export function contactPatch(p: PhysParams, g: Grid): { nodes: number[]; nearest: number; mass: number; center: [number, number]; box: [number, number, number, number] } {
310313 const m = makeMaterial(p);
311314 const [sx, sy] = strikePoint(p);
312315 let nearest = -1, best = Infinity;
@@ -318,7 +321,7 @@ export function contactPatch(p: PhysParams, g: Grid): { nodes: number[]; nearest
318321 }
319322 }
320323 const nodes: number[] = [];
321- let mass = 0;
324+ let mass = 0, cx = 0, cy = 0;
322325 const box: [number, number, number, number] = [g.nx, -1, g.ny, -1]; // i0, i1, j0, j1 (inclusive)
323326 for (let j = 0; j < g.ny; j++) {
324327 for (let i = 0; i < g.nx; i++) {
@@ -328,11 +331,12 @@ export function contactPatch(p: PhysParams, g: Grid): { nodes: number[]; nearest
328331 const d2 = (nodeX(g, i) - sx) ** 2 + (nodeY(g, j) - sy) ** 2;
329332 if (d2 <= p.contactRadius ** 2 || k === nearest) {
330333 nodes.push(k);
331- mass += m.rhoh * g.hx * g.hy * wgt;
334+ const mk = m.rhoh * g.hx * g.hy * wgt;
335+ mass += mk; cx += mk * nodeX(g, i); cy += mk * nodeY(g, j);
332336 box[0] = Math.min(box[0], i); box[1] = Math.max(box[1], i);
333337 box[2] = Math.min(box[2], j); box[3] = Math.max(box[3], j);
334338 }
335339 }
336340 }
337- return { nodes, nearest, mass, box };
341+ return { nodes, nearest, mass, center: [cx / mass, cy / mass], box };
338342 }
src/sim/shaders/common.wgslmodified+4−0View file
@@ -33,6 +33,10 @@ struct Params {
3333 patchI1: u32,
3434 patchJ0: u32,
3535 patchJ1: u32,
36+ patchCX: f32, // mass-weighted center of the contact patch [m]
37+ patchCY: f32,
38+ pad0: u32,
39+ pad1: u32,
3640 };
3741
3842 // Per-step parameters, addressed with a dynamic uniform offset.
src/sim/shaders/step.wgslmodified+7−1View file
@@ -216,8 +216,14 @@ fn main(@builtin(global_invocation_id) gid: vec3u, @builtin(local_invocation_id)
216216 // force is spread over the patch in proportion to the node masses, so the
217217 // pair is conjugate and the contact has a potential energy.
218218 if ((SP.flags & 8u) != 0u) {
219+ let sIn = n + 4u * SP.parity;
220+ // Inertia relief: the force of the previous step, applied to the whole sheet
221+ // as the rigid acceleration field that balances the patch load and its
222+ // moments about the center, so the sheet gains no net momentum (the thread
223+ // would take it up in reality). The one-step lag integrates to zero.
224+ let Fprev = react[sIn + 2u];
225+ acc.z += Fprev * (1.0 / P.mass + nodeY(j) * P.patchCY / P.Ix + nodeX(i) * P.patchCX / P.Iy);
219226 if (inContactPatch(i, j)) {
220- let sIn = n + 4u * SP.parity;
221227 let sx = react[sIn]; let sv = react[sIn + 1u]; let sMax = react[sIn + 3u];
222228 var wc = 0.0;
223229 for (var pj = i32(P.patchJ0); pj <= i32(P.patchJ1); pj++) {
src/sim/simulation.tsmodified+12−5View file
@@ -58,7 +58,7 @@ export interface SimStatus {
5858 stick: StickState | null;
5959 }
6060
61-const PARAMS_LEN = 32; // floats in the Params uniform
61+const PARAMS_LEN = 36; // floats in the Params uniform
6262 const STEP_LEN = 12; // floats in the StepParams uniform
6363 const STATS_LEN = 16;
6464 const STICK_LEN = 8;
@@ -198,6 +198,7 @@ export class Simulation {
198198 pf[20] = sx; pf[21] = sy; pf[22] = params.contactRadius ** 2; pf[23] = cp.mass;
199199 pu[24] = cp.nearest; pf[25] = params.contactStiffness; pf[26] = params.contactExponent; pf[27] = params.stickMass;
200200 pu[28] = cp.box[0]; pu[29] = cp.box[1]; pu[30] = cp.box[2]; pu[31] = cp.box[3];
201+ pf[32] = cp.center[0]; pf[33] = cp.center[1];
201202 this.paramsBuf = device.createBuffer({ size: pf.byteLength, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
202203 device.queue.writeBuffer(this.paramsBuf, 0, pf);
203204
@@ -503,6 +504,10 @@ export class Simulation {
503504 const wgN = Math.ceil(this.grid.n / 256);
504505 let cur = this.cur;
505506 let audioPtr = 0;
507+ // While held, the projection defines the inertia-relief holding and runs
508+ // every RB_INTERVAL_HELD steps. While ringing it is optional; the sums
509+ // still run once per batch for the statistics.
510+ const project = !recording || p.rigidProjection;
506511 const rbInterval = recording ? RB_INTERVAL : RB_INTERVAL_HELD;
507512 let stepBound = false;
508513 for (let s = 0; s < steps; s++) {
@@ -521,13 +526,15 @@ export class Simulation {
521526 pass.dispatchWorkgroups(wgX, wgY);
522527 cur = 1 - cur;
523528 const global = this.totalSteps + s + 1;
524- if (global % rbInterval === 0 || s === steps - 1) {
529+ if ((project && global % rbInterval === 0) || s === steps - 1) {
525530 pass.setPipeline(this.sumsPipe);
526531 pass.setBindGroup(0, this.rbBG[cur]);
527532 pass.dispatchWorkgroups(1);
528- pass.setPipeline(this.applyPipe);
529- pass.setBindGroup(0, this.rbBG[cur]);
530- pass.dispatchWorkgroups(wgN);
533+ if (project) {
534+ pass.setPipeline(this.applyPipe);
535+ pass.setBindGroup(0, this.rbBG[cur]);
536+ pass.dispatchWorkgroups(wgN);
537+ }
531538 stepBound = false;
532539 }
533540 if (audioPtr < audioSteps.length && audioSteps[audioPtr] === s) {
src/style.cssmodified+1−0View file
@@ -56,3 +56,4 @@ button.primary:hover { background: #f8c453; }
5656 #nogpu[hidden] { display: none; }
5757 #nogpu { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; padding: 24px; text-align: center; color: #ff8a8a; background: var(--bg); }
5858 .seg button.on { background: var(--accent2); border-color: var(--accent2); color: white; }
59+.field[hidden] { display: none; }
src/ui/form.tsmodified+56−45View file
@@ -19,24 +19,24 @@ export type SizeUnit = "in" | "mm";
1919 interface FieldSpec {
2020 key: keyof PhysParams;
2121 label: string;
22- unit: string;
22+ unit: string; // "size" means the shared width/height unit
2323 scale: number; // form value = SI value / scale
2424 step?: number;
2525 live?: boolean; // may be changed while a simulation runs
26+ only?: Excitation; // shown only for this excitation
2627 }
2728
2829 interface SelectSpec {
2930 key: keyof PhysParams;
3031 label: string;
3132 options: [string, string][];
32- before?: keyof PhysParams; // insert before this field (default: at the start of the group)
3333 }
3434
3535 interface Group {
3636 title: string;
3737 open: boolean;
38+ selects?: SelectSpec[]; // shown at the top of the group
3839 fields: FieldSpec[];
39- selects?: SelectSpec[];
4040 note?: string | "grid" | "damping";
4141 }
4242
@@ -64,28 +64,23 @@ const groups: Group[] = [
6464 note: "Real sheet metal is never flat. A bow of a few millimeters makes the sheet a shallow shell with an asymmetric stiffness; a negative push amount pushes against the bow.",
6565 },
6666 {
67- title: "Excitation: push and release",
67+ title: "Excitation",
6868 open: true,
69+ selects: [{ key: "excitation", label: "Type", options: [["push", "Push a corner and release"], ["strike", "Drumstick strike"]] }],
6970 fields: [
70- { key: "pushAmount", label: "Push amount", unit: "mm", scale: 1e-3, step: 0.5 },
71- { key: "fingerRadius", label: "Finger radius", unit: "mm", scale: 1e-3, step: 1 },
72- { key: "rampTime", label: "Push ramp", unit: "s", scale: 1, step: 0.01 },
73- { key: "holdMax", label: "Max settle time", unit: "s", scale: 1, step: 0.1 },
74- { key: "releaseTime", label: "Release time", unit: "ms", scale: 1e-3, step: 1 },
75- { key: "settleSigma0", label: "Settle damping σ₀", unit: "1/s", scale: 1, step: 10 },
76- ],
77- },
78- {
79- title: "Excitation: drumstick strike",
80- open: false,
81- fields: [
82- { key: "strikeFracX", label: "Strike x (fraction of width)", unit: "", scale: 1, step: 0.05 },
83- { key: "strikeFracY", label: "Strike y (fraction of height)", unit: "", scale: 1, step: 0.05 },
84- { key: "strikeVelocity", label: "Stick speed", unit: "m/s", scale: 1, step: 0.5 },
85- { key: "stickMass", label: "Stick mass", unit: "g", scale: 1e-3, step: 5 },
86- { key: "contactStiffness", label: "Contact stiffness K", unit: "N/mᵅ", scale: 1, step: 1e8 },
87- { key: "contactExponent", label: "Contact exponent α", unit: "", scale: 1, step: 0.1 },
88- { key: "contactRadius", label: "Tip radius", unit: "mm", scale: 1e-3, step: 1 },
71+ { key: "pushAmount", label: "Push amount", unit: "mm", scale: 1e-3, step: 0.5, only: "push" },
72+ { key: "fingerRadius", label: "Finger radius", unit: "mm", scale: 1e-3, step: 1, only: "push" },
73+ { key: "rampTime", label: "Push ramp", unit: "s", scale: 1, step: 0.01, only: "push" },
74+ { key: "holdMax", label: "Max settle time", unit: "s", scale: 1, step: 0.1, only: "push" },
75+ { key: "releaseTime", label: "Release time", unit: "ms", scale: 1e-3, step: 1, only: "push" },
76+ { key: "settleSigma0", label: "Settle damping σ₀", unit: "1/s", scale: 1, step: 10, only: "push" },
77+ { key: "strikeFracX", label: "Strike x (fraction of width)", unit: "", scale: 1, step: 0.05, only: "strike" },
78+ { key: "strikeFracY", label: "Strike y (fraction of height)", unit: "", scale: 1, step: 0.05, only: "strike" },
79+ { key: "strikeVelocity", label: "Stick speed", unit: "m/s", scale: 1, step: 0.5, only: "strike" },
80+ { key: "stickMass", label: "Stick mass", unit: "g", scale: 1e-3, step: 5, only: "strike" },
81+ { key: "contactStiffness", label: "Contact stiffness K", unit: "N/mᵅ", scale: 1, step: 1e8, only: "strike" },
82+ { key: "contactExponent", label: "Contact exponent α", unit: "", scale: 1, step: 0.1, only: "strike" },
83+ { key: "contactRadius", label: "Tip radius", unit: "mm", scale: 1e-3, step: 1, only: "strike" },
8984 ],
9085 },
9186 {
@@ -110,23 +105,31 @@ const groups: Group[] = [
110105 {
111106 title: "Numerics",
112107 open: false,
108+ selects: [{ key: "rigidProjection", label: "Rigid-body projection while ringing", options: [["false", "Off"], ["true", "On (every 32 steps)"]] }],
113109 fields: [
114110 { key: "spacing", label: "Grid spacing", unit: "mm", scale: 1e-3, step: 0.5 },
115111 { key: "fs", label: "Audio sample rate", unit: "Hz", scale: 1, step: 100 },
116112 { key: "fps", label: "Movie frame rate", unit: "fps", scale: 1, step: 10 },
117113 { key: "settleKE", label: "Settled below KE", unit: "J", scale: 1, step: 1e-9 },
118114 ],
115+ note: "The excitation forces are self-equilibrated, so the projection only removes rounding drift of the six rigid modes. It is always applied while the sheet is held.",
119116 },
120117 ];
121118
122119 const SIZE_SCALE: Record<SizeUnit, number> = { in: 0.0254, mm: 1e-3 };
123-const enumKeys: (keyof PhysParams)[] = ["shape", "bowShape", "excitation"];
124-const enumValues: Record<string, string[]> = {
120+const selectKeys: (keyof PhysParams)[] = ["shape", "bowShape", "excitation", "rigidProjection"];
121+const selectValues: Record<string, string[]> = {
125122 shape: ["rectangle", "ellipse"],
126123 bowShape: ["none", "width", "height", "dish"],
127124 excitation: ["push", "strike"],
125+ rigidProjection: ["false", "true"],
128126 };
129127
128+/** Parses a select value (URL or form) into the parameter's type. */
129+function parseSelect(key: keyof PhysParams, v: string): string | boolean {
130+ return key === "rigidProjection" ? v === "true" || v === "1" : v;
131+}
132+
130133 export function formatNumber(v: number): string {
131134 if (v === 0) return "0";
132135 const a = Math.abs(v);
@@ -141,27 +144,27 @@ export class ParamForm {
141144 onChange: (key: keyof PhysParams, live: boolean) => void = () => {};
142145
143146 private inputs = new Map<keyof PhysParams, HTMLInputElement | HTMLSelectElement>();
144- private sizeUnitSelect: HTMLSelectElement;
147+ private sizeUnitSelect = document.createElement("select");
148+ private sizeUnitLabels: HTMLElement[] = [];
149+ private conditional: { el: HTMLElement; only: Excitation }[] = [];
145150 private gridNote: HTMLElement | null = null;
146151 private dampingNote: HTMLElement | null = null;
147152 private liveKeys = new Set<keyof PhysParams>();
148153
149154 constructor(root: HTMLElement, params: PhysParams) {
150155 this.params = params;
151- // Excitation selector at the top, outside the groups.
152- this.sizeUnitSelect = document.createElement("select");
153- root.appendChild(this.selectRow("Excitation", "excitation", [["push", "Push a corner and release"], ["strike", "Drumstick strike"]]));
154156 for (const g of groups) {
155157 const det = document.createElement("details");
156158 det.open = g.open;
157159 const sum = document.createElement("summary");
158160 sum.textContent = g.title;
159161 det.appendChild(sum);
160- for (const s of g.selects ?? []) if (!s.before) det.appendChild(this.selectRow(s.label, s.key, s.options));
162+ for (const s of g.selects ?? []) det.appendChild(this.selectRow(s));
161163 for (const f of g.fields) {
162- for (const s of g.selects ?? []) if (s.before === f.key) det.appendChild(this.selectRow(s.label, s.key, s.options));
163- det.appendChild(this.fieldRow(f));
164+ const row = this.fieldRow(f);
165+ if (f.only) this.conditional.push({ el: row, only: f.only });
164166 if (f.live) this.liveKeys.add(f.key);
167+ det.appendChild(row);
165168 }
166169 if (g.note) {
167170 const note = document.createElement("p");
@@ -173,28 +176,30 @@ export class ParamForm {
173176 }
174177 root.appendChild(det);
175178 }
179+ this.updateVisibility();
176180 this.updateNotes();
177181 }
178182
179- private selectRow(label: string, key: keyof PhysParams, options: [string, string][]): HTMLElement {
183+ private selectRow(s: SelectSpec): HTMLElement {
180184 const row = document.createElement("div");
181185 row.className = "field";
182186 const l = document.createElement("label");
183- l.textContent = label;
187+ l.textContent = s.label;
184188 const sel = document.createElement("select");
185- for (const [v, t] of options) {
189+ for (const [v, t] of s.options) {
186190 const o = document.createElement("option");
187191 o.value = v; o.textContent = t;
188192 sel.appendChild(o);
189193 }
190- sel.value = String(this.params[key]);
194+ sel.value = String(this.params[s.key]);
191195 sel.addEventListener("change", () => {
192- (this.params as unknown as Record<string, string>)[key] = sel.value;
196+ (this.params as unknown as Record<string, string | boolean>)[s.key] = parseSelect(s.key, sel.value);
197+ this.updateVisibility();
193198 this.updateNotes();
194- this.onChange(key, false);
199+ this.onChange(s.key, false);
195200 });
196201 row.append(l, sel, document.createElement("span"));
197- this.inputs.set(key, sel);
202+ this.inputs.set(s.key, sel);
198203 return row;
199204 }
200205
@@ -243,12 +248,16 @@ export class ParamForm {
243248 row.append(label, inp, unit);
244249 return row;
245250 }
246- private sizeUnitLabels: HTMLElement[] = [];
247251
248252 private scale(f: FieldSpec): number {
249253 return f.unit === "size" ? SIZE_SCALE[this.sizeUnit] : f.scale;
250254 }
251255
256+ /** Shows the fields of the selected excitation only. */
257+ private updateVisibility(): void {
258+ for (const c of this.conditional) c.el.hidden = c.only !== this.params.excitation;
259+ }
260+
252261 /** Rewrites every input from the current parameter values. */
253262 refresh(): void {
254263 for (const g of groups) {
@@ -257,7 +266,8 @@ export class ParamForm {
257266 inp.value = formatNumber((this.params[f.key] as number) / this.scale(f));
258267 }
259268 }
260- for (const k of enumKeys) (this.inputs.get(k) as HTMLSelectElement).value = String(this.params[k]);
269+ for (const k of selectKeys) (this.inputs.get(k) as HTMLSelectElement).value = String(this.params[k]);
270+ this.updateVisibility();
261271 this.updateNotes();
262272 }
263273
@@ -266,15 +276,16 @@ export class ParamForm {
266276 for (const [key, el] of this.inputs) if (!this.liveKeys.has(key)) el.disabled = !enabled;
267277 }
268278
269- /** Applies URL parameters: any PhysParams key in SI units. Returns the keys that were set. */
279+ /** Applies URL parameters: any PhysParams key in SI units (booleans as 0/1 or false/true). Returns the keys that were set. */
270280 applyUrl(q: URLSearchParams): (keyof PhysParams)[] {
271281 const set: (keyof PhysParams)[] = [];
272282 for (const [k, v] of q) {
273283 if (!(k in this.params)) continue;
274284 const key = k as keyof PhysParams;
275- if (enumKeys.includes(key)) {
276- if (enumValues[key].includes(v)) {
277- (this.params as unknown as Record<string, string>)[key] = v;
285+ if (selectKeys.includes(key)) {
286+ const norm = v === "1" ? "true" : v === "0" ? "false" : v;
287+ if (selectValues[key].includes(norm)) {
288+ (this.params as unknown as Record<string, string | boolean>)[key] = parseSelect(key, norm);
278289 set.push(key);
279290 }
280291 } else {
tests/gpu.test.tsmodified+1−1View file
@@ -93,7 +93,7 @@ async function runToCompletion(query: string): Promise<Status> {
9393 }
9494
9595 test("a short push-and-release run completes with finite output", { timeout: 180_000 }, async () => {
96- const st = await runToCompletion("spacing=0.012&duration=0.2&rampTime=0.05&holdMax=0.1");
96+ const st = await runToCompletion("spacing=0.012&duration=0.2&rampTime=0.05&holdMax=0.1&rigidProjection=1");
9797 console.log(` push: ${st.nx}x${st.ny}, dt ${(st.dt * 1e6).toFixed(2)} us, ${(st.stepsPerSec / 1e3).toFixed(0)} k steps/s, peak ${st.peakPressure.toFixed(3)} Pa, max |w| ${(st.wmax * 1e3).toFixed(2)} mm`);
9898 assert.equal(st.phase, "done");
9999 assert.ok(st.samples >= 0.2 * 48000 - 1, "recorded the requested duration");