Self-contained design scripts; 3D by default; resizable, less verbose panel
The design scripts no longer draw on a hidden helper library. Each preset
defines the shapes it uses as ordinary MATLAB functions at the bottom of the
script, the way a .m script would, and the unit constants at its top. numbl
hoists the definitions, so a handle written above them may call them and the
simulator's sampling trailer still follows the whole script. src/mhelpers.ts
becomes src/mruntime.ts, holding only the internal field sampler. Every
preset evaluates to the same plate as before, to rounding.
The view opens in 3D. The sidebar's head, with the recorded duration and the
run controls, no longer scrolls, and a drag bar between the panel and the
view sets the panel width (remembered in the browser). The explanatory prose
is folded away by default under About, Writing a script, and Notes.
11 changed files+428−248
README.mdmodified+27−10View file
@@ -17,46 +17,63 @@ Requires Node 20 or later and a browser with WebGPU (Chrome or Edge 113+; Firefo
1717
1818 ## Using the app
1919
20-The sidebar has three parts: the design script, the strike and simulation parameters, and the controls. The main area shows the plate (2D color map or a lit 3D height field; drag to orbit, wheel to zoom), the movie and playback toolbar, and the spectrogram of the listener signal.
20+The sidebar holds the controls at its top, then the design script and the strike and simulation parameters. The main area shows the plate (a lit 3D height field by default, or a 2D color map; drag to orbit, wheel to zoom), the movie and playback toolbar, and the spectrogram of the listener signal.
21+
22+The head of the sidebar, with the recorded duration and the Start, Pause, Stop, and Continue buttons, does not scroll, so the controls stay in reach however long the script is. The bar between the sidebar and the view can be dragged to widen the panel (double-click to return to 420 px); the width is remembered in the browser. The panel shows controls and measured quantities only: the explanatory prose is folded away under About, Writing a script, and the two Notes headings, which can be opened when it is wanted.
2123
2224 **Design script.** Pick a preset from the dropdown (a flat steel rectangle by default; a bowed rectangle, a flat disc on a stand, a 16 inch crash, the same crash with a dozen dents, a 20 inch ride, an ellipse with a twist, an 18 inch china) and edit it freely; the script is compiled as you type (or with Ctrl+Enter) and the view shows the resulting stress-free shape, colored by height or by thickness. Errors are reported under the script, and a summary lists the bounding box, the material, the thickness range, the mass, the grid, the profile's peak-to-peak height and largest slope (with a warning above the shallow-shell range), the mount, and the losses. Revert restores the selected preset.
2325
24-The script is written in MATLAB syntax and run by [numbl](https://numbl.org). It fills in a struct named `design`. All lengths are meters; `inch` and `mm` are conversion constants. The outline, the thickness, and the profile are numbers or function handles `@(x, y, r, theta)` of the point relative to the center of the bounding box, its distance from the center, and its angle from the x axis; the handles are applied to whole grid matrices, so they use elementwise operators.
26+The script is written in MATLAB syntax and run by [numbl](https://numbl.org). It fills in a struct named `design`. All lengths are meters. The outline, the thickness, and the profile are numbers or function handles `@(x, y, r, theta)` of the point relative to the center of the bounding box, its distance from the center, and its angle from the x axis; the handles are applied to whole grid matrices, so they use elementwise operators.
27+
28+A script is self-contained. Nothing is in scope but numbl's own functions, so a script defines the shapes it needs as ordinary MATLAB functions at the bottom of the file, as it would in a `.m` script, and the unit constants at the top. Nothing is hidden behind a helper library that a reader would have to look up or an author would have to remember, and a script can be copied out of the page and run elsewhere unchanged. The trade-off is that the presets repeat a few small functions between them.
2529
2630 ```matlab
27-D = 16*inch; R = D/2;
31+inch = 0.0254; mm = 1e-3;
32+D = 16*inch; R = D/2; hole = 12*mm;
2833 bellR = 60*mm; bellH = 12*mm; bowH = 16*mm;
34+tBell = 1.4*mm; tEdge = 0.8*mm;
2935 design.name = '16 inch crash';
3036 design.material.E = 110e9; % Pa
3137 design.material.nu = 0.34;
3238 design.material.rho = 8800; % kg/m^3
3339 design.bounds.width = D; % bounding box [m]
3440 design.bounds.height = D;
35-design.inside = circle(D, 12*mm); % outline with a 12 mm hole; omit for the whole box
36-design.thickness = @(x, y, r, theta) lerp(1.4*mm, 0.8*mm, clamp((r - bellR) ./ (R - bellR), 0, 1));
41+design.inside = @(x, y, r, theta) (r <= R) & (r >= hole/2); % omit for the whole box
42+design.thickness = @(x, y, r, theta) tBell + (tEdge - tBell) * ramp(bellR, R, r);
3743 design.profile = @(x, y, r, theta) bowH * (1 - (r/R).^2) + dome(r, bellR, bellH); % stress-free height [m]
3844 design.mount.radius = 20*mm; % or design.mount = [] for a free plate
3945 design.mount.stiffness = 2e4; % N/m
4046 design.mount.damping = 5; % N s/m
4147 design.loss.sigma0 = 0.6; design.loss.sigma1 = 2.5e-4; design.loss.sigmaIn = 20;
48+
49+function v = ramp(a, b, x)
50+% 0 at x = a, 1 at x = b, clamped to that range outside.
51+v = min(max((x - a) ./ (b - a), 0), 1);
52+end
53+
54+function v = dome(d, radius, height)
55+% A cosine dome of the given height and base radius, with zero slope both at
56+% its rim and at its apex; d is the distance from its center.
57+v = 0.5 * height * (1 + cos(pi * d / radius)) .* (d < radius);
58+end
4259 ```
4360
44-`inside`, `mount`, `profile`, and `loss` are optional (`mount` may also give `x` and `y`). Helper functions in scope: `circle(diameter, hole)`, `ellipse(width, height, hole)`, `rectangle(width, height, hole)` (outlines), `clamp`, `lerp`, `smoothstep(e0, e1, x)`, `bump(d, radius, height)` (Gaussian), `dome(d, radius, height)` (cosine dome with zero slope at the rim), `bow(s, span, sag, offset)` (parabolic bow), `dents(x, y, cx, cy, radius, height)` (a sum of bumps), `seededrand(seed, n)`, and `noise2d(seed, length)` (a smooth random field of unit RMS, returned as a handle `f(x, y)`). Their sources are in `src/mhelpers.ts`. The profile's mean plane is removed before use, since only its slopes enter the physics and a tilt would add a spurious membrane coupling.
61+Note that the functions may follow the statements that use them, as they do in MATLAB, and that a handle created above them may call them: numbl hoists the definitions, and the simulator appends its sampling code after the whole script. A function defined in the script also shadows one of the same name defined anywhere else. `inside`, `mount`, `profile`, and `loss` are optional (`mount` may also give `x` and `y`). The profile's mean plane is removed before use, since only its slopes enter the physics and a tilt would add a spurious membrane coupling.
4562
46-**Strike and simulation.** The strike point is given as fractions of the bounding box (the yellow ring in the view), with the stick speed, its effective mass, the Hertz contact stiffness and exponent, and the tip radius. The listener distance and direction can be changed while a simulation runs. The grid spacing (8 mm by default), the in-plane mass factor, the sample rate, and the frame rate take effect at the next Start; the panel reports the grid, the frequencies at which grid dispersion lowers the partials by 3 and 10 percent, the time step, and the 60 dB decay times of the design's losses. The rigid-body projection applies to free plates only; a mounted plate takes the reaction of the strike through the mount.
63+**Strike and simulation.** The strike point is given as fractions of the bounding box (the yellow ring in the view), with the stick speed, its effective mass, the Hertz contact stiffness and exponent, and the tip radius. The listener distance and direction can be changed while a simulation runs. The grid spacing (8 mm by default), the in-plane mass factor, the sample rate, and the frame rate take effect at the next Start; the Notes under the group report the grid, the frequencies at which grid dispersion lowers the partials by 3 and 10 percent, the time step, and the 60 dB decay times of the design's losses. The rigid-body projection applies to free plates only; a mounted plate takes the reaction of the strike through the mount.
4764
4865 **Running.** Press Start. The stick strikes the plate at rest in its stress-free shape, and the recording of audio and movie frames starts at the impact. The view shows the total shape by default (profile plus vibration); untick Show profile to see the vibration alone, which makes a small vibration on a curved cymbal visible. Pause halts the run temporarily; Stop ends it, keeps the audio and movie, and unlocks the script and the fields. To extend a finished run, raise the duration and press Continue. After the run, drag the time slider to explore the movie; Play starts the audio from the slider position and moves the movie and the spectrogram cursor with it; Live returns to the running view and Design to the stress-free shape. The audio can be played or downloaded as a 16-bit WAV at any time; Absolute level maps 1 Pa to full scale shifted by the gain slider (the peak level in dBFS is shown with a clipping warning), and Loudness and Peak normalize each run instead.
4966
5067 **URL parameters.** `?preset=crash-16` selects a preset; any strike, listener, or numerics parameter can be preset in SI units, for example `?preset=ride-20&spacing=0.006&duration=3&autostart=1`.
5168
52-The default grid spacing of 8 mm keeps the simulation interactive but lowers the bending partials by a few percent near 1 kHz and by 10 percent a few kilohertz higher, depending on the thickness; the panel reports the two frequencies. The stable time step is set by the in-plane wave speed (about 1.3 microseconds at 8 mm for steel and 1.7 for bronze) and does not depend on the thickness, so a thick ride costs the same per step as a thin crash; a 16 inch cymbal at 8 mm has about 2000 nodes and runs at 60 to 70 thousand steps per second on an integrated GPU, or roughly 50 s for 4 s of sound.
69+The default grid spacing of 8 mm keeps the simulation interactive but lowers the bending partials by a few percent near 1 kHz and by 10 percent a few kilohertz higher, depending on the thickness; the Notes under the grid group report the two frequencies. The stable time step is set by the in-plane wave speed (about 1.3 microseconds at 8 mm for steel and 1.7 for bronze) and does not depend on the thickness, so a thick ride costs the same per step as a thin crash; a 16 inch cymbal at 8 mm has about 2000 nodes and runs at 60 to 70 thousand steps per second on an integrated GPU, or roughly 50 s for 4 s of sound.
5370
5471 ## Tests
5572
5673 `npm test` runs three groups of tests with Node's built-in runner.
5774
5875 - `tests/cpuRef.test.ts` checks the float64 reference of the spatial discretization without a GPU: symmetry of the stiffness with exactly three rigid modes per block on uniform, tapered, elliptical, and holed plates, forces as the exact gradient of the discrete energy (flat and bowed, uniform and tapered, free and mounted), dissipativity of the loss terms and the mount's dashpot, the scaling of the bending frequencies with the thickness, the mount's stiffness against rigid translation and tilt, and convergence of the free square and circular plate frequencies to the reference values of Gorman and Leissa.
59-- `tests/design.test.ts` checks the design script: the helpers, compilation and the checks with their messages, the evaluation on the grid (including the orientation of the sampled matrices), the mask of a disc with a hole, the removal of the mean plane, the mount patch, and that every preset evaluates on the default grid.
76+- `tests/design.test.ts` checks the design script: the functions a script defines for itself (including a handle that calls one defined below it), compilation and the checks with their messages, the evaluation on the grid (including the orientation of the sampled matrices), the mask of a disc with a hole, the removal of the mean plane, the mount patch, and that every preset evaluates on the default grid.
6077 - `tests/gpu.test.ts` serves the app with Vite and drives a headless Chromium through Playwright. It compares one step of the WGSL kernel with the float64 reference on flat, bowed, tapered, mounted, and elliptical presets (`?selftest=1` in the browser shows the same numbers), runs a short strike on a free plate and on a mounted cymbal to completion, and checks that a script with an error is reported and cannot start. It needs Playwright's Chromium (`npx playwright install chromium`) and a machine whose Chromium has WebGPU; `npm run test:cpu` runs only the first two groups.
6178
6279 ## Benchmark
@@ -67,7 +84,7 @@ The default grid spacing of 8 mm keeps the simulation interactive but lowers the
6784
6885 - `src/params.ts`: simulation parameters, grid and mask, the plate as evaluated on the grid, material functions, patches of the strike and the mount, grid dispersion estimate.
6986 - `src/design.ts`: running the script with numbl, the checks on the design struct, evaluation on the grid.
70-- `src/mhelpers.ts`: the MATLAB helper functions available to a script, as numbl workspace files.
87+- `src/mruntime.ts`: the one internal `.m` file the simulator puts in scope of a script, which samples a field on the grid.
7188 - `src/presets.ts`: the preset scripts.
7289 - `src/sim/cpuRef.ts`: float64 reference of the spatial discretization; used for the time-step estimate and the tests.
7390 - `src/sim/shaders/`: WGSL kernels (time step, rigid-body sums and projection, listener sum, display extraction).
index.htmlmodified+17−10View file
@@ -9,17 +9,24 @@
99 <body>
1010 <div id="app">
1111 <aside id="sidebar">
12- <h1>Cymbal designer</h1>
13- <p class="sub">A scripted cymbal, struck once: geometrically nonlinear plate (von Karman with in-plane inertia) with a thickness field, stepped explicitly on the GPU.</p>
14- <details open>
15- <summary>Design script</summary>
16- <div id="design"></div>
17- </details>
18- <div id="params"></div>
19- <div id="controls"></div>
20- <div id="info"></div>
21- <p class="sub">The model and the numerical method are described in THEORY.md in the repository.</p>
12+ <div id="sidebarHead">
13+ <h1>Cymbal designer</h1>
14+ <div id="controls"></div>
15+ </div>
16+ <div id="sidebarBody">
17+ <details class="blurb">
18+ <summary>About</summary>
19+ <p class="sub">A scripted cymbal, struck once: geometrically nonlinear plate (von Karman with in-plane inertia) with a thickness field, stepped explicitly on the GPU. The model and the numerical method are described in THEORY.md in the repository.</p>
20+ </details>
21+ <details open>
22+ <summary>Design script</summary>
23+ <div id="design"></div>
24+ </details>
25+ <div id="params"></div>
26+ <div id="info"></div>
27+ </div>
2228 </aside>
29+ <div id="splitter" title="Drag to resize the panel; double-click to reset"></div>
2330 <main id="main">
2431 <div class="toolbar" id="viewToolbar"></div>
2532 <div id="viewWrap"><canvas id="view"></canvas><div id="nogpu" hidden></div></div>
src/design.tsmodified+18−13View file
@@ -7,11 +7,13 @@
77 // again with a short trailer that samples the fields on meshgrid matrices of
88 // the requested spacing, inside numbl, and reads the matrices back.
99 //
10-// Lengths are meters throughout; `inch` and `mm` are helper functions. The
11-// helpers a script may call are listed in mhelpers.ts.
10+// Lengths are meters throughout. A script is self-contained: nothing is in
11+// scope but numbl's own functions and whatever the script defines itself,
12+// including ordinary MATLAB functions written at its bottom (numbl hoists
13+// them, so the trailer may still follow them).
1214
1315 import { executeCode, type RuntimeValue, type RuntimeTensor, type RuntimeStruct } from "numbl";
14-import { HELPER_FILES } from "./mhelpers.ts";
16+import { RUNTIME_FILES } from "./mruntime.ts";
1517 import {
1618 type Grid,
1719 type Loss,
@@ -50,23 +52,26 @@ export class DesignError extends Error {}
5052
5153 const FILE_NAME = "design.m";
5254
53-/** Runs a design script (with the helpers in scope) and returns its workspace variables. */
54-export function runDesignScript(source: string): Record<string, RuntimeValue> {
55+/**
56+ * Runs a design script and returns its workspace variables. `userLines` is
57+ * the length of the part of the source the user wrote, so that an error in
58+ * the appended trailer is not reported at a line the user cannot see.
59+ */
60+export function runDesignScript(source: string, userLines = Infinity): Record<string, RuntimeValue> {
5561 try {
56- return executeCode(source, { displayResults: false, onOutput: () => {} }, HELPER_FILES, FILE_NAME).variableValues;
62+ return executeCode(source, { displayResults: false, onOutput: () => {} }, RUNTIME_FILES, FILE_NAME).variableValues;
5763 } catch (e) {
58- throw new DesignError(describeError(e, source));
64+ throw new DesignError(describeError(e, Math.min(source.split("\n").length, userLines)));
5965 }
6066 }
6167
6268 /** The message of a numbl error (a RuntimeError or a parser SyntaxError), with its line when it lies in the user's script. */
63-function describeError(e: unknown, source: string): string {
69+function describeError(e: unknown, userLines: number): string {
6470 if (!(e instanceof Error)) return String(e);
6571 const { line, file } = e as { line?: unknown; file?: unknown };
66- const lines = source.split("\n").length;
67- // A line is only meaningful when the error arose in the script itself, not in a helper .m file.
72+ // A line is only meaningful when the error arose in the script itself, not in the runtime .m file.
6873 const inScript = file === undefined || file === null || file === FILE_NAME;
69- const where = inScript && typeof line === "number" && line >= 1 && line <= lines ? `line ${line}: ` : "";
74+ const where = inScript && typeof line === "number" && line >= 1 && line <= userLines ? `line ${line}: ` : "";
7075 return `${where}${e.message}`;
7176 }
7277
@@ -138,7 +143,7 @@ export function compileDesign(source: string): Design {
138143 const inside = field(d, "inside");
139144 let hasInside = false;
140145 if (!isEmpty(inside)) {
141- if (!isFunction(inside)) throw new DesignError("design.inside must be a function handle @(x, y, r, theta) returning true inside the plate, for example circle(D, hole)");
146+ if (!isFunction(inside)) throw new DesignError("design.inside must be a function handle @(x, y, r, theta) returning true inside the plate, for example @(x, y, r, theta) r <= D/2");
142147 hasInside = true;
143148 }
144149
@@ -254,7 +259,7 @@ function matrix(vars: Record<string, RuntimeValue>, name: string, rows: number,
254259 export function evaluatePlate(d: Design, spacing: number): Plate {
255260 const full = makeGrid(d.Lx, d.Ly, spacing);
256261 const { nx, ny } = full;
257- const vars = runDesignScript(d.source + trailer(d, full));
262+ const vars = runDesignScript(d.source + trailer(d, full), d.source.split("\n").length);
258263 // meshgrid matrices are [ny, nx] in column-major order: entry (i, j) sits at i * ny + j.
259264 const H = matrix(vars, "steelsim_H", ny, nx, "thickness");
260265 const Z = matrix(vars, "steelsim_Z", ny, nx, "profile");
src/main.tsmodified+55−4View file
@@ -64,7 +64,6 @@ function buildControls(): void {
6464 const c = $("controls");
6565 const durRow = document.createElement("div");
6666 durRow.className = "field";
67- durRow.style.width = "100%";
6867 const l = document.createElement("label");
6968 l.textContent = "Recorded duration";
7069 durInput.type = "number";
@@ -124,9 +123,60 @@ function setEditable(enabled: boolean): void {
124123 editor.setEnabled(enabled);
125124 }
126125
126+// ---------- Splitter ----------
127+
128+const SIDEBAR_KEY = "steelsim41.sidebarWidth";
129+const SIDEBAR_DEFAULT = 420;
130+const SIDEBAR_MIN = 280;
131+/** Leaves at least this much for the view, so the panel cannot swallow the page. */
132+const MAIN_MIN = 320;
133+
134+function sidebarWidth(w: number): number {
135+ return Math.round(Math.max(SIDEBAR_MIN, Math.min(w, Math.max(SIDEBAR_MIN, window.innerWidth - MAIN_MIN))));
136+}
137+
138+/** Drag handle between the panel and the view; the width is remembered across visits. */
139+function buildSplitter(): void {
140+ const bar = $("splitter");
141+ const sidebar = $("sidebar");
142+ const setWidth = (w: number, remember: boolean) => {
143+ const px = sidebarWidth(w);
144+ sidebar.style.width = `${px}px`;
145+ if (remember) try { localStorage.setItem(SIDEBAR_KEY, String(px)); } catch { /* storage may be unavailable */ }
146+ };
147+ let stored = NaN;
148+ try { stored = parseFloat(localStorage.getItem(SIDEBAR_KEY) ?? ""); } catch { /* ignore */ }
149+ setWidth(isFinite(stored) ? stored : SIDEBAR_DEFAULT, false);
150+ let dragging = false;
151+ bar.addEventListener("pointerdown", (e) => {
152+ dragging = true;
153+ bar.setPointerCapture(e.pointerId);
154+ bar.classList.add("dragging");
155+ document.body.classList.add("resizing");
156+ e.preventDefault();
157+ });
158+ bar.addEventListener("pointermove", (e) => {
159+ if (!dragging) return;
160+ setWidth(e.clientX - sidebar.getBoundingClientRect().left, false);
161+ });
162+ const end = (e: PointerEvent) => {
163+ if (!dragging) return;
164+ dragging = false;
165+ bar.releasePointerCapture(e.pointerId);
166+ bar.classList.remove("dragging");
167+ document.body.classList.remove("resizing");
168+ setWidth(sidebar.getBoundingClientRect().width, true);
169+ };
170+ bar.addEventListener("pointerup", end);
171+ bar.addEventListener("pointercancel", end);
172+ bar.addEventListener("dblclick", () => setWidth(SIDEBAR_DEFAULT, true));
173+ // A narrower window may leave the stored width too wide for the view.
174+ window.addEventListener("resize", () => setWidth(sidebar.getBoundingClientRect().width, false));
175+}
176+
127177 // ---------- View toolbars ----------
128178
129-const viewMode = { value: "2d" as ViewMode };
179+const viewMode = { value: "3d" as ViewMode };
130180 const colorSel = document.createElement("select");
131181 const scrub = document.createElement("input");
132182 const timeLabel = document.createElement("span");
@@ -164,7 +214,7 @@ function buildViewToolbar(): void {
164214 const tb = $("viewToolbar");
165215 const seg = document.createElement("span");
166216 seg.className = "seg";
167- const b2 = mkButton("2D", "on"), b3 = mkButton("3D");
217+ const b2 = mkButton("2D"), b3 = mkButton("3D", "on");
168218 b2.addEventListener("click", () => { viewMode.value = "2d"; b2.classList.add("on"); b3.classList.remove("on"); });
169219 b3.addEventListener("click", () => { viewMode.value = "3d"; b3.classList.add("on"); b2.classList.remove("on"); });
170220 seg.append(b2, b3);
@@ -518,7 +568,7 @@ function renderPreview(dev: GPUDevice): void {
518568
519569 function updateIdleInfo(): void {
520570 const html = plate
521- ? `<p class="sub">The view shows the design's stress-free shape. Press Start to strike it.</p>`
571+ ? `<p class="sub">The view shows the design's stress-free shape.</p>`
522572 : `<p class="err">${plateError ?? "No design."}</p>`;
523573 if (html !== lastInfoHtml) {
524574 lastInfoHtml = html;
@@ -613,6 +663,7 @@ async function init(): Promise<void> {
613663 if (key.startsWith("listener")) sim.setListener(params.listenerDist, params.listenerAzimuth, params.listenerElevation);
614664 };
615665 editor.onDesign = () => rebuildPlate();
666+ buildSplitter();
616667 buildControls();
617668 buildViewToolbar();
618669 buildMovieToolbar();
src/mhelpers.tsdeleted+0−143View file
@@ -1,143 +0,0 @@
1-// The MATLAB-syntax helper functions available to a design script, as numbl
2-// workspace files. Each is an ordinary .m function; the design script calls
3-// them like any other function. Everything is vectorized over grid matrices.
4-// steelsim_field is internal: evaluatePlate uses it to sample a field.
5-
6-import type { WorkspaceFile } from "numbl";
7-
8-const files: Record<string, string> = {
9- "inch.m": `function v = inch()
10-% inch: one inch in meters.
11-v = 0.0254;
12-end
13-`,
14- "mm.m": `function v = mm()
15-% mm: one millimeter in meters.
16-v = 1e-3;
17-end
18-`,
19- "circle.m": `function f = circle(diameter, hole)
20-% circle(diameter, hole): outline of a disc with an optional center hole.
21-if nargin < 2, hole = 0; end
22-f = @(x, y, r, theta) (r <= diameter/2) & (r >= hole/2);
23-end
24-`,
25- "ellipse.m": `function f = ellipse(width, height, hole)
26-% ellipse(width, height, hole): outline of an ellipse with an optional center hole.
27-if nargin < 3, hole = 0; end
28-f = @(x, y, r, theta) ((x/(width/2)).^2 + (y/(height/2)).^2 <= 1) & (r >= hole/2);
29-end
30-`,
31- "rectangle.m": `function f = rectangle(width, height, hole)
32-% rectangle(width, height, hole): outline of a centered rectangle with an optional center hole.
33-if nargin < 3, hole = 0; end
34-f = @(x, y, r, theta) (abs(x) <= width/2) & (abs(y) <= height/2) & (r >= hole/2);
35-end
36-`,
37- "clamp.m": `function v = clamp(x, lo, hi)
38-% clamp(x, lo, hi): x limited to [lo, hi], elementwise.
39-v = min(hi, max(lo, x));
40-end
41-`,
42- "lerp.m": `function v = lerp(a, b, t)
43-% lerp(a, b, t): a at t = 0, b at t = 1, elementwise.
44-v = a + (b - a) .* t;
45-end
46-`,
47- "smoothstep.m": `function v = smoothstep(e0, e1, x)
48-% smoothstep(e0, e1, x): 0 below e0, 1 above e1, a smooth cubic between.
49-t = clamp((x - e0) ./ (e1 - e0), 0, 1);
50-v = t.^2 .* (3 - 2*t);
51-end
52-`,
53- "bump.m": `function v = bump(d, radius, height)
54-% bump(d, radius, height): Gaussian bump of the given height, falling to
55-% 1/e at the given radius; d is the distance from its center.
56-v = height * exp(-(d.^2) / radius^2);
57-end
58-`,
59- "dome.m": `function v = dome(d, radius, height)
60-% dome(d, radius, height): cosine dome of the given height and base radius,
61-% with zero slope at the rim and the apex; d is the distance from its center.
62-v = 0.5 * height * (1 + cos(pi * d / radius)) .* (d < radius);
63-end
64-`,
65- "bow.m": `function v = bow(s, L, sag, offset)
66-% bow(s, L, sag, offset): parabolic bow of the given sag across a span L
67-% centered on 0; s is the coordinate along the span, and the offset in
68-% -1..1 moves the crest toward one end.
69-if nargin < 4, offset = 0; end
70-t = 2*s/L;
71-v = sag * (1 - t.^2) .* (1 + offset*t);
72-end
73-`,
74- "dents.m": `function v = dents(x, y, cx, cy, radius, height)
75-% dents(x, y, cx, cy, radius, height): the sum of Gaussian bumps centered
76-% at (cx(k), cy(k)) with the given radii and heights (vectors, or scalars
77-% shared by all the dents).
78-v = zeros(size(x));
79-for k = 1:numel(cx)
80- rk = radius(min(k, numel(radius)));
81- hk = height(min(k, numel(height)));
82- v = v + bump(hypot(x - cx(k), y - cy(k)), rk, hk);
83-end
84-end
85-`,
86- "seededrand.m": `function u = seededrand(seed, n)
87-% seededrand(seed, n): n uniform numbers in [0, 1) from a small seeded
88-% generator, the same on every run and every machine.
89-s = floor(seed);
90-u = zeros(1, n);
91-for k = 1:n
92- s = mod(s * 1664525 + 1013904223, 4294967296);
93- u(k) = s / 4294967296;
94-end
95-end
96-`,
97- "noise2d.m": `function f = noise2d(seed, len)
98-% noise2d(seed, len): a smooth random field f(x, y) of unit RMS with the
99-% covariance exp(-d^2 / len^2), the same for a given seed on any grid.
100-n = 64;
101-u = reshape(seededrand(seed, 5*n), 5, n);
102-kx = sqrt(-2*log(1 - u(1,:))) .* cos(2*pi*u(2,:)) * sqrt(2) / max(len, 1e-4);
103-ky = sqrt(-2*log(1 - u(3,:))) .* cos(2*pi*u(4,:)) * sqrt(2) / max(len, 1e-4);
104-ph = 2*pi*u(5,:);
105-f = @(x, y) noise2d_eval(kx, ky, ph, x, y);
106-end
107-`,
108- "noise2d_eval.m": `function v = noise2d_eval(kx, ky, ph, x, y)
109-v = zeros(size(x));
110-for k = 1:numel(kx)
111- v = v + cos(kx(k) * x + ky(k) * y + ph(k));
112-end
113-v = v / sqrt(numel(kx) / 2);
114-end
115-`,
116- "steelsim_field.m": `function v = steelsim_field(f, what, x, y, r, theta)
117-% Samples a design field (a number or a function handle) on the grid matrices.
118-if isa(f, 'function_handle')
119- try
120- v = f(x, y, r, theta);
121- catch e
122- error('%s: %s', what, e.message);
123- end
124-elseif isnumeric(f) || islogical(f)
125- v = f;
126-else
127- error('%s must be a number or a function handle @(x, y, r, theta)', what);
128-end
129-if isscalar(v)
130- v = double(v) * ones(size(x));
131-elseif ~isequal(size(v), size(x))
132- error('%s returned a %d x %d array for a %d x %d grid; use elementwise operators (.*, ./, .^)', what, size(v, 1), size(v, 2), size(x, 1), size(x, 2));
133-else
134- v = double(v);
135-end
136-end
137-`,
138-};
139-
140-export const HELPER_FILES: WorkspaceFile[] = Object.entries(files).map(([name, source]) => ({ name, source }));
141-
142-/** The helper names a script may call (for the interface's hint). */
143-export const HELPER_NAMES = Object.keys(files).filter((n) => !n.startsWith("steelsim_") && !n.startsWith("noise2d_eval")).map((n) => n.replace(/\.m$/, ""));
src/mruntime.tsadded+32−0View file
@@ -0,0 +1,32 @@
1+// The only .m file the simulator puts in scope of a design script. It is not
2+// a design helper: evaluatePlate's trailer calls it to sample a field (a
3+// number or a function handle) on the grid matrices and to report a bad
4+// return value in the script's own terms. Design scripts define whatever
5+// shape functions they need themselves, at the bottom of the script.
6+
7+import type { WorkspaceFile } from "numbl";
8+
9+const SAMPLE = `function v = steelsim_field(f, what, x, y, r, theta)
10+% Samples a design field (a number or a function handle) on the grid matrices.
11+if isa(f, 'function_handle')
12+ try
13+ v = f(x, y, r, theta);
14+ catch e
15+ error('%s: %s', what, e.message);
16+ end
17+elseif isnumeric(f) || islogical(f)
18+ v = f;
19+else
20+ error('%s must be a number or a function handle @(x, y, r, theta)', what);
21+end
22+if isscalar(v)
23+ v = double(v) * ones(size(x));
24+elseif ~isequal(size(v), size(x))
25+ error('%s returned a %d x %d array for a %d x %d grid; use elementwise operators (.*, ./, .^)', what, size(v, 1), size(v, 2), size(x, 1), size(x, 2));
26+else
27+ v = double(v);
28+end
29+end
30+`;
31+
32+export const RUNTIME_FILES: WorkspaceFile[] = [{ name: "steelsim_field.m", source: SAMPLE }];
src/presets.tsmodified+157−33View file
@@ -1,6 +1,9 @@
11 // Predefined design scripts, selectable from the interface. Each is a
22 // MATLAB-syntax script that fills in the `design` struct; see design.ts for
3-// the fields and mhelpers.ts for the helper functions in scope.
3+// the fields. A script is self-contained: the shape functions it uses are
4+// ordinary MATLAB functions written at the bottom of the script itself, so
5+// that reading the script tells the whole story of the design and editing it
6+// cannot go wrong for want of a hidden helper.
47
58 export interface Preset {
69 id: string;
@@ -9,11 +12,16 @@ export interface Preset {
912 }
1013
1114 const flatRectangle = `% A flat rectangular sheet of stainless steel, hanging freely (no mount).
12-% Lengths are meters; inch and mm are conversion constants. The script fills
13-% in a struct named design. A field can be a number or a function handle
14-% @(x, y, r, theta) of the point, measured from the center of the bounding
15-% box; the handles are applied to whole grid matrices, so use elementwise
16-% operators (.*, ./, .^) inside them.
15+% The script is an ordinary MATLAB program that fills in a struct named
16+% design. Lengths are meters; the constants at the top are the only units.
17+% A field can be a number or a function handle @(x, y, r, theta) of the
18+% point, measured from the center of the bounding box; the handles are
19+% applied to whole grid matrices, so use elementwise operators (.*, ./, .^)
20+% inside them. Anything else a script needs it defines itself, as MATLAB
21+% functions at the bottom; the other presets show how.
22+inch = 0.0254; % m
23+mm = 1e-3; % m
24+
1725 design.name = 'Flat steel rectangle';
1826 design.material.E = 170e9; % Young's modulus [Pa]
1927 design.material.nu = 0.29; % Poisson ratio
@@ -31,7 +39,9 @@ design.loss.sigmaIn = 20; % extra loss of the in-plane motion [1/s]
3139 const bowedRectangle = `% The same steel sheet with a 3 mm parabolic bow across its width, the
3240 % kind of bow that rolling and handling leave in real sheet metal. The bow
3341 % makes the sheet a shallow shell, which oil-cans and wobbles when struck.
42+inch = 0.0254; mm = 1e-3;
3443 W = 12*inch; H = 18*inch;
44+
3545 design.name = 'Bowed steel rectangle';
3646 design.material.E = 170e9;
3747 design.material.nu = 0.29;
@@ -39,26 +49,33 @@ design.material.rho = 7900;
3949 design.bounds.width = W;
4050 design.bounds.height = H;
4151 design.thickness = 0.79*mm;
42-% bow(s, span, sag, offset): parabola of the given sag across the span; the
43-% offset in -1..1 moves the crest toward one edge.
4452 design.profile = @(x, y, r, theta) bow(x, W, 3*mm, 0.3);
4553 design.mount = [];
4654 design.loss.sigma0 = 1.15;
4755 design.loss.sigma1 = 5e-4;
4856 design.loss.sigmaIn = 20;
57+
58+function v = bow(s, span, sag, offset)
59+% A parabola of the given sag across a span centered on 0; s runs along the
60+% span, and the offset in -1..1 moves the crest toward one end.
61+t = 2*s/span;
62+v = sag * (1 - t.^2) .* (1 + offset*t);
63+end
4964 `;
5065
5166 const flatDisc = `% A flat 16 inch steel disc with a center hole, sitting on a stand: the
5267 % nodes within the mount radius are tied to the ground by a spring (the felt)
5368 % and a dashpot, spread over the mount area.
54-D = 16*inch;
69+inch = 0.0254; mm = 1e-3;
70+D = 16*inch; hole = 12*mm;
71+
5572 design.name = 'Flat steel disc';
5673 design.material.E = 170e9;
5774 design.material.nu = 0.29;
5875 design.material.rho = 7900;
5976 design.bounds.width = D;
6077 design.bounds.height = D;
61-design.inside = circle(D, 12*mm); % diameter, hole diameter
78+design.inside = @(x, y, r, theta) (r <= D/2) & (r >= hole/2);
6279 design.thickness = 1.0*mm;
6380 design.profile = 0;
6481 design.mount.radius = 20*mm; % felt radius
@@ -73,19 +90,22 @@ const crash16 = `% A 16 inch crash: B20 bronze, tapered from the bell to the edg
7390 % shallow bow and a cosine bell. The shallow-shell model behind the
7491 % simulation is strained where the slope of the profile exceeds about 0.1
7592 % to 0.2, so the bow and the bell are a little flatter than on a real crash.
76-D = 16*inch; R = D/2;
93+inch = 0.0254; mm = 1e-3;
94+D = 16*inch; R = D/2; hole = 12*mm;
7795 bellR = 60*mm; bellH = 12*mm; % base radius and height of the bell
7896 bowH = 16*mm; % rise from the edge to the foot of the bell
97+tBell = 1.4*mm; tEdge = 0.8*mm; % thickness at the bell and at the edge
98+
7999 design.name = '16 inch crash';
80100 design.material.E = 110e9;
81101 design.material.nu = 0.34;
82102 design.material.rho = 8800;
83103 design.bounds.width = D;
84104 design.bounds.height = D;
85-design.inside = circle(D, 12*mm);
86-% Taper: 1.4 mm at the bell, 0.8 mm at the edge.
87-design.thickness = @(x, y, r, theta) lerp(1.4*mm, 0.8*mm, clamp((r - bellR) ./ (R - bellR), 0, 1));
88-% A paraboloidal bow plus the bell; dome(d, radius, height) has zero slope at its rim.
105+design.inside = @(x, y, r, theta) (r <= R) & (r >= hole/2);
106+% Linear taper from the foot of the bell to the edge.
107+design.thickness = @(x, y, r, theta) tBell + (tEdge - tBell) * ramp(bellR, R, r);
108+% A paraboloidal bow plus the bell; dome has zero slope at its rim.
89109 design.profile = @(x, y, r, theta) bowH * (1 - (r/R).^2) + dome(r, bellR, bellH);
90110 design.mount.radius = 20*mm;
91111 design.mount.stiffness = 2e4;
@@ -93,6 +113,17 @@ design.mount.damping = 5;
93113 design.loss.sigma0 = 0.6;
94114 design.loss.sigma1 = 2.5e-4;
95115 design.loss.sigmaIn = 20;
116+
117+function v = ramp(a, b, x)
118+% 0 at x = a, 1 at x = b, clamped to that range outside.
119+v = min(max((x - a) ./ (b - a), 0), 1);
120+end
121+
122+function v = dome(d, radius, height)
123+% A cosine dome of the given height and base radius, with zero slope both at
124+% its rim and at its apex; d is the distance from its center.
125+v = 0.5 * height * (1 + cos(pi * d / radius)) .* (d < radius);
126+end
96127 `;
97128
98129 const crashDented = `% A 16 inch crash that has been through a lot: the same bronze, taper,
@@ -102,25 +133,29 @@ const crashDented = `% A 16 inch crash that has been through a lot: the same bro
102133 % in height, up or down; change the seed for a different set. Dents break
103134 % the symmetry of the disc, split the degenerate mode pairs, and give the
104135 % cymbal a less pure, more trashy tone.
105-D = 16*inch; R = D/2;
136+inch = 0.0254; mm = 1e-3;
137+D = 16*inch; R = D/2; hole = 12*mm;
106138 bellR = 60*mm; bellH = 12*mm; bowH = 16*mm;
107-% Twelve dents from a seeded generator (the same on every run).
139+tBell = 1.4*mm; tEdge = 0.8*mm;
140+
141+% Twelve dents from the seeded generator at the bottom (the same on every run).
108142 nd = 12;
109-u = reshape(seededrand(7, 4*nd), 4, nd); % rows: radius, angle, size, height
110-rr = bellR + 25*mm + (R - bellR - 45*mm) * u(1,:); % on the bow, clear of the bell and the edge
143+u = reshape(seededrand(7, 4*nd), 4, nd); % rows: radius, angle, size, height
144+rr = bellR + 25*mm + (R - bellR - 45*mm) * u(1,:); % on the bow, clear of the bell and the edge
111145 th = 2*pi * u(2,:);
112146 cx = rr .* cos(th); cy = rr .* sin(th);
113147 rad = (18 + 14*u(3,:)) * mm;
114148 hgt = sign(u(4,:) - 0.5) .* (0.8 + 1.4*u(4,:)) * mm;
115-hammer = noise2d(3, 40*mm); % unit RMS, correlation length 40 mm
149+hammer = noise2d(3, 40*mm); % unit RMS, correlation length 40 mm
150+
116151 design.name = '16 inch crash, dented';
117152 design.material.E = 110e9;
118153 design.material.nu = 0.34;
119154 design.material.rho = 8800;
120155 design.bounds.width = D;
121156 design.bounds.height = D;
122-design.inside = circle(D, 12*mm);
123-design.thickness = @(x, y, r, theta) lerp(1.4*mm, 0.8*mm, clamp((r - bellR) ./ (R - bellR), 0, 1));
157+design.inside = @(x, y, r, theta) (r <= R) & (r >= hole/2);
158+design.thickness = @(x, y, r, theta) tBell + (tEdge - tBell) * ramp(bellR, R, r);
124159 design.profile = @(x, y, r, theta) bowH * (1 - (r/R).^2) + dome(r, bellR, bellH) ...
125160 + dents(x, y, cx, cy, rad, hgt) ...
126161 + 0.3*mm * hammer(x, y) .* smoothstep(bellR, bellR + 30*mm, r); % no hammering on the bell
@@ -130,21 +165,82 @@ design.mount.damping = 5;
130165 design.loss.sigma0 = 0.6;
131166 design.loss.sigma1 = 2.5e-4;
132167 design.loss.sigmaIn = 20;
168+
169+function v = ramp(a, b, x)
170+% 0 at x = a, 1 at x = b, clamped to that range outside.
171+v = min(max((x - a) ./ (b - a), 0), 1);
172+end
173+
174+function v = smoothstep(e0, e1, x)
175+% 0 below e0, 1 above e1, a smooth cubic between, with zero slope at both.
176+t = ramp(e0, e1, x);
177+v = t.^2 .* (3 - 2*t);
178+end
179+
180+function v = dome(d, radius, height)
181+% A cosine dome of the given height and base radius, with zero slope both at
182+% its rim and at its apex; d is the distance from its center.
183+v = 0.5 * height * (1 + cos(pi * d / radius)) .* (d < radius);
184+end
185+
186+function v = dents(x, y, cx, cy, radius, height)
187+% The sum of Gaussian bumps centered at (cx(k), cy(k)), each of the given
188+% height and falling to 1/e of it at the given radius.
189+v = zeros(size(x));
190+for k = 1:numel(cx)
191+ d2 = (x - cx(k)).^2 + (y - cy(k)).^2;
192+ v = v + height(k) * exp(-d2 / radius(k)^2);
193+end
194+end
195+
196+function u = seededrand(seed, n)
197+% n uniform numbers in [0, 1) from a small seeded generator, the same on
198+% every run and every machine.
199+s = floor(seed);
200+u = zeros(1, n);
201+for k = 1:n
202+ s = mod(s * 1664525 + 1013904223, 4294967296);
203+ u(k) = s / 4294967296;
204+end
205+end
206+
207+function f = noise2d(seed, len)
208+% A smooth random field f(x, y) of unit RMS with the covariance
209+% exp(-d^2 / len^2), the same for a given seed on any grid: a sum of plane
210+% waves whose wavevectors are drawn from the matching Gaussian spectrum.
211+n = 64;
212+u = reshape(seededrand(seed, 5*n), 5, n);
213+kx = sqrt(-2*log(1 - u(1,:))) .* cos(2*pi*u(2,:)) * sqrt(2) / len;
214+ky = sqrt(-2*log(1 - u(3,:))) .* cos(2*pi*u(4,:)) * sqrt(2) / len;
215+ph = 2*pi*u(5,:);
216+f = @(x, y) noisesum(kx, ky, ph, x, y);
217+end
218+
219+function v = noisesum(kx, ky, ph, x, y)
220+v = zeros(size(x));
221+for k = 1:numel(kx)
222+ v = v + cos(kx(k) * x + ky(k) * y + ph(k));
223+end
224+v = v / sqrt(numel(kx) / 2);
225+end
133226 `;
134227
135228 const ride20 = `% A 20 inch ride: heavier bronze, a larger bell, a flatter bow. The taper
136229 % is stronger than on the crash, and the mount is a little stiffer.
137-D = 20*inch; R = D/2;
230+inch = 0.0254; mm = 1e-3;
231+D = 20*inch; R = D/2; hole = 12*mm;
138232 bellR = 70*mm; bellH = 14*mm;
139233 bowH = 15*mm;
234+tBell = 2.0*mm; tEdge = 1.1*mm;
235+
140236 design.name = '20 inch ride';
141237 design.material.E = 110e9;
142238 design.material.nu = 0.34;
143239 design.material.rho = 8800;
144240 design.bounds.width = D;
145241 design.bounds.height = D;
146-design.inside = circle(D, 12*mm);
147-design.thickness = @(x, y, r, theta) lerp(2.0*mm, 1.1*mm, smoothstep(bellR, R, r));
242+design.inside = @(x, y, r, theta) (r <= R) & (r >= hole/2);
243+design.thickness = @(x, y, r, theta) tBell + (tEdge - tBell) * smoothstep(bellR, R, r);
148244 design.profile = @(x, y, r, theta) bowH * (1 - (r/R).^2) + dome(r, bellR, bellH);
149245 design.mount.radius = 22*mm;
150246 design.mount.stiffness = 3e4;
@@ -152,52 +248,80 @@ design.mount.damping = 8;
152248 design.loss.sigma0 = 0.5;
153249 design.loss.sigma1 = 2e-4;
154250 design.loss.sigmaIn = 20;
251+
252+function v = smoothstep(e0, e1, x)
253+% 0 below e0, 1 above e1, a smooth cubic between, with zero slope at both.
254+t = min(max((x - e0) ./ (e1 - e0), 0), 1);
255+v = t.^2 .* (3 - 2*t);
256+end
257+
258+function v = dome(d, radius, height)
259+% A cosine dome of the given height and base radius, with zero slope both at
260+% its rim and at its apex; d is the distance from its center.
261+v = 0.5 * height * (1 + cos(pi * d / radius)) .* (d < radius);
262+end
155263 `;
156264
157265 const ellipseTwist = `% A steel ellipse with a saddle (twist) and a bow, hanging freely. The
158266 % twist raises two opposite quadrants and lowers the other two, which
159267 % breaks the symmetry of the disc modes.
268+inch = 0.0254; mm = 1e-3;
160269 W = 14*inch; H = 20*inch;
270+
161271 design.name = 'Steel ellipse with a twist';
162272 design.material.E = 170e9;
163273 design.material.nu = 0.29;
164274 design.material.rho = 7900;
165275 design.bounds.width = W;
166276 design.bounds.height = H;
167-design.inside = ellipse(W, H);
168-design.thickness = 0.79*mm;
169277 % 2x/W and 2y/H run from -1 to 1 over the bounding box.
278+design.inside = @(x, y, r, theta) (2*x/W).^2 + (2*y/H).^2 <= 1;
279+design.thickness = 0.79*mm;
170280 design.profile = @(x, y, r, theta) 2*mm * (2*x/W) .* (2*y/H) + bow(y, H, 2*mm);
171281 design.mount = [];
172282 design.loss.sigma0 = 1.15;
173283 design.loss.sigma1 = 5e-4;
174284 design.loss.sigmaIn = 20;
285+
286+function v = bow(s, span, sag)
287+% A parabola of the given sag across a span centered on 0.
288+v = sag * (1 - (2*s/span).^2);
289+end
175290 `;
176291
177292 const china18 = `% An 18 inch china: a conical bow that falls from the bell toward a flange,
178293 % and an upturned edge that rises again over the outer 45 mm. The bell is
179294 % square-topped (a plateau with rounded shoulders). The edge slope is well
180295 % beyond the shallow-shell range, so this preset is the least trustworthy.
181-D = 18*inch; R = D/2;
296+inch = 0.0254; mm = 1e-3;
297+D = 18*inch; R = D/2; hole = 12*mm;
182298 bellR = 65*mm; bellH = 14*mm;
183299 flangeR = R - 45*mm; bowH = 14*mm; edgeH = 12*mm;
300+tBell = 1.6*mm; tEdge = 0.9*mm;
301+
184302 design.name = '18 inch china';
185303 design.material.E = 110e9;
186304 design.material.nu = 0.34;
187305 design.material.rho = 8800;
188306 design.bounds.width = D;
189307 design.bounds.height = D;
190-design.inside = circle(D, 12*mm);
191-design.thickness = @(x, y, r, theta) lerp(1.6*mm, 0.9*mm, smoothstep(bellR, flangeR, r));
192-design.profile = @(x, y, r, theta) bowH * max(0, 1 - r/flangeR) ... % cone down to the flange
193- + edgeH * smoothstep(flangeR, R, r) ... % upturned edge
194- + bellH * (1 - smoothstep(bellR - 20*mm, bellR, r)); % flat-topped bell
308+design.inside = @(x, y, r, theta) (r <= R) & (r >= hole/2);
309+design.thickness = @(x, y, r, theta) tBell + (tEdge - tBell) * smoothstep(bellR, flangeR, r);
310+design.profile = @(x, y, r, theta) bowH * max(0, 1 - r/flangeR) ... % cone down to the flange
311+ + edgeH * smoothstep(flangeR, R, r) ... % upturned edge
312+ + bellH * (1 - smoothstep(bellR - 20*mm, bellR, r)); % flat-topped bell
195313 design.mount.radius = 20*mm;
196314 design.mount.stiffness = 2e4;
197315 design.mount.damping = 5;
198316 design.loss.sigma0 = 0.7;
199317 design.loss.sigma1 = 3e-4;
200318 design.loss.sigmaIn = 20;
319+
320+function v = smoothstep(e0, e1, x)
321+% 0 below e0, 1 above e1, a smooth cubic between, with zero slope at both.
322+t = min(max((x - e0) ./ (e1 - e0), 0), 1);
323+v = t.^2 .* (3 - 2*t);
324+end
201325 `;
202326
203327 export const PRESETS: Preset[] = [
src/style.cssmodified+21−4View file
@@ -14,15 +14,29 @@
1414 html, body { margin: 0; height: 100%; background: var(--bg); color: var(--text); }
1515 #app { display: flex; height: 100vh; }
1616 #sidebar {
17- width: 420px; flex: none; overflow-y: auto; padding: 12px 14px; background: var(--panel);
18- border-right: 1px solid var(--border);
17+ width: 420px; flex: none; min-width: 0; display: flex; flex-direction: column;
18+ background: var(--panel);
1919 }
20+/* The head does not scroll, so Start stays in reach however long the script is. */
21+#sidebarHead { flex: none; padding: 10px 14px 8px; border-bottom: 1px solid var(--border); }
22+#sidebarBody { flex: 1; overflow-y: auto; padding: 10px 14px 16px; }
23+#splitter {
24+ flex: none; width: 6px; cursor: col-resize; background: var(--border);
25+ border-left: 1px solid var(--bg); border-right: 1px solid var(--bg);
26+}
27+#splitter:hover, #splitter.dragging { background: var(--accent2); }
28+body.resizing { cursor: col-resize; user-select: none; }
2029 #main { flex: 1; display: flex; flex-direction: column; min-width: 0; padding: 8px; gap: 6px; }
21-h1 { font-size: 17px; margin: 0 0 4px; }
30+h1 { font-size: 15px; margin: 0 0 6px; }
2231 .sub { color: var(--muted); margin: 0 0 8px; line-height: 1.35; }
2332 a { color: var(--accent2); }
2433 details { border-top: 1px solid var(--border); padding: 4px 0; }
2534 details summary { cursor: pointer; font-size: 12px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); padding: 4px 0; }
35+/* Explanatory prose, folded away by default so the panel stays a panel. */
36+details.blurb { border-top: none; padding: 0; margin: 2px 0; }
37+details.blurb > summary { text-transform: none; letter-spacing: 0; color: var(--muted); padding: 2px 0; }
38+details.blurb > summary:hover { color: var(--text); }
39+details.blurb > .sub { margin: 4px 0 6px; }
2640 .field { display: grid; grid-template-columns: 1fr 92px 46px; align-items: center; gap: 6px; margin: 3px 0; }
2741 .field label { color: var(--text); }
2842 .field .unit { color: var(--muted); font-size: 11px; }
@@ -37,7 +51,10 @@ button:hover { background: #343941; }
3751 button:disabled { opacity: 0.45; cursor: default; }
3852 button.primary { background: var(--accent); color: #1a1500; border-color: var(--accent); font-weight: 600; }
3953 button.primary:hover { background: #f8c453; }
40-#controls { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0; border-top: 1px solid var(--border); padding-top: 8px; }
54+#controls { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
55+#controls .field { display: flex; align-items: center; gap: 6px; width: auto; flex: 1 1 100%; }
56+#controls .field label { flex: none; }
57+#controls .field input { width: 80px; flex: none; }
4158 #info { margin-top: 10px; border-top: 1px solid var(--border); padding-top: 8px; }
4259 .row { display: flex; justify-content: space-between; gap: 8px; padding: 1px 0; }
4360 .row span:last-child { font-variant-numeric: tabular-nums; color: var(--text); text-align: right; }
src/ui/editor.tsmodified+9−4View file
@@ -11,7 +11,6 @@ import { octave } from "@codemirror/legacy-modes/mode/octave";
1111 import { tags } from "@lezer/highlight";
1212 import { type Design, DesignError, compileDesign } from "../design.ts";
1313 import { DEFAULT_PRESET, PRESETS } from "../presets.ts";
14-import { HELPER_NAMES } from "../mhelpers.ts";
1514
1615 const CUSTOM = "custom";
1716 const DEBOUNCE_MS = 400;
@@ -128,9 +127,15 @@ export class DesignEditor {
128127
129128 this.status.className = "editor-status sub";
130129 this.summary.className = "editor-summary";
131- const hint = document.createElement("p");
132- hint.className = "sub";
133- hint.textContent = `MATLAB syntax (run by numbl). The script fills in the struct design: material, bounds, inside, thickness, profile, mount, and loss, in SI units; it is applied as you type (or with Ctrl+Enter). The thickness, profile, and inside fields are numbers or handles @(x, y, r, theta) applied to whole grid matrices, so use .*, ./, .^ in them. Helpers: ${HELPER_NAMES.join(", ")}.`;
130+ // How to write a script: useful once, in the way every time, so it folds away.
131+ const hint = document.createElement("details");
132+ hint.className = "blurb";
133+ const hintSum = document.createElement("summary");
134+ hintSum.textContent = "Writing a script";
135+ const hintText = document.createElement("p");
136+ hintText.className = "sub";
137+ hintText.textContent = "MATLAB syntax (run by numbl). The script fills in the struct design: material, bounds, inside, thickness, profile, mount, and loss, in SI units; it is applied as you type (or with Ctrl+Enter). The thickness, profile, and inside fields are numbers or handles @(x, y, r, theta) applied to whole grid matrices, so use .*, ./, .^ in them. Each script is self-contained: nothing is in scope but numbl's own functions, so define the shapes you need as ordinary MATLAB functions at the bottom of the script.";
138+ hint.append(hintSum, hintText);
134139 root.append(head, wrap, this.status, this.summary, hint);
135140 }
136141
src/ui/form.tsmodified+9−2View file
@@ -1,6 +1,7 @@
11 // Parameter form for the strike, the listener, and the numerics: numeric
22 // fields in convenient units and a few selects, with derived-quantity notes
3-// that depend on the current plate. Everything is stored in SI in `params`.
3+// that depend on the current plate, folded away under "Notes". Everything is
4+// stored in SI in `params`.
45
56 import {
67 type Plate,
@@ -111,11 +112,17 @@ export class ParamForm {
111112 if (f.live) this.liveKeys.add(f.key);
112113 }
113114 if (g.note) {
115+ // The derived quantities are prose, so they are folded away by default.
116+ const fold = document.createElement("details");
117+ fold.className = "blurb";
118+ const sum2 = document.createElement("summary");
119+ sum2.textContent = "Notes";
114120 const note = document.createElement("p");
115121 note.className = "sub";
122+ fold.append(sum2, note);
116123 if (g.note === "strike") this.strikeNote = note;
117124 else if (g.note === "grid") this.gridNote = note;
118- det.appendChild(note);
125+ det.appendChild(fold);
119126 }
120127 root.appendChild(det);
121128 }
tests/design.test.tsmodified+83−25View file
@@ -1,5 +1,5 @@
1-// Tests of the design script: the MATLAB-syntax helpers, compilation and
2-// the checks, the evaluation on the grid, and the presets.
1+// Tests of the design script: the functions a script defines for itself,
2+// compilation and the checks, the evaluation on the grid, and the presets.
33
44 import { test } from "node:test";
55 import assert from "node:assert/strict";
@@ -31,36 +31,68 @@ test("a minimal script compiles with defaults filled in", () => {
3131 assert.ok(Math.abs(d.Lx - 0.3) < 1e-12 && Math.abs(d.Ly - 0.2) < 1e-12);
3232 });
3333
34-test("the helpers are in scope, vectorized, and in SI", () => {
34+test("a script may define its own functions, and they are vectorized", () => {
3535 const v = runDesignScript(`
36-a = 10*inch; b = 100*mm;
37-t1 = lerp(2*mm, 1*mm, clamp(0.05/0.1, 0, 1));
38-t2 = lerp(2*mm, 1*mm, clamp(0.2/0.1, 0, 1));
39-p0 = dome(0, 0.05, 0.01) + bump(0, 0.02, 0.001) + bow(0, 0.254, 0.002);
40-p1 = dome(0.06, 0.05, 0.01) + bump(0.06, 0.02, 0.001);
36+mm = 1e-3;
4137 s = [smoothstep(0, 1, -1), smoothstep(0, 1, 2), smoothstep(0, 1, 0.5)];
42-f = circle(0.2, 0.03);
38+p0 = dome(0, 0.05, 0.01);
39+p1 = dome(0.06, 0.05, 0.01);
4340 [X, Y] = meshgrid(-0.1:0.05:0.1, -0.1:0.05:0.1);
44-inside = f(X, Y, hypot(X, Y), atan2(Y, X));
45-dd = dents(X, Y, [0 0.05], [0 0], 0.02, [1e-3 -1e-3]);
41+dd = dents(X, Y, [0 0.05], [0 0], [0.02 0.02], [1e-3 -1e-3]);
4642 u = seededrand(5, 3); u2 = seededrand(5, 3);
43+
44+function v = ramp(a, b, x)
45+v = min(max((x - a) ./ (b - a), 0), 1);
46+end
47+function v = smoothstep(e0, e1, x)
48+t = ramp(e0, e1, x);
49+v = t.^2 .* (3 - 2*t);
50+end
51+function v = dome(d, radius, height)
52+v = 0.5 * height * (1 + cos(pi * d / radius)) .* (d < radius);
53+end
54+function v = dents(x, y, cx, cy, radius, height)
55+v = zeros(size(x));
56+for k = 1:numel(cx)
57+ v = v + height(k) * exp(-((x - cx(k)).^2 + (y - cy(k)).^2) / radius(k)^2);
58+end
59+end
60+function u = seededrand(seed, n)
61+s = floor(seed);
62+u = zeros(1, n);
63+for k = 1:n
64+ s = mod(s * 1664525 + 1013904223, 4294967296);
65+ u(k) = s / 4294967296;
66+end
67+end
4768 `);
48- assert.ok(Math.abs(num(v, "a") - 0.254) < 1e-12 && Math.abs(num(v, "b") - 0.1) < 1e-12);
49- assert.ok(Math.abs(num(v, "t1") - 1.5e-3) < 1e-12);
50- assert.ok(Math.abs(num(v, "t2") - 1e-3) < 1e-12);
51- assert.ok(Math.abs(num(v, "p0") - (0.01 + 0.001 + 0.002)) < 1e-12);
52- assert.ok(Math.abs(num(v, "p1") - 0.001 * Math.exp(-9)) < 1e-15);
53- assert.deepEqual(Array.from(data(v, "s")), [0, 1, 0.5]);
54- const inside = data(v, "inside"); // 5 x 5, column-major; center at index 12, corner at 0, hole at the center
55- assert.equal(inside[12], 0, "the center lies in the hole");
56- assert.equal(inside[0], 0, "the corner lies outside");
57- assert.equal(inside[2], 1, "the point (-0.1, 0) lies on the rim");
58- const dd = data(v, "dd");
69+ assert.deepEqual(Array.from(data(v, "s")), [0, 1, 0.5], "one local function may call another");
70+ assert.ok(Math.abs(num(v, "p0") - 0.01) < 1e-12);
71+ assert.equal(num(v, "p1"), 0, "the dome vanishes beyond its rim");
72+ const dd = data(v, "dd"); // 5 x 5, column-major; the center sits at index 12
5973 assert.ok(Math.abs(dd[12] - (1e-3 - 1e-3 * Math.exp(-(0.05 ** 2) / 0.02 ** 2))) < 1e-15, "two dents of opposite sign");
60- assert.deepEqual(Array.from(data(v, "u")), Array.from(data(v, "u2")), "seededrand is deterministic");
74+ assert.deepEqual(Array.from(data(v, "u")), Array.from(data(v, "u2")), "the seeded generator is deterministic");
6175 assert.ok(data(v, "u").every((x) => x >= 0 && x < 1));
6276 });
6377
78+test("a handle may call a function the script defines below it", () => {
79+ // The trailer that samples the fields is appended after the script's own
80+ // functions, and it calls the handles from inside a function of its own.
81+ const d = compileDesign(`design.material.E = 170e9; design.material.nu = 0.29; design.material.rho = 7900;
82+design.bounds.width = 0.2; design.bounds.height = 0.2;
83+t0 = 1e-3;
84+design.thickness = @(x, y, r, theta) t0 * (1 + taper(r));
85+design.inside = @(x, y, r, theta) r <= 0.1;
86+
87+function v = taper(r)
88+v = min(max(r / 0.1, 0), 1);
89+end
90+`);
91+ const pl = evaluatePlate(d, 0.01);
92+ const hr = thicknessRange(pl);
93+ assert.ok(Math.abs(hr.min - 1e-3) < 2e-4 && Math.abs(hr.max - 2e-3) < 1e-4, `thickness ${hr.min}..${hr.max}`);
94+});
95+
6496 test("scripts that do not describe a plate are rejected with a message", () => {
6597 const bad: [string, RegExp][] = [
6698 [`x = 5;`, /struct named design/],
@@ -98,7 +130,7 @@ test("evaluation rejects fields that are not positive, not finite, not vectorize
98130 test("a circle with a hole excludes the center and the corners", () => {
99131 const d = compileDesign(`design.material.E = 170e9; design.material.nu = 0.3; design.material.rho = 7900;
100132 design.bounds.width = 0.2; design.bounds.height = 0.2; design.thickness = 1e-3;
101-design.inside = circle(0.2, 0.03);`);
133+design.inside = @(x, y, r, theta) (r <= 0.1) & (r >= 0.015);`);
102134 const pl = evaluatePlate(d, 0.01);
103135 const g = pl.grid;
104136 assert.equal(g.nx, 21);
@@ -157,13 +189,39 @@ test("removePlane leaves a plane-free field unchanged", () => {
157189 for (let k = 0; k < g.n; k++) assert.ok(Math.abs(f[k] - before[k] - shift) < 1e-12);
158190 });
159191
160-test("noise2d is deterministic with unit RMS", () => {
192+test("the presets' noise field is deterministic with unit RMS", () => {
193+ // The same generator the dented crash uses, as a script-local function
194+ // returning a handle that calls another script-local function.
161195 const v = runDesignScript(`
162196 a = noise2d(7, 0.05); b = noise2d(7, 0.05); c = noise2d(8, 0.05);
163197 [X, Y] = meshgrid(-0.5:0.01:0.49, -0.5:0.01:0.49);
164198 A = a(X, Y); B = b(X, Y); C = c(X, Y);
165199 same = isequal(A, B); different = ~isequal(A, C);
166200 rms = sqrt(mean(A(:).^2));
201+
202+function u = seededrand(seed, n)
203+s = floor(seed);
204+u = zeros(1, n);
205+for k = 1:n
206+ s = mod(s * 1664525 + 1013904223, 4294967296);
207+ u(k) = s / 4294967296;
208+end
209+end
210+function f = noise2d(seed, len)
211+n = 64;
212+u = reshape(seededrand(seed, 5*n), 5, n);
213+kx = sqrt(-2*log(1 - u(1,:))) .* cos(2*pi*u(2,:)) * sqrt(2) / len;
214+ky = sqrt(-2*log(1 - u(3,:))) .* cos(2*pi*u(4,:)) * sqrt(2) / len;
215+ph = 2*pi*u(5,:);
216+f = @(x, y) noisesum(kx, ky, ph, x, y);
217+end
218+function v = noisesum(kx, ky, ph, x, y)
219+v = zeros(size(x));
220+for k = 1:numel(kx)
221+ v = v + cos(kx(k) * x + ky(k) * y + ph(k));
222+end
223+v = v / sqrt(numel(kx) / 2);
224+end
167225 `);
168226 assert.equal(v.same, true);
169227 assert.equal(v.different, true);