/ proof-of-concept / plate-dynamics-lab
Sign in
proof-of-concept / plate-dynamics-lab
Plate dynamics lab: plates in realizable mounts under gravity, explicit finite differences on WebGPU
Successor of plate-mechanics-sim. One thread, two threads, pads, cymbal stand, vise and pinched mounts; gravity through the normal load and a plane-stress self-weight prestress whose rigid rotation gives the pendulum stiffness exactly; one-sided contacts with an active-set static solve; rigid-body coordinates plus a flexural field carried separately, with the operators applied in factored form on the GPU so the smooth modes survive single precision; baffled and unbaffled sound; scenario presets; a WebGL view of the plate in its mount. The .m files remain the source of truth and the tests compare the WebGPU loop with the numbl loop for every mount.
Jeremy Magland <jeremy.magland@gmail.com> committed commit 80c0b6dd3799 Browse files
101 changed files+16510−0
.gitignoreadded+4−0View file
@@ -0,0 +1,4 @@
1+node_modules/
2+dist/
3+*.tsbuildinfo
4+.DS_Store
.mochi/workflows/deploy.ymladded+69−0View file
@@ -0,0 +1,69 @@
1+name: Deploy the lab
2+
3+# configure-pages runs before the build rather than after it, because the build
4+# needs the base path it reports: a mochi site is served at the root of its own
5+# origin when the repository has a site label, and under /<collection>/<repo>/site/
6+# when it does not, and never at /<repo>/.
7+
8+on:
9+ push:
10+ branches: [main]
11+ workflow_dispatch:
12+
13+permissions:
14+ contents: read
15+ pages: write
16+ id-token: write
17+
18+concurrency:
19+ group: pages
20+ cancel-in-progress: false
21+
22+jobs:
23+ build:
24+ runs-on: ubuntu-latest
25+ steps:
26+ - name: Checkout
27+ uses: actions/checkout@v4
28+
29+ - name: Setup Node
30+ uses: actions/setup-node@v4
31+ with:
32+ node-version: 22
33+
34+ - name: Setup Pages
35+ id: pages
36+ uses: actions/configure-pages@v5
37+
38+ - name: Install
39+ run: npm ci
40+
41+ - name: Typecheck
42+ run: npm run typecheck
43+
44+ # Runs the .m library under numbl in Node: the pendulum identity, the statics,
45+ # the CFL bound, every scenario. The GPU comparison skips itself without a
46+ # device. A regression here should not deploy.
47+ - name: Test
48+ run: npm test
49+
50+ - name: Build the lab
51+ env:
52+ DEPLOY_BASE: ${{ steps.pages.outputs.base_path }}
53+ run: npm run build -w apps/lab
54+
55+ - name: Upload artifact
56+ uses: actions/upload-pages-artifact@v3
57+ with:
58+ path: apps/lab/dist
59+
60+ deploy:
61+ needs: build
62+ runs-on: ubuntu-latest
63+ environment:
64+ name: github-pages
65+ url: ${{ steps.deployment.outputs.page_url }}
66+ steps:
67+ - name: Deploy to Pages
68+ id: deployment
69+ uses: actions/deploy-pages@v4
.prettierrcadded+1−0View file
@@ -0,0 +1 @@
1+{ "printWidth": 100, "singleQuote": false, "trailingComma": "all" }
README.mdadded+94−0View file
@@ -0,0 +1,94 @@
1+# Plate Dynamics Lab
2+
3+An interactive lab for the dynamics of a thin metal plate held the way a plate can actually be held on a bench: hung from a thread or two, resting on foam or felt pads, on a cymbal stand, gripped in a vise, or pinched between finger and thumb. Gravity is part of the model. A hung plate swings on its thread and rocks about its hole while it rings; a plate on pads sags into them and can be knocked off them; a cantilever droops and then vibrates about its droop. You choose the plate (a rectangle or an ellipse), the material, the mount and its orientation, and strike the plate or pull it aside and let go; the app shows the plate moving in its mount, the flexural displacement and surface stress fields, the rigid-body swing and the support forces over time, a probe time series and spectrum, and the radiated sound, which you can play or save.
4+
5+The numerics run in [numbl](https://github.com/flatironinstitute/numbl), a MATLAB-syntax environment executing in the browser. The MATLAB source shown on screen is the source that ran: edit any file and rerun. The time loop, which is hundreds of thousands of steps for a run of a few seconds, has a WebGPU port that is on by default; the setup, the statics, the stresses, the sound and the spectra run in numbl in double precision, and the double-precision loop in numbl remains the reference and the fallback. No physics is computed in JavaScript outside that port; the host draws arrays and plays samples.
6+
7+This lab succeeds [plate-mechanics-sim](../plate-mechanics-sim), keeping its shapes, materials, source view and result panels, and replacing its idealized edge conditions with mounts, its several methods with one explicit scheme, and its millisecond windows with seconds.
8+
9+## Layout
10+
11+```
12+packages/core problem spec, .m generation, numbl runner, WebGPU time loop, result typing, field primitives (no UI)
13+packages/core/src/m the MATLAB library: plate_fd.m, plate_model.m, plate_mount.m, plate_prestress.m, ...
14+packages/components React components: code view, parameter panel, 2-D field view, 3-D scene, plots, equations, run status, audio
15+apps/lab the app: one arrangement of the components
16+```
17+
18+## The model
19+
20+The plate is a damped Kirchhoff-Love plate in its own coordinates, with two extra terms that carry gravity:
21+
22+ mu w_tt + 2 mu sigma0 w_t - 2 mu sigma1 lap(w_t) + D lap(lap(w)) - div(N grad w) = f + mu g_n + f_mount
23+
24+The normal component of gravity, `mu g_n`, loads the plate transversely and gives a horizontal plate its static sag. The in-plane component puts the plate under its own weight, and `N` is the resulting in-plane stress resultant, found once at setup by a plane-stress solve with the mount's constraints (the hole a thread passes through, the jaws of a vise, the pinched patch). The membrane term `div(N grad w)` is how gravity acts on a hanging plate's motion: a tilted element under tension is pulled back. For a rigid rotation of the plate about its hole the term integrates to the compound-pendulum stiffness `m g d` exactly, and the lab assembles it with the same difference operators as the plane-stress energy, so this identity holds to rounding rather than to truncation error (`packages/core/test/fd.test.ts` checks it). The swing of a hung plate and the small stiffening of its lowest flexural modes therefore come from one operator, and a plate hung from below its centre of mass is correctly unstable.
25+
26+Each mount is data on the grid (`plate_mount.m`):
27+
28+- **One thread.** A plate hung from one point turns in its plane until its centre of mass is straight below the hole, which fixes the direction of gravity. For small out-of-plane motion the thread is a transverse spring of stiffness `T/L` at the hole (the linearized pendulum), plus a torsional spring `G J / L` about the vertical, which for any real thread is so weak that the spin has a period of minutes.
29+- **Two threads** of one length from hooks at one height: the line of the holes hangs horizontal, statics shares the weight, and the lab refuses a geometry in which a thread would go slack.
30+- **Pads.** Rectangular patches of a stiffness and damping per unit area, one-sided: a pad pushes while compressed and lets go when the plate lifts off. Which pads carry the plate at rest is found by an active-set iteration.
31+- **Cymbal stand.** A felt washer below and one above the centre, each preloaded by the wing nut; the rod, the hole and the cymbal's bow are not modelled.
32+- **Vise.** The nodes within the jaw depth are held at zero; the clamped condition follows from the bending energy (a second difference centred on a held node penalizes the slope at the clamp). Any tilt from hanging down (0°) through horizontal (90°) to standing up (180°, self-weight in compression).
33+- **Fingers.** A two-sided spring and dashpot over a patch, also fixed in the plane; hanging (0°) to flat (90°).
34+
35+Every quantity of the model has an equation in the app's Equations tab, and every idealization a line in its validity notes.
36+
37+## The scheme
38+
39+The spatial operators are the sparse energy-method matrices of `plate_bending.m` (the 13-point biharmonic stencil inside, the free-edge conditions as natural conditions on the edges, held nodes dropped) and `plate_prestress.m`. The time stepping is the second-order explicit scheme of the old lab, with two changes.
40+
41+First, the state is the departure from the static equilibrium, split into three rigid-body coordinates (a translation along the normal at the mount's pivot, and the rotations about the horizontal and the vertical in-plane axes through it) and a flexural remainder that is mass-orthogonal to them (`plate_rigid_basis.m`). The rigid coordinates obey three scalar equations with the exact reduced stiffness `R' A R` (the pendulum, the thread springs, the thread's torsion) and are coupled to the flexural field through `A R`; the flexural field obeys the grid equation with the rigid acceleration projected out. The split is exact, and it is what keeps a millimetre swing and a micrometre vibration apart in single precision: bending annihilates the rigid basis, so the bending operator is never applied to the swing.
42+
43+Second, the stable step is the von Neumann bound of the bending scheme extended by a Gershgorin bound on the extra stiffness of the prestress, the springs and the engaged contacts (`plate_model.m`), and the dashpots, treated explicitly, keep the step below half of their own limit. A run of a few seconds on the default 41 x 27 grid is about 650,000 steps.
44+
45+Contacts are evaluated at the current step, one-sided, with the static part subtracted so that the forces are linear in the departure until a contact opens. A release starts from the static deflection under a force at the pull point, scaled to the asked displacement and split into rigid and flexural parts.
46+
47+## The GPU loop
48+
49+`packages/core/src/gpu/fdLoop.ts` is `plate_fd_loop.m` as compute shaders: five dispatches per step (strains, forces, the rigid-body update with the reductions it needs, the field update, and the audio and blow-up reductions). The setup (`plate_fd_setup.m`) and the output (`plate_fd_output.m`) run in numbl, unchanged and in double precision; the worker hands the setup struct to the GPU and the loop's record back. Editing `plate_fd_loop.m` sends the loop back to numbl, where the edit is live, and `packages/core/test/gpu.test.ts` compares the port with the numbl loop for every mount.
50+
51+The port applies the operators in their factored form, a difference stencil, then weights, then the transposed stencil, rather than as the assembled sparse matrix. In single precision the assembled 13-point stencil loses the smooth modes: the biharmonic of a smooth field is a small residual of large cancelling terms, and the rounding of the field alone is a few percent of it at low wavenumbers, which is where a long run lives. In factored form each stencil is exact integers applied to nearby values, the scales sit in the weights where rounding is harmless, and the port agrees with the double-precision loop to a relative L2 error of about 1e-6 in the fields, the rigid coordinates and the support forces over the test windows.
52+
53+Cost. On an integrated Intel GPU the default run (a 300 x 200 mm sheet on a 41 x 27 grid for 3 s, about 700,000 steps) takes between 15 and 50 s in the loop, the spread being the GPU's own load, plus about 1.5 s in numbl for the setup and the output; a 61 x 41 grid for 1 s takes about a minute. The same loop in numbl runs at roughly 0.8 us per cell-step, so the default run would take some ten minutes there; the estimate shown before a run is calibrated by the runs before it.
54+
55+## Scenarios
56+
57+The scenario picker sets everything at once; every field stays editable afterwards.
58+
59+- Steel sheet on a thread (the default): sways at about 0.8 Hz, rocks about the hole at about 2.9 Hz, spins slowly, and rings.
60+- Gong on two cords: a bronze disk, struck at the centre.
61+- Glockenspiel bar on nodal pads: felt strips under the nodes of the first mode.
62+- Sheet on four foam blocks: a hard strike lifts it off the pads.
63+- Cymbal on a stand: preloaded felts at the centre of a flat bronze disk.
64+- Cantilever in a vise: a steel strip pulled down 10 mm and released.
65+- Disk pinched in the hand: a soft, lossy grip near the rim.
66+
67+## Running
68+
69+```bash
70+npm install
71+npm run dev # the lab, http://localhost:5173
72+npm test # vitest: the .m files in numbl under Node, plus the GPU loop against the numbl loop when a WebGPU device is available
73+npm run typecheck
74+npm run build
75+```
76+
77+`packages/core/test/gpu.test.ts` runs the WebGPU loop from Node against desktop WebGPU through the optional `webgpu` package (prebuilt Google Dawn); `npm i --no-save webgpu` installs it. Without the package or a GPU adapter the suite skips itself.
78+
79+The `.m` files also run unchanged from the numbl CLI, Octave or MATLAB:
80+
81+```bash
82+npx numbl run --path packages/core/src/m my_script.m
83+```
84+
85+## Honest limits
86+
87+- Kirchhoff-Love thin-plate theory: no shear deformation or rotary inertia, so thick plates and very high modes are wrong.
88+- Small-angle theory throughout. The threads are linear springs, the pendulum motion is linearized, and the plate's in-plane motion (a swing in its own plane, a rotation about the normal) is not part of the model. A pull beyond a few degrees of swing leaves this regime; the spin about a thread, which has no gravitational stiffness, is exact at any angle and is drawn as a true rotation.
89+- Mounts are idealizations with order-of-magnitude constants: a thread has one tension, a pad is a spring and dashpot per unit area, a vise is perfectly rigid, a hand is a guess.
90+- Free edges and curved edges are first order at the boundary; the stress at a hole or a jaw is that of a point or line constraint on the grid and depends on the spacing.
91+- Damping is the two-parameter loss model plus the mount's dashpots, chosen, not measured; the sigma1 loss acts on the flexural part only.
92+- The probe series are averages over each output sample; stress fields are evaluated only at the saved frames.
93+- Radiated sound is an estimate of the waveform, not a calibrated level: Rayleigh's integral in a baffle, or a rough front-back cancellation for an unbaffled plate.
94+- The GPU loop is single precision. The rigid-flexural split and the factored operators keep it close to the double-precision loop, but a run of many seconds accumulates rounding that the tests over tens of milliseconds do not measure.
apps/lab/index.htmladded+12−0View file
@@ -0,0 +1,12 @@
1+<!doctype html>
2+<html lang="en">
3+ <head>
4+ <meta charset="UTF-8" />
5+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+ <title>Plate Dynamics Lab</title>
7+ </head>
8+ <body>
9+ <div id="root"></div>
10+ <script type="module" src="/src/main.tsx"></script>
11+ </body>
12+</html>
apps/lab/package.jsonadded+18−0View file
@@ -0,0 +1,18 @@
1+{
2+ "name": "@plate-dyn/lab",
3+ "version": "0.1.0",
4+ "private": true,
5+ "type": "module",
6+ "scripts": {
7+ "dev": "vite",
8+ "build": "vite build",
9+ "preview": "vite preview",
10+ "typecheck": "tsc -p tsconfig.json --noEmit"
11+ },
12+ "dependencies": {
13+ "@plate-dyn/components": "*",
14+ "@plate-dyn/core": "*",
15+ "react": "^19.2.0",
16+ "react-dom": "^19.2.0"
17+ }
18+}
apps/lab/src/App.tsxadded+512−0View file
@@ -0,0 +1,512 @@
1+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2+import {
3+ CostModel,
4+ FD_METHOD,
5+ LIBRARY_FILES,
6+ SCENARIO_PRESETS,
7+ buildGpuStages,
8+ buildPlanBundle,
9+ buildRunBundle,
10+ cloneSpec,
11+ defaultSpec,
12+ methodBlockers,
13+ parseMethodPlan,
14+ parsePlateResult,
15+ validateSpec,
16+ withEdits,
17+ type BackendInfo,
18+ type MethodPlan,
19+ type ProblemSpec,
20+} from "@plate-dyn/core";
21+import {
22+ CodeView,
23+ EquationsView,
24+ ParameterPanel,
25+ RunStatus,
26+ useRunner,
27+ useSolve,
28+ useWebGpuSupport,
29+ type ParameterSection,
30+} from "@plate-dyn/components";
31+import { ResultsView } from "./views/ResultsView";
32+import { clearPersistentState, usePersistentState } from "./usePersistentState";
33+
34+type RightTab = "equations" | "source" | "run";
35+
36+const RIGHT_TABS: { id: RightTab; label: string }[] = [
37+ { id: "equations", label: "Equations" },
38+ { id: "source", label: "Source" },
39+ { id: "run", label: "Run details" },
40+];
41+
42+const IDLE = { status: "idle" } as const;
43+const method = FD_METHOD;
44+
45+/** What is on the bench: the settings a first run needs. */
46+const PROBLEM_SECTIONS: ParameterSection[] = ["plate", "material", "mount", "excitation"];
47+/** Reasonable defaults, rarely the point. */
48+const ADVANCED_SECTIONS: ParameterSection[] = ["gravity", "damping", "probe", "listener", "output"];
49+const ADVANCED_PREFIXES = ["gravity", "damping", "probe", "listener", "output"];
50+const NUMERICS_SECTIONS: ParameterSection[] = ["fd"];
51+
52+const SUMMARIES: Record<string, string> = Object.fromEntries(
53+ LIBRARY_FILES.map((f) => [f.name, f.summary]),
54+);
55+SUMMARIES["main.m"] = "Generated from the controls: the params struct and the solver call";
56+
57+const isSpec = (v: unknown): v is ProblemSpec => {
58+ const s = v as ProblemSpec | null;
59+ return (
60+ !!s &&
61+ typeof s === "object" &&
62+ !!s.plate &&
63+ !!s.fd &&
64+ !!s.mount?.type &&
65+ !!s.gravity &&
66+ !!s.listener?.model &&
67+ !!s.output
68+ );
69+};
70+const isRightTab = (v: unknown): v is RightTab => RIGHT_TABS.some((t) => t.id === v);
71+const isBool = (v: unknown): v is boolean => typeof v === "boolean";
72+const isEdits = (v: unknown): v is Record<string, string> =>
73+ !!v && typeof v === "object" && Object.values(v as object).every((x) => typeof x === "string");
74+
75+export function App() {
76+ // Everything the user chooses is remembered in this browser; "Reset to defaults" forgets it all.
77+ const [spec, setSpec] = usePersistentState<ProblemSpec>("spec-v1", defaultSpec, isSpec);
78+ const [edits, setEdits] = usePersistentState<Record<string, string>>(
79+ "edits",
80+ () => ({}),
81+ isEdits,
82+ );
83+ const [rightTab, setRightTab] = usePersistentState<RightTab>(
84+ "rightTab",
85+ () => "equations",
86+ isRightTab,
87+ );
88+ const [advancedOpen, setAdvancedOpen] = usePersistentState<boolean>(
89+ "advancedOpen",
90+ () => false,
91+ isBool,
92+ );
93+ // The time loop runs as its WebGPU port by default: a run of seconds is hundreds of thousands of steps.
94+ const [fdGpu, setFdGpu] = usePersistentState<boolean>("fdGpu", () => true, isBool);
95+ const gpuSupport = useWebGpuSupport();
96+ const gpuOn = fdGpu && gpuSupport.status === "available";
97+ const runner = useRunner();
98+ const [backend, setBackend] = useState<BackendInfo | null>(null);
99+ useEffect(() => {
100+ void runner.backend.then(setBackend);
101+ }, [runner]);
102+
103+ const issues = useMemo(() => validateSpec(spec), [spec]);
104+ const hasErrors = issues.some((i) => i.severity === "error");
105+ const blockers = methodBlockers(method, spec);
106+ const mainEdited = "main.m" in edits;
107+
108+ const scenarioId = useMemo(
109+ () => SCENARIO_PRESETS.find((s) => JSON.stringify(s.spec) === JSON.stringify(spec))?.id ?? "",
110+ [spec],
111+ );
112+
113+ // Source bundles: what the code view shows is what runs. With the GPU option on, the script is
114+ // written as the three stages the host then runs; an edited main script runs as written, in numbl.
115+ const bundles = useMemo(() => {
116+ const single = buildRunBundle(spec, method, { gpu: gpuOn });
117+ const stages = gpuOn && !mainEdited ? (buildGpuStages(spec, method) ?? undefined) : undefined;
118+ return { display: single, run: withEdits(single, edits), gpu: stages };
119+ }, [spec, edits, gpuOn, mainEdited]);
120+
121+ const solve = useSolve(parsePlateResult);
122+ const plan = useSolve(parseMethodPlan);
123+ const cost = useRef(new CostModel());
124+
125+ // Keep the plan (step, cost, statics, pendulum frequencies) current
126+ useEffect(() => {
127+ if (hasErrors) return;
128+ const t = window.setTimeout(() => {
129+ const pb = buildPlanBundle(spec, method);
130+ if (pb) void plan.run({ bundle: withEdits(pb, edits) });
131+ }, 300);
132+ return () => window.clearTimeout(t);
133+ // eslint-disable-next-line react-hooks/exhaustive-deps
134+ }, [spec, edits, hasErrors]);
135+ const methodPlan: MethodPlan | null = plan.last;
136+ const planError = plan.state.status === "error" ? plan.state.run.error : null;
137+
138+ const running = solve.state.status === "running";
139+
140+ /** Identifies the inputs a result came from, so a result from other settings can say so. */
141+ const stamp = useMemo(() => JSON.stringify({ spec, edits, gpuOn }), [spec, edits, gpuOn]);
142+ const [ranWith, setRanWith] = useState<string | null>(null);
143+
144+ const run = useCallback(async () => {
145+ if (running || hasErrors || blockers.length) return;
146+ solve.reset();
147+ setRanWith(stamp);
148+ const r = await solve.run({ bundle: bundles.run, gpu: bundles.gpu });
149+ if (r?.ok) {
150+ const res = parsePlateResult(r.value);
151+ cost.current.observe(
152+ method,
153+ Number(res.info.extra.cell_steps),
154+ r.wallMs,
155+ r.compute?.loop === "webgpu",
156+ );
157+ }
158+ // eslint-disable-next-line react-hooks/exhaustive-deps
159+ }, [running, hasErrors, blockers.length, bundles, solve, stamp]);
160+
161+ const cancel = () => solve.cancel();
162+
163+ const resetAll = () => {
164+ if (!window.confirm("Reset every setting and source edit to the defaults?")) return;
165+ clearPersistentState();
166+ setSpec(defaultSpec());
167+ setEdits({});
168+ setRightTab("equations");
169+ setAdvancedOpen(false);
170+ setFdGpu(true);
171+ solve.reset();
172+ setRanWith(null);
173+ };
174+
175+ useEffect(() => {
176+ const onKey = (e: KeyboardEvent) => {
177+ if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
178+ e.preventDefault();
179+ void run();
180+ }
181+ };
182+ window.addEventListener("keydown", onKey);
183+ return () => window.removeEventListener("keydown", onKey);
184+ }, [run]);
185+
186+ const currentState = ranWith !== null ? solve.state : IDLE;
187+ const currentResult = ranWith !== null ? solve.last : null;
188+ const stale = ranWith !== null && !running && ranWith !== stamp && currentResult !== null;
189+ const estimateMs = methodPlan
190+ ? cost.current.estimateMs(method, methodPlan.cell_steps, gpuOn)
191+ : undefined;
192+ const slow = estimateMs !== undefined && estimateMs > 5000;
193+ const advancedIssue = issues.some(
194+ (i) =>
195+ i.severity === "error" &&
196+ ADVANCED_PREFIXES.some((p) => i.path === p || i.path.startsWith(p + ".")),
197+ );
198+ const equationGroups = method.equationGroups.filter(
199+ (g) => !g.mounts || g.mounts.includes(spec.mount.type),
200+ );
201+
202+ return (
203+ <div className="app">
204+ <header className="app-header">
205+ <div>
206+ <h1>Plate Dynamics Lab</h1>
207+ <div className="sub">
208+ A metal plate on a thread, on pads, in a vise or in the hand: how it swings, rings and
209+ sounds, solved by MATLAB code and a WebGPU time loop in your browser.
210+ </div>
211+ </div>
212+ <div className="method-picker">
213+ <span className="picker-label">Scenario</span>
214+ <select
215+ className="pl-select"
216+ style={{ width: "auto" }}
217+ value={scenarioId}
218+ onChange={(e) => {
219+ const s = SCENARIO_PRESETS.find((x) => x.id === e.target.value);
220+ if (s) setSpec(cloneSpec(s.spec));
221+ }}
222+ >
223+ <option value="">custom settings</option>
224+ {SCENARIO_PRESETS.map((s) => (
225+ <option key={s.id} value={s.id}>
226+ {s.name}
227+ </option>
228+ ))}
229+ </select>
230+ </div>
231+ </header>
232+ <div className="app-body">
233+ <aside className="col">
234+ <div className="action-bar">
235+ {running ? (
236+ <button className="pl-btn pl-btn-danger run-btn" onClick={cancel}>
237+ Cancel
238+ </button>
239+ ) : (
240+ <button
241+ className={`pl-btn pl-btn-primary run-btn${stale ? " run-btn-stale" : ""}`}
242+ onClick={() => void run()}
243+ disabled={hasErrors || blockers.length > 0}
244+ title="Ctrl+Enter or Cmd+Enter"
245+ >
246+ Run
247+ </button>
248+ )}
249+ <div className="action-meta">
250+ {running && <span className="pl-badge pl-badge-info">running</span>}
251+ {!running && currentState.status === "done" && (
252+ <span className="pl-badge pl-badge-ok">finished</span>
253+ )}
254+ {!running && currentState.status === "error" && (
255+ <span className="pl-badge pl-badge-error">error</span>
256+ )}
257+ {slow && (
258+ <span className="pl-badge pl-badge-warn">
259+ about {(estimateMs! / 1000).toFixed(0)} s
260+ </span>
261+ )}
262+ {!slow && estimateMs !== undefined && (
263+ <span className="pl-muted">
264+ about{" "}
265+ {estimateMs < 1000
266+ ? `${estimateMs.toFixed(0)} ms`
267+ : `${(estimateMs / 1000).toFixed(1)} s`}
268+ </span>
269+ )}
270+ <span style={{ flex: 1 }} />
271+ <button
272+ className="pl-btn"
273+ onClick={resetAll}
274+ title="Forget every setting and source edit saved in this browser"
275+ >
276+ Reset to defaults
277+ </button>
278+ </div>
279+ {hasErrors && !running && (
280+ <div className="pl-issue">
281+ Some settings are out of range; the groups that need attention are marked below.
282+ </div>
283+ )}
284+ </div>
285+ {scenarioId && (
286+ <div className="method-note">
287+ <strong>{SCENARIO_PRESETS.find((s) => s.id === scenarioId)?.name}</strong>
288+ <div className="pl-muted">
289+ {SCENARIO_PRESETS.find((s) => s.id === scenarioId)?.note}
290+ </div>
291+ </div>
292+ )}
293+ {mainEdited && (
294+ <div className="banner">
295+ <span>
296+ The generated script was edited, so the controls no longer feed the run. Keep
297+ editing the source, or restore it to reconnect the controls.
298+ </span>
299+ <button
300+ className="pl-btn"
301+ onClick={() =>
302+ setEdits((e) =>
303+ Object.fromEntries(Object.entries(e).filter(([k]) => k !== "main.m")),
304+ )
305+ }
306+ >
307+ Restore
308+ </button>
309+ </div>
310+ )}
311+ {blockers.map((b) => (
312+ <div className="pl-note" key={b}>
313+ {b}
314+ </div>
315+ ))}
316+ <div className={mainEdited ? "pl-disabled" : undefined}>
317+ <div className="panel-head">On the bench</div>
318+ <ParameterPanel
319+ spec={spec}
320+ onChange={setSpec}
321+ issues={issues}
322+ sections={PROBLEM_SECTIONS}
323+ open={["mount", "excitation"]}
324+ />
325+ {methodPlan && !hasErrors && <PlanSummary plan={methodPlan} spec={spec} />}
326+ {planError && (
327+ <div className="pl-note">The setup refuses this problem: {planError}</div>
328+ )}
329+ <div className="panel-head">Grid and time</div>
330+ <ParameterPanel
331+ spec={spec}
332+ onChange={setSpec}
333+ issues={issues}
334+ fdDtMax={methodPlan?.dt_max}
335+ sections={NUMERICS_SECTIONS}
336+ open={NUMERICS_SECTIONS}
337+ fdGpu={{ enabled: fdGpu, onChange: setFdGpu, support: gpuSupport }}
338+ />
339+ {methodPlan && (
340+ <div className="pl-small pl-muted plan-line">
341+ {methodPlan.nsteps.toLocaleString()} steps of {(methodPlan.dt * 1e6).toPrecision(3)}{" "}
342+ µs on {methodPlan.nx} × {methodPlan.ny} nodes (
343+ {methodPlan.nunknowns.toLocaleString()} unknowns),{" "}
344+ {methodPlan.cell_steps.toLocaleString()} cell-steps
345+ {gpuOn ? ", time loop on the GPU" : ", in numbl"}.
346+ </div>
347+ )}
348+ <details
349+ className="advanced"
350+ open={advancedOpen || advancedIssue}
351+ onToggle={(e) => setAdvancedOpen((e.currentTarget as HTMLDetailsElement).open)}
352+ >
353+ <summary>
354+ More settings: gravity, damping, probe, listener, output
355+ {advancedIssue && (
356+ <span className="pl-badge pl-badge-error" style={{ marginLeft: 6 }}>
357+ check
358+ </span>
359+ )}
360+ </summary>
361+ <ParameterPanel
362+ spec={spec}
363+ onChange={setSpec}
364+ issues={issues}
365+ sections={ADVANCED_SECTIONS}
366+ open={ADVANCED_SECTIONS}
367+ />
368+ </details>
369+ </div>
370+ <div className="pl-small pl-muted">
371+ Settings and source edits are saved in this browser. Ctrl+Enter runs.
372+ </div>
373+ </aside>
374+
375+ <main className="col">
376+ {stale && (
377+ <div className="banner">
378+ <span>
379+ The settings changed after this run, so what you see below is from the earlier ones.
380+ </span>
381+ <button className="pl-btn" onClick={() => void run()}>
382+ Run again
383+ </button>
384+ </div>
385+ )}
386+ {currentState.status === "error" && (
387+ <div className="pl-error">{currentState.run.error}</div>
388+ )}
389+ {currentResult && <ResultsView result={currentResult} spec={spec} method={method} />}
390+ {!currentResult && <EmptyState status={currentState.status} />}
391+ </main>
392+
393+ <section className="col-right">
394+ <div className="tabs" role="tablist">
395+ {RIGHT_TABS.map((t) => (
396+ <button
397+ key={t.id}
398+ className={rightTab === t.id ? "active" : ""}
399+ onClick={() => setRightTab(t.id)}
400+ role="tab"
401+ aria-selected={rightTab === t.id}
402+ >
403+ {t.label}
404+ </button>
405+ ))}
406+ </div>
407+ {rightTab === "equations" && (
408+ <div className="tab-body">
409+ <div className="pl-small pl-muted tab-intro">
410+ What the lab solves, how the {spec.mount.type} mount enters, and how the scheme
411+ discretizes it. The code that does it is under Source.
412+ </div>
413+ {equationGroups.map((g) => (
414+ <div key={g.title} className="eq-group">
415+ <h3>{g.title}</h3>
416+ <EquationsView equations={g.equations} />
417+ </div>
418+ ))}
419+ </div>
420+ )}
421+ {rightTab === "source" && (
422+ <CodeView
423+ files={bundles.display.files}
424+ edits={edits}
425+ onEditsChange={setEdits}
426+ order={["main.m", ...method.readingOrder]}
427+ summaries={SUMMARIES}
428+ height="100%"
429+ />
430+ )}
431+ {rightTab === "run" && (
432+ <div className="tab-body" style={{ display: "flex", flexDirection: "column", gap: 12 }}>
433+ <RunStatus
434+ state={currentState}
435+ result={currentResult}
436+ method={method}
437+ backend={backend}
438+ estimateMs={estimateMs}
439+ onCancel={cancel}
440+ />
441+ </div>
442+ )}
443+ </section>
444+ </div>
445+ </div>
446+ );
447+}
448+
449+/** The statics and the rigid-body modes the plan found, before any run. */
450+function PlanSummary({ plan, spec }: { plan: MethodPlan; spec: ProblemSpec }) {
451+ const m = spec.mount;
452+ const lines: string[] = [];
453+ lines.push(`mass ${(plan.mass * 1000).toFixed(0)} g`);
454+ if (plan.tension.length === 1) lines.push(`thread tension ${plan.tension[0].toFixed(2)} N`);
455+ if (plan.tension.length === 2)
456+ lines.push(`thread tensions ${plan.tension[0].toFixed(2)} and ${plan.tension[1].toFixed(2)} N`);
457+ if (plan.sag > 0) lines.push(`sag under gravity ${(plan.sag * 1000).toPrecision(3)} mm`);
458+ if (plan.rigid_hz.length > 0) {
459+ const hz = plan.rigid_hz.map(
460+ (h, k) =>
461+ `${Number.isFinite(h) ? (h < 0.05 ? `a ${(1 / Math.max(h, 1e-9) / 60).toFixed(0)} min period` : `${h.toFixed(2)} Hz`) : "unstable"} (${plan.rigid_mode_names[k] ?? ""})`,
462+ );
463+ lines.push(`rigid-body modes at ${hz.join(", ")}`);
464+ }
465+ if (m.type === "thread" || m.type === "threads")
466+ lines.push(
467+ `hangs turned ${(Math.atan2(plan.gx, -plan.gy) * (180 / Math.PI)).toFixed(1)}° in its plane`,
468+ );
469+ return (
470+ <div className="pl-small pl-muted plan-line">
471+ Statics: {lines.join("; ")}.
472+ {plan.notes
473+ .filter((n) => /unstable|slack|widened|no load|clear of/.test(n))
474+ .map((n) => (
475+ <div key={n} className="pl-issue pl-issue-warning">
476+ {n}
477+ </div>
478+ ))}
479+ </div>
480+ );
481+}
482+
483+/** What the middle column says before anything has been solved. */
484+function EmptyState({ status }: { status: string }) {
485+ if (status === "running") return <div className="empty">Solving…</div>;
486+ if (status === "error")
487+ return (
488+ <div className="empty">
489+ The run failed. The message is above, and the full log is under Run details.
490+ </div>
491+ );
492+ return (
493+ <div className="empty">
494+ <h2>Nothing solved yet</h2>
495+ <ol>
496+ <li>
497+ Pick a scenario at the top, or set the plate, material, mount and strike on the left.
498+ </li>
499+ <li>
500+ Press <strong>Run</strong>, or Ctrl+Enter.
501+ </li>
502+ <li>
503+ The plate in its mount, its displacement and stress, the swing and the support forces, the
504+ probe traces and the sound appear here.
505+ </li>
506+ </ol>
507+ <p className="pl-muted">
508+ The equations are on the right; Source shows the MATLAB that runs, and you can edit it.
509+ </p>
510+ </div>
511+ );
512+}
apps/lab/src/main.tsxadded+14−0View file
@@ -0,0 +1,14 @@
1+import { StrictMode } from "react";
2+import { createRoot } from "react-dom/client";
3+import { RunnerProvider } from "@plate-dyn/components";
4+import "@plate-dyn/components/styles.css";
5+import "./styles.css";
6+import { App } from "./App";
7+
8+createRoot(document.getElementById("root")!).render(
9+ <StrictMode>
10+ <RunnerProvider>
11+ <App />
12+ </RunnerProvider>
13+ </StrictMode>,
14+);
apps/lab/src/styles.cssadded+311−0View file
@@ -0,0 +1,311 @@
1+* {
2+ box-sizing: border-box;
3+}
4+html,
5+body,
6+#root {
7+ height: 100%;
8+ margin: 0;
9+}
10+body {
11+ font-family: var(--pl-font);
12+ color: var(--pl-text);
13+ background: #eef0f3;
14+ font-size: 13px;
15+}
16+.app {
17+ display: grid;
18+ grid-template-rows: auto 1fr;
19+ height: 100%;
20+}
21+.app-header {
22+ display: flex;
23+ align-items: center;
24+ gap: 14px;
25+ padding: 8px 14px;
26+ background: #fff;
27+ border-bottom: 1px solid var(--pl-border);
28+ flex-wrap: wrap;
29+}
30+.app-header h1 {
31+ font-size: 16px;
32+ margin: 0;
33+ font-weight: 650;
34+}
35+.app-header .sub {
36+ color: var(--pl-muted);
37+ font-size: 12px;
38+}
39+.method-picker {
40+ display: flex;
41+ align-items: center;
42+ gap: 8px;
43+ margin-left: auto;
44+}
45+.method-picker .picker-label {
46+ font-size: 11px;
47+ text-transform: uppercase;
48+ letter-spacing: 0.06em;
49+ color: var(--pl-muted);
50+}
51+.mode-tabs {
52+ display: flex;
53+ gap: 2px;
54+ background: var(--pl-panel);
55+ border: 1px solid var(--pl-border);
56+ border-radius: 6px;
57+ padding: 2px;
58+}
59+.mode-tabs button {
60+ font: inherit;
61+ padding: 4px 12px;
62+ border: 0;
63+ background: transparent;
64+ border-radius: 4px;
65+ cursor: pointer;
66+ color: var(--pl-muted);
67+}
68+.mode-tabs button.active {
69+ background: #fff;
70+ color: var(--pl-text);
71+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
72+}
73+.app-body {
74+ display: grid;
75+ grid-template-columns: 320px minmax(420px, 1fr) minmax(460px, 560px);
76+ gap: 10px;
77+ padding: 10px;
78+ min-height: 0;
79+}
80+.col {
81+ min-height: 0;
82+ overflow: auto;
83+ display: flex;
84+ flex-direction: column;
85+ gap: 10px;
86+}
87+.action-bar {
88+ position: sticky;
89+ top: 0;
90+ z-index: 2;
91+ background: #eef0f3;
92+ padding-bottom: 6px;
93+ display: flex;
94+ flex-direction: column;
95+ gap: 6px;
96+}
97+.run-btn {
98+ width: 100%;
99+ padding: 9px 12px;
100+ font-size: 14px;
101+ font-weight: 600;
102+}
103+.action-meta {
104+ display: flex;
105+ gap: 8px;
106+ align-items: center;
107+ font-size: 12px;
108+ flex-wrap: wrap;
109+}
110+.col-right {
111+ display: grid;
112+ grid-template-rows: auto 1fr;
113+ gap: 6px;
114+ min-height: 0;
115+}
116+.col-right .pl-code {
117+ min-height: 320px;
118+}
119+.tabs {
120+ display: flex;
121+ gap: 2px;
122+ border-bottom: 1px solid var(--pl-border);
123+}
124+.tabs button {
125+ font: inherit;
126+ font-size: 12px;
127+ padding: 5px 10px;
128+ border: 1px solid transparent;
129+ border-bottom: 0;
130+ background: transparent;
131+ cursor: pointer;
132+ color: var(--pl-muted);
133+ border-radius: 5px 5px 0 0;
134+}
135+.tabs button.active {
136+ background: #fff;
137+ border-color: var(--pl-border);
138+ color: var(--pl-text);
139+}
140+.tab-body {
141+ background: #fff;
142+ border: 1px solid var(--pl-border);
143+ border-top: 0;
144+ border-radius: 0 0 6px 6px;
145+ padding: 10px;
146+ overflow: auto;
147+ min-height: 0;
148+}
149+.results {
150+ display: flex;
151+ flex-direction: column;
152+ gap: 12px;
153+}
154+.results-toolbar {
155+ display: flex;
156+ gap: 8px;
157+ align-items: center;
158+ flex-wrap: wrap;
159+ font-size: 12px;
160+}
161+.two-up {
162+ display: grid;
163+ grid-template-columns: 1fr 1fr;
164+ gap: 10px;
165+}
166+.modes-table {
167+ font-size: 11px;
168+ border-collapse: collapse;
169+}
170+.modes-table td,
171+.modes-table th {
172+ padding: 1px 8px;
173+ text-align: right;
174+ border-bottom: 1px solid var(--pl-border);
175+}
176+.modes-table th:first-child,
177+.modes-table td:first-child {
178+ text-align: left;
179+}
180+.banner {
181+ padding: 8px 10px;
182+ border-radius: 6px;
183+ background: var(--pl-warn-bg);
184+ border: 1px solid #f1d7a8;
185+ font-size: 12px;
186+ display: flex;
187+ gap: 8px;
188+ align-items: center;
189+}
190+.empty {
191+ color: var(--pl-muted);
192+ padding: 24px 26px;
193+ background: #fff;
194+ border: 1px dashed var(--pl-border);
195+ border-radius: 6px;
196+ max-width: 560px;
197+}
198+.empty h2 {
199+ font-size: 15px;
200+ margin: 0 0 8px;
201+ color: var(--pl-text);
202+ font-weight: 650;
203+}
204+.empty ol {
205+ margin: 0 0 10px;
206+ padding-left: 20px;
207+ line-height: 1.7;
208+}
209+.empty p {
210+ margin: 0;
211+ font-size: 12px;
212+}
213+
214+/* Left column structure: the problem, then the numerics, then everything else */
215+.panel-head {
216+ font-size: 11px;
217+ text-transform: uppercase;
218+ letter-spacing: 0.06em;
219+ color: var(--pl-muted);
220+ font-weight: 650;
221+ margin: 12px 0 6px;
222+}
223+.panel-head:first-child {
224+ margin-top: 0;
225+}
226+.method-note {
227+ font-size: 12px;
228+ line-height: 1.45;
229+ padding: 8px 10px;
230+ background: #fff;
231+ border: 1px solid var(--pl-border);
232+ border-left: 3px solid var(--pl-accent);
233+ border-radius: 5px;
234+}
235+.plan-line {
236+ margin-top: 6px;
237+}
238+.pl-params + .pl-params,
239+.plan-line + .pl-params {
240+ margin-top: 10px;
241+}
242+details.advanced {
243+ margin-top: 12px;
244+ border-top: 1px solid var(--pl-border);
245+ padding-top: 8px;
246+}
247+details.advanced > summary {
248+ font-size: 12px;
249+ color: var(--pl-muted);
250+ cursor: pointer;
251+ margin-bottom: 8px;
252+}
253+details.advanced > summary:hover {
254+ color: var(--pl-accent);
255+}
256+/* The result on screen is from other settings: the button says so without moving. */
257+.run-btn-stale {
258+ box-shadow: 0 0 0 3px rgba(31, 95, 168, 0.25);
259+}
260+kbd {
261+ font-family: var(--pl-mono);
262+ font-size: 11px;
263+ border: 1px solid var(--pl-border);
264+ border-bottom-width: 2px;
265+ border-radius: 3px;
266+ padding: 0 4px;
267+ background: #fff;
268+}
269+@media (max-width: 1250px) {
270+ .app-body {
271+ grid-template-columns: 300px 1fr;
272+ }
273+ .col-right {
274+ grid-column: 1 / -1;
275+ }
276+}
277+@media (max-width: 800px) {
278+ .app-body {
279+ grid-template-columns: 1fr;
280+ }
281+ .col-right {
282+ grid-column: auto;
283+ }
284+}
285+.sound-bar {
286+ padding: 6px 8px;
287+ background: #fff;
288+ border: 1px solid var(--pl-border);
289+ border-radius: 6px;
290+}
291+.pair-picker .pl-select {
292+ width: auto;
293+ flex: 1;
294+}
295+.tab-intro {
296+ margin-bottom: 10px;
297+ padding-bottom: 8px;
298+ border-bottom: 1px solid var(--pl-border);
299+}
300+
301+/* Equation groups */
302+.eq-group {
303+ margin-bottom: 14px;
304+}
305+.eq-group h3 {
306+ font-size: 12px;
307+ text-transform: uppercase;
308+ letter-spacing: 0.05em;
309+ color: var(--pl-muted);
310+ margin: 10px 0 6px;
311+}
apps/lab/src/usePersistentState.tsadded+47−0View file
@@ -0,0 +1,47 @@
1+import { useCallback, useEffect, useState } from "react";
2+
3+const PREFIX = "plate-dyn:";
4+
5+/** React state mirrored into localStorage under `key`. `validate` rejects stale shapes. */
6+export function usePersistentState<T>(
7+ key: string,
8+ initial: () => T,
9+ validate?: (v: unknown) => v is T,
10+): [T, (v: T | ((prev: T) => T)) => void, () => void] {
11+ const full = PREFIX + key;
12+ const [value, setValue] = useState<T>(() => {
13+ try {
14+ const raw = localStorage.getItem(full);
15+ if (raw !== null) {
16+ const parsed: unknown = JSON.parse(raw);
17+ if (!validate || validate(parsed)) return parsed as T;
18+ }
19+ } catch {
20+ /* fall back to the default */
21+ }
22+ return initial();
23+ });
24+ useEffect(() => {
25+ try {
26+ localStorage.setItem(full, JSON.stringify(value));
27+ } catch {
28+ /* storage unavailable: state simply does not persist */
29+ }
30+ }, [full, value]);
31+ const reset = useCallback(() => setValue(initial()), [initial]);
32+ return [value, setValue, reset];
33+}
34+
35+/** Forget everything the lab has stored in this browser. */
36+export function clearPersistentState(): void {
37+ try {
38+ const keys: string[] = [];
39+ for (let i = 0; i < localStorage.length; i++) {
40+ const k = localStorage.key(i);
41+ if (k && k.startsWith(PREFIX)) keys.push(k);
42+ }
43+ keys.forEach((k) => localStorage.removeItem(k));
44+ } catch {
45+ /* ignore */
46+ }
47+}
apps/lab/src/views/ResultsView.tsxadded+565−0View file
@@ -0,0 +1,565 @@
1+import { useEffect, useMemo, useState } from "react";
2+import {
3+ rangeOf,
4+ symmetricRange,
5+ type Field2D,
6+ type FieldFrames,
7+ type MethodDefinition,
8+ type PlateResult,
9+ type ProblemSpec,
10+ type Range,
11+} from "@plate-dyn/core";
12+import {
13+ AudioPlayer,
14+ FieldView,
15+ FramePlayer,
16+ LinePlot,
17+ Plate3DView,
18+ ValidityNotes,
19+ useFramePlayer,
20+ useSharedAxis,
21+ type FieldArrow,
22+ type FieldMarker,
23+ type FieldRect,
24+} from "@plate-dyn/components";
25+
26+type Quantity = "w" | "w_total" | "vm" | "sp" | "env_vm" | "env_sp" | "w0" | "nvm";
27+
28+const QUANTITIES: {
29+ id: Quantity;
30+ label: string;
31+ unit: string;
32+ scale: number;
33+ symmetric: boolean;
34+ frames: boolean;
35+}[] = [
36+ {
37+ id: "w",
38+ label: "Flexural displacement",
39+ unit: "µm",
40+ scale: 1e6,
41+ symmetric: true,
42+ frames: true,
43+ },
44+ {
45+ id: "w_total",
46+ label: "Displacement with the rigid-body motion",
47+ unit: "mm",
48+ scale: 1e3,
49+ symmetric: true,
50+ frames: true,
51+ },
52+ {
53+ id: "vm",
54+ label: "Von Mises stress (surface)",
55+ unit: "MPa",
56+ scale: 1e-6,
57+ symmetric: false,
58+ frames: true,
59+ },
60+ {
61+ id: "sp",
62+ label: "Principal stress, signed (surface)",
63+ unit: "MPa",
64+ scale: 1e-6,
65+ symmetric: true,
66+ frames: true,
67+ },
68+ {
69+ id: "env_vm",
70+ label: "Peak von Mises over time",
71+ unit: "MPa",
72+ scale: 1e-6,
73+ symmetric: false,
74+ frames: false,
75+ },
76+ {
77+ id: "env_sp",
78+ label: "Peak |principal stress| over time",
79+ unit: "MPa",
80+ scale: 1e-6,
81+ symmetric: false,
82+ frames: false,
83+ },
84+ {
85+ id: "w0",
86+ label: "Static sag under gravity",
87+ unit: "mm",
88+ scale: 1e3,
89+ symmetric: true,
90+ frames: false,
91+ },
92+ {
93+ id: "nvm",
94+ label: "Self-weight in-plane stress (von Mises)",
95+ unit: "kPa",
96+ scale: 1e-3,
97+ symmetric: false,
98+ frames: false,
99+ },
100+];
101+
102+export interface ResultsViewProps {
103+ result: PlateResult;
104+ spec: ProblemSpec;
105+ method: MethodDefinition;
106+ fieldWidth?: number;
107+}
108+
109+/** The plate in its mount, the fields, the rigid-body and support series, the probe, the sound. */
110+export function ResultsView({ result, spec, method, fieldWidth = 460 }: ResultsViewProps) {
111+ const [quantity, setQuantity] = useState<Quantity>("w");
112+ const [rangeMode, setRangeMode] = useState<"global" | "frame">("global");
113+ const [flexScale, setFlexScale] = useState(200);
114+ const [rigidScale, setRigidScale] = useState(1);
115+ const q = QUANTITIES.find((x) => x.id === quantity)!;
116+ const nf = result.frames.t.length;
117+ const player = useFramePlayer(nf, 30, false);
118+ const startFrame = useMemo(() => peakFrame(result.frames.w), [result]);
119+ const setIndex = player.setIndex;
120+ useEffect(() => setIndex(startFrame), [startFrame, setIndex]);
121+
122+ const nvm = useMemo(() => inPlaneVonMises(result), [result]);
123+ const frames: FieldFrames | undefined =
124+ quantity === "w"
125+ ? result.frames.w
126+ : quantity === "w_total"
127+ ? result.frames.w_total
128+ : quantity === "vm"
129+ ? result.frames.vm
130+ : quantity === "sp"
131+ ? result.frames.sp
132+ : undefined;
133+ const field: Field2D | undefined =
134+ quantity === "env_vm"
135+ ? result.envelope.vm
136+ : quantity === "env_sp"
137+ ? result.envelope.sp
138+ : quantity === "w0"
139+ ? result.static.w0
140+ : quantity === "nvm"
141+ ? nvm
142+ : undefined;
143+ const tFrame = result.frames.t[Math.min(player.index, nf - 1)] ?? 0;
144+
145+ // the 3D surface is coloured by the same quantity when it is a frame stack
146+ const colorRange: Range | undefined = useMemo(() => {
147+ if (!frames) return undefined;
148+ const r = rangeOf(frames.data);
149+ return q.symmetric ? symmetricRange(r) : r;
150+ }, [frames, q.symmetric]);
151+
152+ const markers: FieldMarker[] = useMemo(() => {
153+ const m: FieldMarker[] = [
154+ {
155+ x: spec.excitation.x,
156+ y: spec.excitation.y,
157+ label: spec.excitation.type === "impulse" ? "strike" : "pull",
158+ color: "#d1495b",
159+ },
160+ { x: result.probe.x, y: result.probe.y, label: "probe", color: "#1f5fa8" },
161+ ];
162+ const mt = spec.mount;
163+ if (mt.type === "thread") m.push({ x: mt.x, y: mt.y, label: "hole", color: "#2a9d8f" });
164+ if (mt.type === "threads") {
165+ m.push({ x: mt.x1, y: mt.y1, label: "hole 1", color: "#2a9d8f" });
166+ m.push({ x: mt.x2, y: mt.y2, label: "hole 2", color: "#2a9d8f" });
167+ }
168+ if (mt.type === "pinched") m.push({ x: mt.x, y: mt.y, label: "fingers", color: "#2a9d8f" });
169+ if (mt.type === "cymbal")
170+ m.push({ x: spec.plate.Lx / 2, y: spec.plate.Ly / 2, label: "felts", color: "#2a9d8f" });
171+ return m;
172+ }, [spec, result.probe.x, result.probe.y]);
173+ const rects: FieldRect[] = useMemo(() => {
174+ const mt = spec.mount;
175+ if (mt.type === "pads")
176+ return mt.pads.map((p, k) => ({
177+ x0: p.x - p.rx,
178+ y0: p.y - p.ry,
179+ x1: p.x + p.rx,
180+ y1: p.y + p.ry,
181+ label: `pad ${k + 1}`,
182+ }));
183+ if (mt.type === "vise") {
184+ const { Lx, Ly } = spec.plate;
185+ const d = mt.depth;
186+ const r =
187+ mt.edge === "x0"
188+ ? { x0: 0, y0: 0, x1: d, y1: Ly }
189+ : mt.edge === "x1"
190+ ? { x0: Lx - d, y0: 0, x1: Lx, y1: Ly }
191+ : mt.edge === "y0"
192+ ? { x0: 0, y0: 0, x1: Lx, y1: d }
193+ : { x0: 0, y0: Ly - d, x1: Lx, y1: Ly };
194+ return [{ ...r, label: "jaws" }];
195+ }
196+ return [];
197+ }, [spec]);
198+ const arrow: FieldArrow | undefined = useMemo(() => {
199+ const s = result.static;
200+ if (s.g === 0) return undefined;
201+ if (s.gx === 0 && s.gy === 0) return undefined;
202+ return { x: s.xc, y: s.yc, dx: s.gx, dy: s.gy, label: "down" };
203+ }, [result.static]);
204+
205+ const onPlotClick = (t: number) => {
206+ let best = 0;
207+ for (let k = 1; k < nf; k++)
208+ if (Math.abs(result.frames.t[k] - t) < Math.abs(result.frames.t[best] - t)) best = k;
209+ player.setIndex(best);
210+ player.setPlaying(false);
211+ };
212+
213+ const axis = useSharedAxis();
214+ const spectrumAxis = useSharedAxis();
215+ const audioT = useMemo(
216+ () => Float64Array.from({ length: result.audio.p.length }, (_, i) => i / result.audio.fs),
217+ [result.audio],
218+ );
219+ const rigidUnits = result.rigid.names.map((_, k) => (k === 0 ? "mm" : "mrad"));
220+ const rigidColors = ["#1f5fa8", "#d1495b", "#2a9d8f"];
221+ const supportColors = [
222+ "#1f5fa8",
223+ "#d1495b",
224+ "#2a9d8f",
225+ "#e9a300",
226+ "#7b4fa6",
227+ "#555",
228+ "#a0522d",
229+ "#008b8b",
230+ ];
231+ const orientation = describeOrientation(result, spec);
232+
233+ return (
234+ <div className="results">
235+ <div className="results-toolbar sound-bar">
236+ <AudioPlayer
237+ p={result.audio.p}
238+ fs={result.audio.fs}
239+ label="sound"
240+ filename={`plate-${spec.mount.type}`}
241+ />
242+ </div>
243+
244+ <div className="pl-scene">
245+ <div className="pl-plot-title">The plate in its mount</div>
246+ <Plate3DView
247+ result={result}
248+ spec={spec}
249+ frameIndex={player.index}
250+ colorFrames={frames}
251+ colorRange={colorRange}
252+ colormap={q.symmetric ? "bwr" : "viridis"}
253+ flexScale={flexScale}
254+ rigidScale={rigidScale}
255+ width={fieldWidth}
256+ height={Math.round(fieldWidth * 0.7)}
257+ />
258+ <div className="pl-scene-controls">
259+ <label>
260+ flexure ×{flexScale}
261+ <input
262+ type="range"
263+ min={0}
264+ max={4}
265+ step={0.05}
266+ value={Math.log10(Math.max(1, flexScale))}
267+ onChange={(e) => setFlexScale(Math.round(10 ** Number(e.target.value)))}
268+ />
269+ </label>
270+ <label>
271+ rigid motion ×{rigidScale}
272+ <input
273+ type="range"
274+ min={0}
275+ max={2}
276+ step={0.05}
277+ value={Math.log10(Math.max(1, rigidScale))}
278+ onChange={(e) => setRigidScale(Math.round(10 ** Number(e.target.value)))}
279+ />
280+ </label>
281+ <span className="pl-muted">drag to orbit, wheel to zoom</span>
282+ </div>
283+ <div className="pl-small pl-muted">
284+ {orientation} The static sag and the flexural field are exaggerated by the first factor,
285+ the swing and sway by the second (1 is true scale); the colour is the quantity chosen
286+ below.
287+ </div>
288+ </div>
289+
290+ {frames && <FramePlayer {...player} times={result.frames.t} timeScale={1} timeUnit="s" />}
291+
292+ <div className="results-toolbar">
293+ <label className="pl-small pl-muted">show</label>
294+ <select
295+ className="pl-select"
296+ style={{ width: "auto" }}
297+ value={quantity}
298+ onChange={(e) => setQuantity(e.target.value as Quantity)}
299+ >
300+ {QUANTITIES.filter((x) => x.id !== "nvm" || nvm).map((x) => (
301+ <option key={x.id} value={x.id}>
302+ {x.label}
303+ </option>
304+ ))}
305+ </select>
306+ {frames && (
307+ <label className="pl-small pl-muted">
308+ colour range{" "}
309+ <select
310+ className="pl-select"
311+ style={{ width: "auto" }}
312+ value={rangeMode}
313+ onChange={(e) => setRangeMode(e.target.value as "global" | "frame")}
314+ >
315+ <option value="global">fixed over all frames</option>
316+ <option value="frame">rescaled each frame</option>
317+ </select>
318+ </label>
319+ )}
320+ {!result.info.stable && (
321+ <span className="pl-badge pl-badge-error">
322+ diverged at step {result.info.diverged_step}: frames stop there
323+ </span>
324+ )}
325+ </div>
326+ <FieldView
327+ frames={frames}
328+ field={field}
329+ frameIndex={player.index}
330+ Lx={spec.plate.Lx}
331+ Ly={spec.plate.Ly}
332+ shape={spec.plate.shape}
333+ title={q.label}
334+ unit={q.unit}
335+ scale={q.scale}
336+ symmetric={q.symmetric}
337+ range={rangeMode}
338+ markers={markers}
339+ rects={rects}
340+ arrow={arrow}
341+ width={fieldWidth}
342+ caption={captionFor(quantity)}
343+ />
344+ {spec.plate.shape === "ellipse" && (
345+ <div className="pl-small pl-muted">
346+ The grid spans the box around the ellipse; the edge is a staircase of grid nodes, drawn
347+ here clipped to the true outline. Stress on the outermost ring is copied from the nearest
348+ node with a full stencil.
349+ </div>
350+ )}
351+
352+ {result.rigid.nr > 0 && (
353+ <>
354+ <LinePlot
355+ title="Rigid-body motion about the pivot"
356+ series={result.rigid.series.map((s, k) => ({
357+ x: result.t,
358+ y: s,
359+ label: `${result.rigid.names[k]} [${rigidUnits[k]}]`,
360+ color: rigidColors[k],
361+ }))}
362+ yScale={1e3}
363+ xLabel="time [s]"
364+ yLabel="mm, mrad"
365+ yUnit=""
366+ cursorX={tFrame}
367+ onClick={onPlotClick}
368+ height={170}
369+ {...axis}
370+ />
371+ <div className="pl-small pl-muted">
372+ The translation along the normal at the pivot (
373+ {(result.rigid.pivot.x * 1000).toFixed(0)}, {(result.rigid.pivot.y * 1000).toFixed(0)})
374+ mm in millimetres, the two rotations in milliradians; all measured from the static
375+ equilibrium. Rigid-body modes of this mount:{" "}
376+ {result.rigid.hz
377+ .map(
378+ (h, k) =>
379+ `${Number.isFinite(h) ? h.toFixed(2) + " Hz" : "unstable"} (${modeName(result, k)})`,
380+ )
381+ .join(", ")}
382+ .
383+ </div>
384+ </>
385+ )}
386+
387+ {result.supports.names.length > 0 && (
388+ <>
389+ <LinePlot
390+ title="Force the supports apply to the plate"
391+ series={result.supports.series.map((s, k) => ({
392+ x: result.t,
393+ y: s,
394+ label: result.supports.names[k],
395+ color: supportColors[k % supportColors.length],
396+ }))}
397+ xLabel="time [s]"
398+ yLabel="F [N]"
399+ yUnit="N"
400+ cursorX={tFrame}
401+ onClick={onPlotClick}
402+ height={150}
403+ {...axis}
404+ />
405+ <div className="pl-small pl-muted">
406+ Positive pushes the plate towards +z (a pad or the lower felt), negative pulls it back
407+ (a thread's transverse spring, the upper felt). At rest:{" "}
408+ {result.supports.names
409+ .map((n, k) => `${n} ${result.supports.static[k].toFixed(2)} N`)
410+ .join(", ")}
411+ . A contact at zero has lost touch with the plate.
412+ </div>
413+ </>
414+ )}
415+
416+ <LinePlot
417+ title={`Displacement at the probe (${(result.probe.x * 1000).toFixed(0)}, ${(result.probe.y * 1000).toFixed(0)}) mm`}
418+ series={[{ x: result.t, y: result.probe.w, label: "w (rigid + flexural)" }]}
419+ yScale={1e6}
420+ xLabel="time [s]"
421+ yLabel="w [µm]"
422+ yUnit="µm"
423+ cursorX={tFrame}
424+ onClick={onPlotClick}
425+ height={170}
426+ {...axis}
427+ />
428+ <LinePlot
429+ title="Surface stress at the probe"
430+ series={[
431+ { x: result.t, y: result.probe.vm, label: "von Mises" },
432+ { x: result.t, y: result.probe.sp, label: "principal (signed)", color: "#d1495b" },
433+ ]}
434+ yScale={1e-6}
435+ xLabel="time [s]"
436+ yLabel="σ [MPa]"
437+ yUnit="MPa"
438+ cursorX={tFrame}
439+ onClick={onPlotClick}
440+ height={170}
441+ {...axis}
442+ />
443+ <LinePlot
444+ title="Amplitude spectrum at the probe (Hann window)"
445+ series={[
446+ { x: result.spectrum.f, y: result.spectrum.w, label: "w [m]" },
447+ {
448+ x: result.spectrum.f,
449+ y: result.spectrum.vm,
450+ label: "von Mises [Pa]",
451+ color: "#d1495b",
452+ },
453+ ]}
454+ defaultXRange={[0, Math.min(result.info.fs / 2, 5000)]}
455+ logY
456+ xLabel="frequency [Hz]"
457+ yLabel="amplitude"
458+ height={170}
459+ {...spectrumAxis}
460+ />
461+ <div className="pl-small pl-muted">
462+ Frequency resolution {(1 / (result.t[result.t.length - 1] - result.t[0])).toFixed(2)} Hz
463+ from a {(result.t[result.t.length - 1] - result.t[0]).toFixed(2)} s record. The pendulum
464+ modes sit at the far left; zoom in to see them.
465+ </div>
466+
467+ <div>
468+ <div className="pl-plot-title">Radiated sound at the listener</div>
469+ <LinePlot
470+ series={[{ x: audioT, y: result.audio.p, label: "pressure" }]}
471+ xLabel="time after first arrival [s]"
472+ yLabel="p [Pa]"
473+ yUnit="Pa"
474+ height={120}
475+ {...axis}
476+ />
477+ <div className="pl-small pl-muted">
478+ {result.audio.model === "unbaffled"
479+ ? "Unbaffled estimate: the back face's contribution, taken around the nearest edge, cancels the front at low frequency. "
480+ : "Rayleigh integral in an infinite baffle. "}
481+ Listener {spec.listener.z} m from the plate; propagation delay of the nearest patch
482+ removed.
483+ </div>
484+ </div>
485+
486+ <details>
487+ <summary className="pl-small">What this result idealizes</summary>
488+ <ValidityNotes method={method} result={result} />
489+ </details>
490+ </div>
491+ );
492+}
493+
494+function captionFor(q: Quantity): string {
495+ switch (q) {
496+ case "w":
497+ return "The flexural part of the departure from rest: the rigid-body swing and sway are taken out. Positive along the plate normal.";
498+ case "w_total":
499+ return "Flexural field plus the rigid-body motion, measured from the static equilibrium; the swing dominates on a hung plate.";
500+ case "vm":
501+ case "sp":
502+ return "Surface stress at z = h/2 from the curvature of the flexural field; the opposite face carries the opposite sign.";
503+ case "env_vm":
504+ case "env_sp":
505+ return "Peak over the saved frames, so a sample of the true peak. Surface z = h/2.";
506+ case "w0":
507+ return "Deflection at rest under gravity: the sag of a horizontal plate into its pads or from its clamp. A hanging plate has none.";
508+ case "nvm":
509+ return "Von Mises equivalent of the in-plane stress the plate's own weight puts in it, from the plane-stress solve; largest near the hole or the jaws.";
510+ }
511+}
512+
513+function modeName(result: PlateResult, k: number): string {
514+ return result.rigid.modeNames[k] ?? `mode ${k + 1}`;
515+}
516+
517+function describeOrientation(result: PlateResult, spec: ProblemSpec): string {
518+ const s = result.static;
519+ const m = spec.mount;
520+ if (s.g === 0) return "Gravity is off.";
521+ if (m.type === "thread" || m.type === "threads")
522+ return `The plate hangs vertically; it is turned ${(Math.atan2(s.gx, -s.gy) * (180 / Math.PI)).toFixed(1)}° in its plane so its centre of mass is below the ${m.type === "thread" ? "hole" : "line of the holes"}.`;
523+ if (m.type === "pads" || m.type === "cymbal") return "The plate lies horizontal on its supports.";
524+ if (m.type === "vise")
525+ return `The plate is tilted ${m.tilt}° from hanging straight down out of the jaws.`;
526+ return `The plate is held at ${m.tilt}° from hanging straight below the fingers.`;
527+}
528+
529+/** Von Mises equivalent of the self-weight stress resultants, per unit thickness. */
530+function inPlaneVonMises(result: PlateResult): Field2D | undefined {
531+ const s = result.static;
532+ if (!s.Nxx || !s.Nyy || !s.Nxy) return undefined;
533+ const n = s.Nxx.data.length;
534+ const out = new Float64Array(n);
535+ for (let i = 0; i < n; i++) {
536+ const a = s.Nxx.data[i];
537+ const b = s.Nyy.data[i];
538+ const c = s.Nxy.data[i];
539+ out[i] = Math.sqrt(a * a - a * b + b * b + 3 * c * c);
540+ }
541+ return { nx: s.Nxx.nx, ny: s.Nxx.ny, data: out };
542+}
543+
544+/**
545+ * The frame of largest flexural displacement. The player starts here so a run
546+ * that has just finished shows the plate in motion.
547+ */
548+function peakFrame(f: FieldFrames | undefined): number {
549+ if (!f) return 0;
550+ const n = f.nx * f.ny;
551+ let best = 0;
552+ let bestv = -1;
553+ for (let k = 0; k < f.nf; k++) {
554+ let v = 0;
555+ for (let i = k * n; i < (k + 1) * n; i++) {
556+ const a = Math.abs(f.data[i]);
557+ if (a > v) v = a;
558+ }
559+ if (v > bestv) {
560+ bestv = v;
561+ best = k;
562+ }
563+ }
564+ return best;
565+}
apps/lab/tsconfig.jsonadded+9−0View file
@@ -0,0 +1,9 @@
1+{
2+ "extends": "../../tsconfig.base.json",
3+ "compilerOptions": {
4+ "noEmit": true
5+ },
6+ "include": [
7+ "src"
8+ ]
9+}
apps/lab/vite.config.tsadded+13−0View file
@@ -0,0 +1,13 @@
1+import { defineConfig } from "vite";
2+import react from "@vitejs/plugin-react";
3+import { numblDefine } from "../../vite.shared";
4+
5+// The vault serves a site either at the root of an origin of its own (when the
6+// repository has a site label) or under /<collection>/<repo>/site/, so the base
7+// path is not knowable here. CI reads it from configure-pages and passes it in.
8+export default defineConfig({
9+ base: process.env.DEPLOY_BASE ?? "/",
10+ plugins: [react()],
11+ define: numblDefine(),
12+ worker: { format: "es" },
13+});
package-lock.jsonadded+4236−0View file
This diff is 4,241 lines long and is not shown.
package.jsonadded+29−0View file
@@ -0,0 +1,29 @@
1+{
2+ "name": "plate-dynamics-lab",
3+ "private": true,
4+ "version": "0.1.0",
5+ "description": "Plate dynamics lab: Kirchhoff-Love plates in realizable mounts under gravity, explicit finite differences on WebGPU, driven by numbl-executed MATLAB source in the browser",
6+ "type": "module",
7+ "workspaces": [
8+ "packages/*",
9+ "apps/*"
10+ ],
11+ "scripts": {
12+ "dev": "npm run dev -w apps/lab",
13+ "build": "npm run build -w packages/core -w packages/components -w apps/lab --if-present",
14+ "typecheck": "tsc -p tsconfig.json",
15+ "test": "vitest run",
16+ "format": "prettier --write ."
17+ },
18+ "devDependencies": {
19+ "@types/node": "^24.10.1",
20+ "@types/react": "^19.2.5",
21+ "@types/react-dom": "^19.2.3",
22+ "@vitejs/plugin-react": "^5.1.1",
23+ "@webgpu/types": "^0.1.72",
24+ "prettier": "^3.8.1",
25+ "typescript": "~5.9.3",
26+ "vite": "^7.2.4",
27+ "vitest": "^4.0.18"
28+ }
29+}
packages/components/package.jsonadded+30−0View file
@@ -0,0 +1,30 @@
1+{
2+ "name": "@plate-dyn/components",
3+ "version": "0.1.0",
4+ "private": true,
5+ "type": "module",
6+ "description": "Composable React components over @plate-dyn/core: code view, parameter panel, field and 3D views, plots, equations, run status, audio.",
7+ "exports": {
8+ ".": "./src/index.ts",
9+ "./styles.css": "./src/styles.css"
10+ },
11+ "scripts": {
12+ "typecheck": "tsc -p tsconfig.json --noEmit"
13+ },
14+ "dependencies": {
15+ "@plate-dyn/core": "*",
16+ "@codemirror/language": "^6.11.0",
17+ "@codemirror/legacy-modes": "^6.5.0",
18+ "codemirror": "^6.0.1",
19+ "katex": "^0.16.22",
20+ "@codemirror/state": "^6.5.2",
21+ "@codemirror/view": "^6.38.1"
22+ },
23+ "peerDependencies": {
24+ "react": ">=18",
25+ "react-dom": ">=18"
26+ },
27+ "devDependencies": {
28+ "@types/katex": "^0.16.7"
29+ }
30+}
packages/components/src/audio/AudioPlayer.tsxadded+81−0View file
@@ -0,0 +1,81 @@
1+import { useEffect, useMemo, useRef, useState } from "react";
2+import { encodeWav, prepareAudio, toAudioBuffer } from "@plate-dyn/core";
3+
4+export interface AudioPlayerProps {
5+ /** Pressure signal [Pa] */
6+ p: Float64Array;
7+ fs: number;
8+ label?: string;
9+ /** Base name for the exported .wav file (no extension) */
10+ filename?: string;
11+}
12+
13+/**
14+ * Plays the radiated-pressure estimate once, exactly as long as it is, and
15+ * offers the same clip as a 16-bit mono WAV download. Playback and export level
16+ * are normalized; the displayed peak is not. A clip of a few milliseconds
17+ * sounds like a click, and is labelled as such rather than repeated.
18+ */
19+export function AudioPlayer({ p, fs, label, filename = "plate-sound" }: AudioPlayerProps) {
20+ const prep = useMemo(() => prepareAudio(p, fs), [p, fs]);
21+ const ctxRef = useRef<AudioContext | null>(null);
22+ const srcRef = useRef<AudioBufferSourceNode | null>(null);
23+ const [playing, setPlaying] = useState(false);
24+ useEffect(() => () => stop(), []);
25+
26+ const stop = () => {
27+ srcRef.current?.stop();
28+ srcRef.current = null;
29+ setPlaying(false);
30+ };
31+ const play = () => {
32+ if (!ctxRef.current) ctxRef.current = new AudioContext();
33+ const ctx = ctxRef.current;
34+ void ctx.resume();
35+ stop();
36+ const src = ctx.createBufferSource();
37+ src.buffer = toAudioBuffer(ctx, prep);
38+ src.connect(ctx.destination);
39+ src.start();
40+ src.onended = () => setPlaying(false);
41+ srcRef.current = src;
42+ setPlaying(true);
43+ };
44+ const download = () => {
45+ // The exported file carries the same normalized signal that is played, at the
46+ // simulation's own sample rate; the peak in Pa is reported alongside it.
47+ const blob = new Blob([encodeWav(prep.samples, Math.round(prep.fs)) as BlobPart], {
48+ type: "audio/wav",
49+ });
50+ const url = URL.createObjectURL(blob);
51+ const a = document.createElement("a");
52+ a.href = url;
53+ a.download = `${filename}.wav`;
54+ a.click();
55+ setTimeout(() => URL.revokeObjectURL(url), 0);
56+ };
57+
58+ const short = prep.durationS < 0.1;
59+ return (
60+ <div className="pl-row pl-small">
61+ <button className="pl-btn" onClick={playing ? stop : play} disabled={p.length < 2}>
62+ {playing ? "Stop" : `Play${label ? ` ${label}` : ""}`}
63+ </button>
64+ <button
65+ className="pl-btn"
66+ onClick={download}
67+ disabled={p.length < 2}
68+ title="Download as a 16-bit mono WAV file"
69+ >
70+ Save .wav
71+ </button>
72+ <span className="pl-muted">
73+ {(prep.durationS * 1000).toFixed(1)} ms at {fs} Hz, peak {prep.peakPa.toExponential(2)} Pa;
74+ normalized for playback, played once
75+ {short
76+ ? ". This excerpt is too short to hear as a note; lengthen the simulated window for a longer clip"
77+ : ""}
78+ </span>
79+ </div>
80+ );
81+}
packages/components/src/code/CodeView.tsxadded+162−0View file
@@ -0,0 +1,162 @@
1+import { useEffect, useMemo, useRef, useState } from "react";
2+import { EditorView, basicSetup } from "codemirror";
3+import { EditorState, Compartment } from "@codemirror/state";
4+import { StreamLanguage } from "@codemirror/language";
5+import { octave } from "@codemirror/legacy-modes/mode/octave";
6+import type { SourceFile } from "@plate-dyn/core";
7+
8+export interface CodeViewProps {
9+ /** The generated sources */
10+ files: SourceFile[];
11+ /** User edits by file name; a file absent here shows its generated text */
12+ edits: Record<string, string>;
13+ onEditsChange: (edits: Record<string, string>) => void;
14+ /** Tab order; files not listed follow in their original order */
15+ order?: string[];
16+ /** One-line descriptions shown under the tabs */
17+ summaries?: Record<string, string>;
18+ readOnly?: boolean;
19+ /** Called with the active file name */
20+ onActiveChange?: (name: string) => void;
21+ initialFile?: string;
22+ height?: number | string;
23+}
24+
25+/**
26+ * Tabbed source view of exactly the `.m` files a run executes. Editing a file
27+ * records an edit; the run then uses the edited text, and a modified tab shows
28+ * an asterisk with a way back to the generated version.
29+ */
30+export function CodeView(props: CodeViewProps) {
31+ const {
32+ files,
33+ edits,
34+ onEditsChange,
35+ order,
36+ summaries,
37+ readOnly,
38+ onActiveChange,
39+ initialFile,
40+ height,
41+ } = props;
42+ const ordered = useMemo(() => {
43+ const byName = new Map(files.map((f) => [f.name, f]));
44+ const out: SourceFile[] = [];
45+ for (const n of order ?? []) if (byName.has(n)) out.push(byName.get(n)!);
46+ for (const f of files) if (!out.includes(f)) out.push(f);
47+ return out;
48+ }, [files, order]);
49+
50+ const [active, setActive] = useState(initialFile ?? ordered[0]?.name ?? "");
51+ useEffect(() => {
52+ if (!ordered.some((f) => f.name === active) && ordered[0]) setActive(ordered[0].name);
53+ }, [ordered, active]);
54+ useEffect(() => onActiveChange?.(active), [active, onActiveChange]);
55+
56+ const hostRef = useRef<HTMLDivElement>(null);
57+ const viewRef = useRef<EditorView | null>(null);
58+ const editsRef = useRef(edits);
59+ editsRef.current = edits;
60+ const filesRef = useRef(files);
61+ filesRef.current = files;
62+ const activeRef = useRef(active);
63+ activeRef.current = active;
64+ const onEditsRef = useRef(onEditsChange);
65+ onEditsRef.current = onEditsChange;
66+ const readOnlyCompartment = useMemo(() => new Compartment(), []);
67+
68+ // One editor for the lifetime of the component
69+ useEffect(() => {
70+ if (!hostRef.current) return;
71+ const view = new EditorView({
72+ parent: hostRef.current,
73+ state: EditorState.create({
74+ doc: "",
75+ extensions: [
76+ basicSetup,
77+ StreamLanguage.define(octave),
78+ readOnlyCompartment.of(EditorState.readOnly.of(!!readOnly)),
79+ EditorView.updateListener.of((u) => {
80+ if (!u.docChanged) return;
81+ const name = activeRef.current;
82+ const text = u.state.doc.toString();
83+ const original = filesRef.current.find((f) => f.name === name)?.source;
84+ const next = { ...editsRef.current };
85+ if (text === original) delete next[name];
86+ else next[name] = text;
87+ onEditsRef.current(next);
88+ }),
89+ ],
90+ }),
91+ });
92+ viewRef.current = view;
93+ return () => {
94+ view.destroy();
95+ viewRef.current = null;
96+ };
97+ // eslint-disable-next-line react-hooks/exhaustive-deps
98+ }, []);
99+
100+ useEffect(() => {
101+ viewRef.current?.dispatch({
102+ effects: readOnlyCompartment.reconfigure(EditorState.readOnly.of(!!readOnly)),
103+ });
104+ }, [readOnly, readOnlyCompartment]);
105+
106+ // Load the active file's text (edited or generated) into the editor whenever it changes externally
107+ const shown = edits[active] ?? files.find((f) => f.name === active)?.source ?? "";
108+ useEffect(() => {
109+ const view = viewRef.current;
110+ if (!view) return;
111+ const current = view.state.doc.toString();
112+ if (current !== shown) {
113+ view.dispatch({ changes: { from: 0, to: current.length, insert: shown } });
114+ }
115+ }, [shown, active]);
116+
117+ const modified = active in edits;
118+ const restore = () => {
119+ const next = { ...edits };
120+ delete next[active];
121+ onEditsChange(next);
122+ };
123+ const restoreAll = () => onEditsChange({});
124+ const nModified = Object.keys(edits).length;
125+
126+ return (
127+ <div className="pl-code" style={{ height }}>
128+ <div className="pl-code-tabs" role="tablist">
129+ {ordered.map((f) => (
130+ <button
131+ key={f.name}
132+ role="tab"
133+ aria-selected={f.name === active}
134+ className={`pl-code-tab${f.name === active ? " pl-active" : ""}${f.name in edits ? " pl-modified" : ""}`}
135+ onClick={() => setActive(f.name)}
136+ title={summaries?.[f.name]}
137+ >
138+ {f.name}
139+ </button>
140+ ))}
141+ </div>
142+ <div className="pl-code-toolbar">
143+ <span>{summaries?.[active] ?? ""}</span>
144+ <span style={{ flex: 1 }} />
145+ {modified && (
146+ <>
147+ <span className="pl-badge pl-badge-warn">edited</span>
148+ <button className="pl-btn" onClick={restore}>
149+ Restore this file
150+ </button>
151+ </>
152+ )}
153+ {nModified > 1 && (
154+ <button className="pl-btn" onClick={restoreAll}>
155+ Restore all ({nModified})
156+ </button>
157+ )}
158+ </div>
159+ <div className="pl-code-editor" ref={hostRef} />
160+ </div>
161+ );
162+}
packages/components/src/field/Colorbar.tsxadded+53−0View file
@@ -0,0 +1,53 @@
1+import { useEffect, useRef } from "react";
2+import { colormapTable, getColormap, type Range } from "@plate-dyn/core";
3+
4+export function Colorbar({
5+ range,
6+ colormap,
7+ unit,
8+ scale = 1,
9+ height = 200,
10+}: {
11+ range: Range;
12+ colormap: string;
13+ unit?: string;
14+ scale?: number;
15+ height?: number;
16+}) {
17+ const ref = useRef<HTMLCanvasElement>(null);
18+ useEffect(() => {
19+ const c = ref.current;
20+ if (!c) return;
21+ const ctx = c.getContext("2d");
22+ if (!ctx) return;
23+ const table = colormapTable(getColormap(colormap));
24+ const h = 256;
25+ c.width = 1;
26+ c.height = h;
27+ const img = ctx.createImageData(1, h);
28+ for (let k = 0; k < h; k++) {
29+ const t = 255 - k;
30+ img.data[4 * k] = table[3 * t];
31+ img.data[4 * k + 1] = table[3 * t + 1];
32+ img.data[4 * k + 2] = table[3 * t + 2];
33+ img.data[4 * k + 3] = 255;
34+ }
35+ ctx.putImageData(img, 0, 0);
36+ }, [colormap]);
37+ return (
38+ <div className="pl-colorbar" style={{ height }}>
39+ <span>{fmt(range.max * scale)}</span>
40+ <canvas ref={ref} />
41+ <span>
42+ {fmt(range.min * scale)} {unit}
43+ </span>
44+ </div>
45+ );
46+}
47+
48+export function fmt(x: number): string {
49+ if (x === 0) return "0";
50+ const a = Math.abs(x);
51+ if (a >= 1e4 || a < 1e-2) return x.toExponential(2);
52+ return x.toPrecision(3);
53+}
packages/components/src/field/FieldView.tsxadded+264−0View file
@@ -0,0 +1,264 @@
1+import { useEffect, useMemo, useRef } from "react";
2+import {
3+ frameOf,
4+ getColormap,
5+ rangeOf,
6+ rasterizeField,
7+ symmetricRange,
8+ type Field2D,
9+ type FieldFrames,
10+ type PlateShape,
11+ type Range,
12+} from "@plate-dyn/core";
13+import { Colorbar, fmt } from "./Colorbar";
14+
15+export interface FieldMarker {
16+ x: number;
17+ y: number;
18+ label: string;
19+ color?: string;
20+}
21+
22+/** A rectangle drawn on the plate, in plate coordinates [m] */
23+export interface FieldRect {
24+ x0: number;
25+ y0: number;
26+ x1: number;
27+ y1: number;
28+ label?: string;
29+ color?: string;
30+}
31+
32+/** An arrow drawn from a point, in plate coordinates, with a direction in the plate's plane */
33+export interface FieldArrow {
34+ x: number;
35+ y: number;
36+ dx: number;
37+ dy: number;
38+ label?: string;
39+ color?: string;
40+}
41+
42+export interface FieldViewProps {
43+ /** Either a single field or a frame stack with an index */
44+ field?: Field2D;
45+ frames?: FieldFrames;
46+ frameIndex?: number;
47+ /** Physical extents of the bounding box [m] */
48+ Lx: number;
49+ Ly: number;
50+ /** Outline of the plate within the box; default rectangle */
51+ shape?: PlateShape;
52+ title?: string;
53+ unit?: string;
54+ /** Display multiplier applied to values in labels (e.g. 1e-6 for MPa) */
55+ scale?: number;
56+ colormap?: string;
57+ /** "frame": rescale per frame; "global": one range over all frames; or a fixed range */
58+ range?: "frame" | "global" | Range;
59+ /** Force a symmetric range about zero (for signed fields) */
60+ symmetric?: boolean;
61+ markers?: FieldMarker[];
62+ rects?: FieldRect[];
63+ arrow?: FieldArrow;
64+ /** Canvas width in CSS pixels; height follows the aspect ratio */
65+ width?: number;
66+ caption?: string;
67+}
68+
69+/**
70+ * Paints a field on the plate outline. Values pass straight from the solver
71+ * through a colormap; nothing here changes them. Nodes the solver marked NaN
72+ * (outside an elliptical plate) come out transparent, and the image is clipped
73+ * to the plate's true outline.
74+ */
75+export function FieldView(props: FieldViewProps) {
76+ const {
77+ frames,
78+ frameIndex = 0,
79+ Lx,
80+ Ly,
81+ shape = "rectangle",
82+ title,
83+ unit,
84+ scale = 1,
85+ colormap,
86+ symmetric,
87+ markers = [],
88+ rects = [],
89+ arrow,
90+ width = 420,
91+ caption,
92+ } = props;
93+ const field: Field2D | undefined =
94+ props.field ?? (frames ? frameOf(frames, Math.min(frameIndex, frames.nf - 1)) : undefined);
95+ const cmId = colormap ?? (symmetric ? "bwr" : "viridis");
96+ const ellipse = shape === "ellipse";
97+
98+ const globalRange = useMemo(() => {
99+ const r = frames ? rangeOf(frames.data) : field ? rangeOf(field.data) : { min: 0, max: 1 };
100+ return symmetric ? symmetricRange(r) : r;
101+ }, [frames, field, symmetric]);
102+
103+ const range: Range = useMemo(() => {
104+ const mode = props.range ?? "global";
105+ if (typeof mode === "object") return mode;
106+ if (mode === "global" || !field) return globalRange;
107+ const r = rangeOf(field.data);
108+ return symmetric ? symmetricRange(r) : r;
109+ }, [props.range, field, globalRange, symmetric]);
110+
111+ const canvasRef = useRef<HTMLCanvasElement>(null);
112+ const offRef = useRef<HTMLCanvasElement | null>(null);
113+ const pixRef = useRef<Uint8ClampedArray | undefined>(undefined);
114+
115+ const height = Math.max(60, Math.round((width * Ly) / Lx));
116+
117+ useEffect(() => {
118+ const canvas = canvasRef.current;
119+ if (!canvas || !field) return;
120+ const ctx = canvas.getContext("2d");
121+ if (!ctx) return;
122+ const dpr = Math.min(2, window.devicePixelRatio || 1);
123+ canvas.width = Math.round(width * dpr);
124+ canvas.height = Math.round(height * dpr);
125+ // Rasterize at grid resolution, then scale with smoothing onto the plate outline
126+ if (!offRef.current) offRef.current = document.createElement("canvas");
127+ const off = offRef.current;
128+ off.width = field.nx;
129+ off.height = field.ny;
130+ const pix = rasterizeField(field, range, getColormap(cmId), pixRef.current);
131+ pixRef.current = pix;
132+ const octx = off.getContext("2d");
133+ if (!octx) return;
134+ octx.putImageData(
135+ new ImageData(pix as Uint8ClampedArray<ArrayBuffer>, field.nx, field.ny),
136+ 0,
137+ 0,
138+ );
139+ ctx.save();
140+ ctx.scale(dpr, dpr);
141+ ctx.clearRect(0, 0, width, height);
142+ ctx.imageSmoothingEnabled = true;
143+ const outline = () => {
144+ ctx.beginPath();
145+ ctx.ellipse(width / 2, height / 2, width / 2 - 0.75, height / 2 - 0.75, 0, 0, 2 * Math.PI);
146+ };
147+ if (ellipse) {
148+ ctx.save();
149+ outline();
150+ ctx.clip();
151+ }
152+ // Grid nodes sit at cell centres of the image; map node (0..nx-1) to the full extent
153+ const sx = width / (field.nx - 1);
154+ const sy = height / (field.ny - 1);
155+ ctx.drawImage(off, -sx / 2, -sy / 2, width + sx, height + sy);
156+ if (ellipse) ctx.restore();
157+ ctx.strokeStyle = "#333";
158+ ctx.lineWidth = 1;
159+ if (ellipse) {
160+ outline();
161+ ctx.stroke();
162+ } else {
163+ ctx.strokeRect(0.5, 0.5, width - 1, height - 1);
164+ }
165+ const toPx = (x: number, y: number): [number, number] => [
166+ (x / Lx) * width,
167+ height - (y / Ly) * height,
168+ ];
169+ for (const r of rects) {
170+ const [x0, y0] = toPx(r.x0, r.y1);
171+ const [x1, y1] = toPx(r.x1, r.y0);
172+ ctx.fillStyle = "rgba(60,60,70,0.18)";
173+ ctx.fillRect(x0, y0, x1 - x0, y1 - y0);
174+ ctx.strokeStyle = r.color ?? "#333";
175+ ctx.lineWidth = 1;
176+ ctx.strokeRect(x0, y0, x1 - x0, y1 - y0);
177+ if (r.label) {
178+ ctx.font = "11px system-ui, sans-serif";
179+ ctx.fillStyle = "#000";
180+ ctx.strokeStyle = "rgba(255,255,255,0.9)";
181+ ctx.lineWidth = 3;
182+ ctx.strokeText(r.label, x0 + 3, y1 - 4);
183+ ctx.fillText(r.label, x0 + 3, y1 - 4);
184+ }
185+ }
186+ if (arrow && (arrow.dx !== 0 || arrow.dy !== 0)) {
187+ const [x0, y0] = toPx(arrow.x, arrow.y);
188+ const len = 0.18 * Math.min(width, height);
189+ const n = Math.hypot(arrow.dx, arrow.dy);
190+ const ux = arrow.dx / n;
191+ const uy = -arrow.dy / n; // screen y is down
192+ const x1 = x0 + ux * len;
193+ const y1 = y0 + uy * len;
194+ ctx.strokeStyle = arrow.color ?? "#d1495b";
195+ ctx.fillStyle = arrow.color ?? "#d1495b";
196+ ctx.lineWidth = 2;
197+ ctx.beginPath();
198+ ctx.moveTo(x0, y0);
199+ ctx.lineTo(x1, y1);
200+ ctx.stroke();
201+ ctx.beginPath();
202+ ctx.moveTo(x1, y1);
203+ ctx.lineTo(x1 - ux * 8 - uy * 5, y1 - uy * 8 + ux * 5);
204+ ctx.lineTo(x1 - ux * 8 + uy * 5, y1 - uy * 8 - ux * 5);
205+ ctx.closePath();
206+ ctx.fill();
207+ if (arrow.label) {
208+ ctx.font = "11px system-ui, sans-serif";
209+ ctx.fillStyle = "#000";
210+ ctx.strokeStyle = "rgba(255,255,255,0.9)";
211+ ctx.lineWidth = 3;
212+ ctx.strokeText(arrow.label, x1 + 6, y1 + 4);
213+ ctx.fillText(arrow.label, x1 + 6, y1 + 4);
214+ }
215+ }
216+ for (const m of markers) {
217+ const [px, py] = toPx(m.x, m.y);
218+ ctx.beginPath();
219+ ctx.arc(px, py, 5, 0, 2 * Math.PI);
220+ ctx.fillStyle = "rgba(255,255,255,0.85)";
221+ ctx.fill();
222+ ctx.strokeStyle = m.color ?? "#000";
223+ ctx.lineWidth = 1.5;
224+ ctx.stroke();
225+ ctx.font = "11px system-ui, sans-serif";
226+ ctx.fillStyle = "#000";
227+ ctx.strokeStyle = "rgba(255,255,255,0.9)";
228+ ctx.lineWidth = 3;
229+ ctx.strokeText(m.label, px + 8, py - 6);
230+ ctx.fillText(m.label, px + 8, py - 6);
231+ }
232+ ctx.restore();
233+ }, [field, range, cmId, width, height, markers, rects, arrow, Lx, Ly, ellipse]);
234+
235+ const peak = field ? rangeOf(field.data) : null;
236+
237+ return (
238+ <div className="pl-fieldview">
239+ <div className="pl-fieldview-head">
240+ <strong>{title}</strong>
241+ {peak && (
242+ <span className="pl-muted pl-mono">
243+ {fmt(peak.min * scale)} to {fmt(peak.max * scale)} {unit}
244+ </span>
245+ )}
246+ </div>
247+ <div className="pl-fieldview-body">
248+ <canvas
249+ ref={canvasRef}
250+ style={{
251+ width,
252+ height,
253+ aspectRatio: `${Lx} / ${Ly}`,
254+ border: ellipse ? "none" : undefined,
255+ background: ellipse ? "transparent" : undefined,
256+ }}
257+ aria-label={title}
258+ />
259+ <Colorbar range={range} colormap={cmId} unit={unit} scale={scale} height={height} />
260+ </div>
261+ {caption && <div className="pl-small pl-muted">{caption}</div>}
262+ </div>
263+ );
264+}
packages/components/src/field/FramePlayer.tsxadded+76−0View file
@@ -0,0 +1,76 @@
1+import { useCallback, useEffect, useRef, useState } from "react";
2+
3+export interface FramePlayerState {
4+ index: number;
5+ setIndex: (i: number) => void;
6+ playing: boolean;
7+ setPlaying: (p: boolean) => void;
8+}
9+
10+/** Advance a frame index at a fixed rate while playing. */
11+export function useFramePlayer(nf: number, fps = 20, autoplay = false): FramePlayerState {
12+ const [index, setIndexState] = useState(0);
13+ const [playing, setPlaying] = useState(autoplay);
14+ const timer = useRef<number | null>(null);
15+ useEffect(() => {
16+ if (index >= nf) setIndexState(0);
17+ }, [nf, index]);
18+ useEffect(() => {
19+ if (!playing || nf <= 1) return;
20+ timer.current = window.setInterval(() => setIndexState((i) => (i + 1) % nf), 1000 / fps);
21+ return () => {
22+ if (timer.current !== null) window.clearInterval(timer.current);
23+ };
24+ }, [playing, nf, fps]);
25+ const setIndex = useCallback(
26+ (i: number) => setIndexState(Math.max(0, Math.min(nf - 1, i))),
27+ [nf],
28+ );
29+ return { index, setIndex, playing, setPlaying };
30+}
31+
32+export interface FramePlayerProps extends FramePlayerState {
33+ times: Float64Array;
34+ /** Show time in these units */
35+ timeScale?: number;
36+ timeUnit?: string;
37+}
38+
39+export function FramePlayer({
40+ index,
41+ setIndex,
42+ playing,
43+ setPlaying,
44+ times,
45+ timeScale = 1000,
46+ timeUnit = "ms",
47+}: FramePlayerProps) {
48+ const nf = times.length;
49+ const t = times[Math.min(index, nf - 1)] ?? 0;
50+ return (
51+ <div className="pl-player">
52+ <button
53+ className="pl-btn"
54+ onClick={() => setPlaying(!playing)}
55+ disabled={nf <= 1}
56+ aria-label={playing ? "pause" : "play"}
57+ >
58+ {playing ? "Pause" : "Play"}
59+ </button>
60+ <input
61+ type="range"
62+ min={0}
63+ max={Math.max(0, nf - 1)}
64+ value={Math.min(index, nf - 1)}
65+ onChange={(e) => setIndex(Number(e.target.value))}
66+ aria-label="frame"
67+ />
68+ <span className="pl-mono">
69+ {(t * timeScale).toFixed(2)} {timeUnit}
70+ </span>
71+ <span className="pl-muted pl-small">
72+ frame {Math.min(index, nf - 1) + 1}/{nf}
73+ </span>
74+ </div>
75+ );
76+}
packages/components/src/hooks/RunnerContext.tsxadded+28−0View file
@@ -0,0 +1,28 @@
1+import { createContext, useContext, useEffect, useRef, type ReactNode } from "react";
2+import { WorkerRunner, type Runner } from "@plate-dyn/core";
3+
4+const RunnerContext = createContext<Runner | null>(null);
5+
6+/**
7+ * Provides one numbl runner to a tree of components. By default a WorkerRunner
8+ * is created lazily; pass `runner` to share or substitute one.
9+ */
10+export function RunnerProvider({ children, runner }: { children: ReactNode; runner?: Runner }) {
11+ const ref = useRef<Runner | null>(null);
12+ if (!ref.current) ref.current = runner ?? new WorkerRunner();
13+ useEffect(() => {
14+ const r = ref.current;
15+ return () => {
16+ // Dispose terminates the worker; the runner recreates one on the next run,
17+ // so React StrictMode's mount-unmount-mount cycle is harmless.
18+ r?.dispose();
19+ };
20+ }, []);
21+ return <RunnerContext.Provider value={ref.current}>{children}</RunnerContext.Provider>;
22+}
23+
24+export function useRunner(): Runner {
25+ const r = useContext(RunnerContext);
26+ if (!r) throw new Error("useRunner must be used inside a RunnerProvider");
27+ return r;
28+}
packages/components/src/hooks/useSolve.tsadded+82−0View file
@@ -0,0 +1,82 @@
1+import { useCallback, useRef, useState } from "react";
2+import type { PlainValue, RunFailure, RunRequest, RunResult, RunSuccess } from "@plate-dyn/core";
3+import { useRunner } from "./RunnerContext";
4+
5+export type SolveState<T> =
6+ | { status: "idle" }
7+ | { status: "running"; startedAt: number; output: string[] }
8+ | { status: "done"; run: RunSuccess; value: T; output: string[] }
9+ | { status: "error"; run: RunFailure; output: string[] };
10+
11+export interface Solve<T> {
12+ state: SolveState<T>;
13+ /** Most recent successful value, kept while a new run is in progress */
14+ last: T | null;
15+ run: (req: RunRequest) => Promise<RunResult | undefined>;
16+ cancel: () => void;
17+ /** Forget the last value and go back to idle, so nothing stale is displayed */
18+ reset: () => void;
19+}
20+
21+/**
22+ * Drive one run at a time through the shared runner. `parse` types the raw
23+ * result; a parse failure is reported like any other error so a script that
24+ * returns the wrong shape never looks like a blank screen.
25+ */
26+export function useSolve<T>(parse: (v: PlainValue) => T): Solve<T> {
27+ const runner = useRunner();
28+ const [state, setState] = useState<SolveState<T>>({ status: "idle" });
29+ const [last, setLast] = useState<T | null>(null);
30+ const seq = useRef(0);
31+
32+ const run = useCallback(
33+ async (req: RunRequest) => {
34+ const id = ++seq.current;
35+ const output: string[] = [];
36+ setState({ status: "running", startedAt: performance.now(), output: [] });
37+ const res = await runner.run(req, (text) => {
38+ output.push(text);
39+ if (seq.current === id)
40+ setState((s) => (s.status === "running" ? { ...s, output: [...output] } : s));
41+ });
42+ if (seq.current !== id) return undefined;
43+ if (res.ok) {
44+ try {
45+ const value = parse(res.value);
46+ setLast(value);
47+ setState({ status: "done", run: res, value, output: res.output });
48+ } catch (e) {
49+ setState({
50+ status: "error",
51+ run: {
52+ ...res,
53+ ok: false,
54+ error: `the script ran, but its result has an unexpected shape: ${(e as Error).message}`,
55+ },
56+ output: res.output,
57+ });
58+ }
59+ } else {
60+ setState({ status: "error", run: res, output: res.output });
61+ }
62+ return res;
63+ },
64+ [runner, parse],
65+ );
66+
67+ const cancel = useCallback(() => {
68+ seq.current++;
69+ runner.cancel();
70+ setState({ status: "idle" });
71+ }, [runner]);
72+
73+ // Local state only: nothing is terminated, so the worker (and the numbl module it
74+ // has loaded) survives. Use `cancel` to stop a run that is actually in flight.
75+ const reset = useCallback(() => {
76+ seq.current++;
77+ setLast(null);
78+ setState({ status: "idle" });
79+ }, []);
80+
81+ return { state, last, run, cancel, reset };
82+}
packages/components/src/hooks/useWebGpu.tsadded+39−0View file
@@ -0,0 +1,39 @@
1+import { useEffect, useState } from "react";
2+import { describeAdapter } from "@plate-dyn/core";
3+
4+export type WebGpuSupport =
5+ | { status: "checking" }
6+ | { status: "available"; adapter: string }
7+ | { status: "unavailable"; reason: string };
8+
9+/**
10+ * Whether this browser exposes WebGPU, and on what. Asked once on the main
11+ * thread so the controls can say what a GPU run would use; the worker makes
12+ * its own device request when a run asks for it.
13+ */
14+export function useWebGpuSupport(): WebGpuSupport {
15+ const [support, setSupport] = useState<WebGpuSupport>({ status: "checking" });
16+ useEffect(() => {
17+ let alive = true;
18+ const set = (s: WebGpuSupport) => {
19+ if (alive) setSupport(s);
20+ };
21+ (async () => {
22+ try {
23+ const gpu = (navigator as { gpu?: GPU }).gpu;
24+ if (!gpu)
25+ return set({ status: "unavailable", reason: "this browser does not expose WebGPU" });
26+ const adapter = await gpu.requestAdapter();
27+ if (!adapter)
28+ return set({ status: "unavailable", reason: "no WebGPU adapter was offered" });
29+ set({ status: "available", adapter: describeAdapter(adapter) });
30+ } catch (e) {
31+ set({ status: "unavailable", reason: e instanceof Error ? e.message : String(e) });
32+ }
33+ })();
34+ return () => {
35+ alive = false;
36+ };
37+ }, []);
38+ return support;
39+}
packages/components/src/index.tsadded+33−0View file
@@ -0,0 +1,33 @@
1+export { RunnerProvider, useRunner } from "./hooks/RunnerContext";
2+export { useSolve, type Solve, type SolveState } from "./hooks/useSolve";
3+export { useWebGpuSupport, type WebGpuSupport } from "./hooks/useWebGpu";
4+export { CodeView, type CodeViewProps } from "./code/CodeView";
5+export { NumberField, type NumberFieldProps } from "./params/NumberField";
6+export { CflSlider, type CflSliderProps } from "./params/CflSlider";
7+export {
8+ ParameterPanel,
9+ ALL_SECTIONS,
10+ type ParameterPanelProps,
11+ type ParameterSection,
12+} from "./params/ParameterPanel";
13+export {
14+ FieldView,
15+ type FieldViewProps,
16+ type FieldMarker,
17+ type FieldRect,
18+ type FieldArrow,
19+} from "./field/FieldView";
20+export { Colorbar } from "./field/Colorbar";
21+export {
22+ FramePlayer,
23+ useFramePlayer,
24+ type FramePlayerProps,
25+ type FramePlayerState,
26+} from "./field/FramePlayer";
27+export { LinePlot, type LinePlotProps, type Series, type XRange } from "./plots/LinePlot";
28+export { useSharedAxis } from "./plots/useSharedAxis";
29+export { EquationsView } from "./status/EquationsView";
30+export { RunStatus, fmtMs, fmtTime, type RunStatusProps } from "./status/RunStatus";
31+export { ValidityNotes } from "./status/ValidityNotes";
32+export { AudioPlayer, type AudioPlayerProps } from "./audio/AudioPlayer";
33+export { Plate3DView, type Plate3DViewProps } from "./scene/Plate3DView";
packages/components/src/params/CflSlider.tsxadded+61−0View file
@@ -0,0 +1,61 @@
1+export interface CflSliderProps {
2+ value: number;
3+ onChange: (v: number) => void;
4+ /** Largest stable step, when known, to show the resulting dt */
5+ dtMax?: number;
6+ min?: number;
7+ max?: number;
8+ disabled?: boolean;
9+}
10+
11+/**
12+ * The time step as a fraction of the stability limit. Past 1 the explicit
13+ * scheme amplifies its shortest waves every step, and the run blows up within
14+ * a few dozen steps; the slider colours that region so it is not a surprise.
15+ */
16+export function CflSlider({
17+ value,
18+ onChange,
19+ dtMax,
20+ min = 0.5,
21+ max = 1.2,
22+ disabled,
23+}: CflSliderProps) {
24+ const unstable = value > 1;
25+ const pct = ((1 - min) / (max - min)) * 100;
26+ return (
27+ <div className={disabled ? "pl-disabled" : undefined}>
28+ <div className="pl-slider">
29+ <input
30+ type="range"
31+ min={min}
32+ max={max}
33+ step={0.01}
34+ value={value}
35+ disabled={disabled}
36+ onChange={(e) => onChange(Number(e.target.value))}
37+ style={{
38+ background: `linear-gradient(to right, #cfe0f5 0%, #cfe0f5 ${pct}%, #f5c6c6 ${pct}%, #f5c6c6 100%)`,
39+ }}
40+ aria-label="time step as a fraction of the stability limit"
41+ />
42+ <span className="pl-mono" style={{ color: unstable ? "var(--pl-error)" : undefined }}>
43+ {value.toFixed(2)} dt<sub>max</sub>
44+ </span>
45+ </div>
46+ <div className="pl-small pl-muted">
47+ {dtMax !== undefined ? (
48+ <>
49+ dt = {(value * dtMax * 1e6).toPrecision(3)} µs (limit {(dtMax * 1e6).toPrecision(3)}{" "}
50+ µs).{" "}
51+ </>
52+ ) : null}
53+ {unstable ? (
54+ <span style={{ color: "var(--pl-error)" }}>Above the limit: the run will diverge.</span>
55+ ) : (
56+ "Stable below 1."
57+ )}
58+ </div>
59+ </div>
60+ );
61+}
packages/components/src/params/NumberField.tsxadded+70−0View file
@@ -0,0 +1,70 @@
1+import { useEffect, useState } from "react";
2+
3+export interface NumberFieldProps {
4+ label: string;
5+ value: number;
6+ onChange: (v: number) => void;
7+ /** Display = value * scale (e.g. 1000 to show metres as millimetres) */
8+ scale?: number;
9+ unit?: string;
10+ step?: number;
11+ min?: number;
12+ max?: number;
13+ integer?: boolean;
14+ invalid?: boolean;
15+ disabled?: boolean;
16+ title?: string;
17+}
18+
19+/** A numeric input that tolerates partial typing and commits parsed values. */
20+export function NumberField(props: NumberFieldProps) {
21+ const {
22+ label,
23+ value,
24+ onChange,
25+ scale = 1,
26+ unit,
27+ step,
28+ min,
29+ max,
30+ integer,
31+ invalid,
32+ disabled,
33+ title,
34+ } = props;
35+ const shown = formatShown(value * scale);
36+ const [text, setText] = useState(shown);
37+ useEffect(() => {
38+ // Sync from outside unless the text already means the same number
39+ if (Number(text) * 1 !== value * scale) setText(shown);
40+ // eslint-disable-next-line react-hooks/exhaustive-deps
41+ }, [value, scale]);
42+ return (
43+ <div className={`pl-field${disabled ? " pl-disabled" : ""}`} title={title}>
44+ <label>{label}</label>
45+ <input
46+ type="number"
47+ className={invalid ? "pl-invalid" : undefined}
48+ value={text}
49+ step={step ?? (integer ? 1 : "any")}
50+ min={min}
51+ max={max}
52+ disabled={disabled}
53+ onChange={(e) => {
54+ setText(e.target.value);
55+ const n = Number(e.target.value);
56+ if (e.target.value.trim() === "" || !Number.isFinite(n)) return;
57+ onChange(integer ? Math.round(n) : n / scale);
58+ }}
59+ onBlur={() => setText(shown)}
60+ />
61+ <span className="pl-unit">{unit ?? ""}</span>
62+ </div>
63+ );
64+}
65+
66+function formatShown(x: number): string {
67+ if (!Number.isFinite(x)) return "";
68+ const r = Number(x.toPrecision(10));
69+ return String(r);
70+}
packages/components/src/params/ParameterPanel.tsxadded+1007−0View file
@@ -0,0 +1,1007 @@
1+import type { ReactNode } from "react";
2+import {
3+ MATERIAL_PRESETS,
4+ PAD_PRESETS,
5+ PLATE_PRESETS,
6+ THREAD_PRESETS,
7+ defaultMount,
8+ type Excitation,
9+ type Mount,
10+ type MountType,
11+ type Pad,
12+ type PlateShape,
13+ type ProblemSpec,
14+ type ValidationIssue,
15+ type ViseEdge,
16+} from "@plate-dyn/core";
17+import { NumberField } from "./NumberField";
18+import { CflSlider } from "./CflSlider";
19+import type { WebGpuSupport } from "../hooks/useWebGpu";
20+
21+export type ParameterSection =
22+ | "plate"
23+ | "material"
24+ | "mount"
25+ | "gravity"
26+ | "damping"
27+ | "excitation"
28+ | "probe"
29+ | "listener"
30+ | "output"
31+ | "fd";
32+
33+export const ALL_SECTIONS: ParameterSection[] = [
34+ "plate",
35+ "material",
36+ "mount",
37+ "gravity",
38+ "damping",
39+ "excitation",
40+ "probe",
41+ "listener",
42+ "output",
43+ "fd",
44+];
45+
46+export interface ParameterPanelProps {
47+ spec: ProblemSpec;
48+ onChange: (spec: ProblemSpec) => void;
49+ /** Sections to show; default all */
50+ sections?: ParameterSection[];
51+ issues?: ValidationIssue[];
52+ /** Largest stable step, when known, for the CFL slider */
53+ fdDtMax?: number;
54+ /** Sections open initially; default all */
55+ open?: ParameterSection[];
56+ /** The GPU option of the time loop: whether it is on, and what this browser offers */
57+ fdGpu?: { enabled: boolean; onChange: (v: boolean) => void; support: WebGpuSupport };
58+}
59+
60+const MOUNT_LABELS: Record<MountType, string> = {
61+ thread: "hanging from one thread",
62+ threads: "hanging from two threads",
63+ pads: "resting on pads",
64+ cymbal: "on a cymbal stand",
65+ vise: "one edge in a vise",
66+ pinched: "pinched between fingers",
67+};
68+
69+/** Controls that edit the spec. Every control maps to one field of the `params` struct. */
70+export function ParameterPanel(props: ParameterPanelProps) {
71+ const { spec, onChange, sections = ALL_SECTIONS, issues = [], fdDtMax, open, fdGpu } = props;
72+ const set = (patch: Partial<ProblemSpec>) => onChange({ ...spec, ...patch });
73+ const issue = (path: string) =>
74+ issues.find((i) => i.path === path || i.path.startsWith(path + "."));
75+ const invalid = (path: string) => issue(path)?.severity === "error";
76+ const show = (s: ParameterSection) => sections.includes(s);
77+ const isOpen = (s: ParameterSection) => (open ? open.includes(s) : true);
78+ const e = spec.excitation;
79+ const m = spec.mount;
80+ const setMount = (patch: Partial<Mount>) => set({ mount: { ...m, ...patch } as Mount });
81+
82+ const materialPresetId = MATERIAL_PRESETS.find(
83+ (p) => p.material.name === spec.material.name && p.material.E === spec.material.E,
84+ )?.id;
85+ const platePresetId = PLATE_PRESETS.find(
86+ (p) =>
87+ p.plate.shape === spec.plate.shape &&
88+ p.plate.Lx === spec.plate.Lx &&
89+ p.plate.Ly === spec.plate.Ly &&
90+ p.plate.h === spec.plate.h,
91+ )?.id;
92+ const ellipse = spec.plate.shape === "ellipse";
93+
94+ return (
95+ <div className="pl-params">
96+ {show("plate") && (
97+ <Group title="Plate" issues={issues} prefix="plate" open={isOpen("plate")}>
98+ <div className="pl-field pl-field-wide">
99+ <label>Stock</label>
100+ <select
101+ className="pl-select"
102+ value={platePresetId ?? ""}
103+ onChange={(ev) => {
104+ const p = PLATE_PRESETS.find((x) => x.id === ev.target.value);
105+ if (p) set({ plate: { ...p.plate } });
106+ }}
107+ >
108+ <option value="">custom</option>
109+ {PLATE_PRESETS.map((p) => (
110+ <option key={p.id} value={p.id}>
111+ {p.name}
112+ </option>
113+ ))}
114+ </select>
115+ </div>
116+ {platePresetId && (
117+ <div className="pl-small pl-muted">
118+ {PLATE_PRESETS.find((p) => p.id === platePresetId)?.note}
119+ </div>
120+ )}
121+ <div className="pl-field pl-field-wide">
122+ <label>Shape</label>
123+ <select
124+ className="pl-select"
125+ value={spec.plate.shape}
126+ onChange={(ev) =>
127+ set({ plate: { ...spec.plate, shape: ev.target.value as PlateShape } })
128+ }
129+ >
130+ <option value="rectangle">rectangle</option>
131+ <option value="ellipse">ellipse (a disk when Lx = Ly)</option>
132+ </select>
133+ </div>
134+ <NumberField
135+ label={ellipse ? "Diameter along x, Lx" : "Length Lx"}
136+ value={spec.plate.Lx}
137+ scale={1000}
138+ unit="mm"
139+ invalid={invalid("plate.Lx")}
140+ onChange={(v) => set({ plate: { ...spec.plate, Lx: v } })}
141+ />
142+ <NumberField
143+ label={ellipse ? "Diameter along y, Ly" : "Width Ly"}
144+ value={spec.plate.Ly}
145+ scale={1000}
146+ unit="mm"
147+ invalid={invalid("plate.Ly")}
148+ onChange={(v) => set({ plate: { ...spec.plate, Ly: v } })}
149+ />
150+ <NumberField
151+ label="Thickness h"
152+ value={spec.plate.h}
153+ scale={1000}
154+ unit="mm"
155+ step={0.1}
156+ invalid={invalid("plate.h")}
157+ onChange={(v) => set({ plate: { ...spec.plate, h: v } })}
158+ />
159+ {ellipse && (
160+ <div className="pl-small pl-muted">
161+ The ellipse fills the Lx by Ly box, and positions are still measured from the box's
162+ corner. On the grid the edge becomes a staircase of nodes, first order in the spacing.
163+ </div>
164+ )}
165+ </Group>
166+ )}
167+ {show("material") && (
168+ <Group title="Material" issues={issues} prefix="material" open={isOpen("material")}>
169+ <div className="pl-field pl-field-wide">
170+ <label>Preset</label>
171+ <select
172+ className="pl-select"
173+ value={materialPresetId ?? ""}
174+ onChange={(ev) => {
175+ const p = MATERIAL_PRESETS.find((x) => x.id === ev.target.value);
176+ if (p) set({ material: { ...p.material }, damping: { ...p.damping } });
177+ }}
178+ >
179+ <option value="">custom</option>
180+ {MATERIAL_PRESETS.map((p) => (
181+ <option key={p.id} value={p.id}>
182+ {p.material.name}
183+ </option>
184+ ))}
185+ </select>
186+ </div>
187+ {materialPresetId && (
188+ <div className="pl-small pl-muted">
189+ {MATERIAL_PRESETS.find((p) => p.id === materialPresetId)?.source} Handbook values, not
190+ a certificate.
191+ </div>
192+ )}
193+ <NumberField
194+ label="Young's modulus E"
195+ value={spec.material.E}
196+ scale={1e-9}
197+ unit="GPa"
198+ invalid={invalid("material.E")}
199+ onChange={(v) =>
200+ set({ material: { ...spec.material, E: v, name: customName(spec.material.name) } })
201+ }
202+ />
203+ <NumberField
204+ label="Poisson's ratio ν"
205+ value={spec.material.nu}
206+ step={0.01}
207+ invalid={invalid("material.nu")}
208+ onChange={(v) =>
209+ set({ material: { ...spec.material, nu: v, name: customName(spec.material.name) } })
210+ }
211+ />
212+ <NumberField
213+ label="Density ρ"
214+ value={spec.material.rho}
215+ unit="kg/m³"
216+ invalid={invalid("material.rho")}
217+ onChange={(v) =>
218+ set({ material: { ...spec.material, rho: v, name: customName(spec.material.name) } })
219+ }
220+ />
221+ </Group>
222+ )}
223+ {show("mount") && (
224+ <Group title="Mount" issues={issues} prefix="mount" open={isOpen("mount")}>
225+ <div className="pl-field pl-field-wide">
226+ <label>Held</label>
227+ <select
228+ className="pl-select"
229+ value={m.type}
230+ onChange={(ev) =>
231+ set({ mount: defaultMount(ev.target.value as MountType, spec.plate, m) })
232+ }
233+ >
234+ {(Object.keys(MOUNT_LABELS) as MountType[]).map((t) => (
235+ <option key={t} value={t}>
236+ {MOUNT_LABELS[t]}
237+ </option>
238+ ))}
239+ </select>
240+ </div>
241+ <MountFields mount={m} spec={spec} setMount={setMount} invalid={invalid} />
242+ </Group>
243+ )}
244+ {show("gravity") && (
245+ <Group title="Gravity" issues={issues} prefix="gravity" open={isOpen("gravity")}>
246+ <NumberField
247+ label="g"
248+ value={spec.gravity.g}
249+ unit="m/s²"
250+ step={0.01}
251+ min={0}
252+ invalid={invalid("gravity.g")}
253+ onChange={(v) => set({ gravity: { g: v } })}
254+ />
255+ <div className="pl-small pl-muted">
256+ Zero switches gravity off: a hung plate then has no pendulum stiffness, a plate on pads
257+ no weight to hold it down. Its direction is set by the mount.
258+ </div>
259+ </Group>
260+ )}
261+ {show("damping") && (
262+ <Group title="Damping" issues={issues} prefix="damping" open={isOpen("damping")}>
263+ <NumberField
264+ label="σ₀ (all frequencies)"
265+ value={spec.damping.sigma0}
266+ unit="1/s"
267+ step={0.1}
268+ invalid={invalid("damping")}
269+ onChange={(v) => set({ damping: { ...spec.damping, sigma0: v } })}
270+ />
271+ <NumberField
272+ label="σ₁ (rises with frequency)"
273+ value={spec.damping.sigma1}
274+ unit="m²/s"
275+ step={0.001}
276+ invalid={invalid("damping")}
277+ onChange={(v) => set({ damping: { ...spec.damping, sigma1: v } })}
278+ />
279+ <div className="pl-small pl-muted">
280+ A mode with Laplacian eigenvalue λ decays at rate σ₀ + σ₁λ; σ₀ also slows the pendulum
281+ motion, as air would. Illustrative values; the mount's own dashpots are set with it.
282+ </div>
283+ </Group>
284+ )}
285+ {show("excitation") && (
286+ <Group title="Excitation" issues={issues} prefix="excitation" open={isOpen("excitation")}>
287+ <div className="pl-field pl-field-wide">
288+ <label>Type</label>
289+ <select
290+ className="pl-select"
291+ value={e.type}
292+ onChange={(ev) => {
293+ const type = ev.target.value as Excitation["type"];
294+ const next: Excitation =
295+ type === "impulse"
296+ ? { type, x: e.x, y: e.y, force: 50, duration: 1e-3, radius: e.radius }
297+ : { type, x: e.x, y: e.y, displacement: 0.002, radius: e.radius };
298+ set({ excitation: next });
299+ }}
300+ >
301+ <option value="impulse">hammer strike (half-sine pulse)</option>
302+ <option value="release">pull aside and let go</option>
303+ </select>
304+ </div>
305+ <NumberField
306+ label={e.type === "impulse" ? "Strike x" : "Pull x"}
307+ value={e.x}
308+ scale={1000}
309+ unit="mm"
310+ invalid={invalid("excitation")}
311+ onChange={(v) => set({ excitation: { ...e, x: v } })}
312+ />
313+ <NumberField
314+ label={e.type === "impulse" ? "Strike y" : "Pull y"}
315+ value={e.y}
316+ scale={1000}
317+ unit="mm"
318+ invalid={invalid("excitation")}
319+ onChange={(v) => set({ excitation: { ...e, y: v } })}
320+ />
321+ {e.type === "impulse" ? (
322+ <>
323+ <NumberField
324+ label="Peak force"
325+ value={e.force}
326+ unit="N"
327+ invalid={invalid("excitation.force")}
328+ onChange={(v) => set({ excitation: { ...e, force: v } })}
329+ />
330+ <NumberField
331+ label="Contact time"
332+ value={e.duration}
333+ scale={1000}
334+ unit="ms"
335+ step={0.1}
336+ invalid={invalid("excitation.duration")}
337+ onChange={(v) => set({ excitation: { ...e, duration: v } })}
338+ />
339+ </>
340+ ) : (
341+ <NumberField
342+ label="Pull, then release"
343+ value={e.displacement}
344+ scale={1000}
345+ unit="mm"
346+ step={0.5}
347+ invalid={invalid("excitation.displacement")}
348+ onChange={(v) => set({ excitation: { ...e, displacement: v } })}
349+ />
350+ )}
351+ <NumberField
352+ label="Footprint radius"
353+ value={e.radius}
354+ scale={1000}
355+ unit="mm"
356+ step={1}
357+ invalid={invalid("excitation.radius")}
358+ onChange={(v) => set({ excitation: { ...e, radius: v } })}
359+ />
360+ <div className="pl-small pl-muted">
361+ {e.type === "impulse"
362+ ? "A finite footprint stands in for the contact patch; a point load would give infinite stress under the hammer."
363+ : "The plate is held in the static shape a force at this point produces, scaled to the displacement asked for, and let go at t = 0. On one thread the pull is applied without twisting the plate."}
364+ </div>
365+ </Group>
366+ )}
367+ {show("probe") && (
368+ <Group title="Probe" issues={issues} prefix="probe" open={isOpen("probe")}>
369+ <NumberField
370+ label="x"
371+ value={spec.probe.x}
372+ scale={1000}
373+ unit="mm"
374+ invalid={invalid("probe")}
375+ onChange={(v) => set({ probe: { ...spec.probe, x: v } })}
376+ />
377+ <NumberField
378+ label="y"
379+ value={spec.probe.y}
380+ scale={1000}
381+ unit="mm"
382+ invalid={invalid("probe")}
383+ onChange={(v) => set({ probe: { ...spec.probe, y: v } })}
384+ />
385+ <div className="pl-small pl-muted">
386+ Time series and spectra are recorded here, at the nearest grid node with a full stencil.
387+ </div>
388+ </Group>
389+ )}
390+ {show("listener") && (
391+ <Group title="Listener (sound)" issues={issues} prefix="listener" open={isOpen("listener")}>
392+ <div className="pl-field pl-field-wide">
393+ <label>Model</label>
394+ <select
395+ className="pl-select"
396+ value={spec.listener.model}
397+ onChange={(ev) =>
398+ set({
399+ listener: { ...spec.listener, model: ev.target.value as "baffled" | "unbaffled" },
400+ })
401+ }
402+ >
403+ <option value="unbaffled">
404+ unbaffled: plate in free air (front and back cancel)
405+ </option>
406+ <option value="baffled">baffled: plate set in a large table or wall</option>
407+ </select>
408+ </div>
409+ <NumberField
410+ label="x"
411+ value={spec.listener.x}
412+ scale={1000}
413+ unit="mm"
414+ onChange={(v) => set({ listener: { ...spec.listener, x: v } })}
415+ />
416+ <NumberField
417+ label="y"
418+ value={spec.listener.y}
419+ scale={1000}
420+ unit="mm"
421+ onChange={(v) => set({ listener: { ...spec.listener, y: v } })}
422+ />
423+ <NumberField
424+ label="Distance from the plate z"
425+ value={spec.listener.z}
426+ unit="m"
427+ step={0.1}
428+ invalid={invalid("listener.z")}
429+ onChange={(v) => set({ listener: { ...spec.listener, z: v } })}
430+ />
431+ <div className="pl-small pl-muted">
432+ In plate coordinates, z along the normal. Either model is an estimate of the waveform,
433+ not a calibrated level; the unbaffled one is a rough account of the front-back
434+ cancellation.
435+ </div>
436+ </Group>
437+ )}
438+ {show("output") && (
439+ <Group title="Output" issues={issues} prefix="output" open={isOpen("output")}>
440+ <NumberField
441+ label="Sample rate"
442+ value={spec.output.fs}
443+ unit="Hz"
444+ integer
445+ invalid={invalid("output.fs")}
446+ onChange={(v) => set({ output: { ...spec.output, fs: v } })}
447+ />
448+ <NumberField
449+ label="Animation frames"
450+ value={spec.output.nframes}
451+ integer
452+ invalid={invalid("output.nframes")}
453+ onChange={(v) => set({ output: { ...spec.output, nframes: v } })}
454+ />
455+ </Group>
456+ )}
457+ {show("fd") && (
458+ <Group title="Grid and time" issues={issues} prefix="fd" open={isOpen("fd")}>
459+ <NumberField
460+ label="Grid nx"
461+ value={spec.fd.nx}
462+ integer
463+ min={5}
464+ invalid={invalid("fd")}
465+ onChange={(v) => set({ fd: { ...spec.fd, nx: v } })}
466+ />
467+ <NumberField
468+ label="Grid ny"
469+ value={spec.fd.ny}
470+ integer
471+ min={5}
472+ invalid={invalid("fd")}
473+ onChange={(v) => set({ fd: { ...spec.fd, ny: v } })}
474+ />
475+ <NumberField
476+ label="Simulated time"
477+ value={spec.fd.duration}
478+ unit="s"
479+ step={0.1}
480+ invalid={invalid("fd.duration")}
481+ onChange={(v) => set({ fd: { ...spec.fd, duration: v } })}
482+ />
483+ <div className="pl-small" style={{ marginTop: 6 }}>
484+ Time step
485+ </div>
486+ <CflSlider
487+ value={spec.fd.cfl}
488+ dtMax={fdDtMax}
489+ onChange={(v) => set({ fd: { ...spec.fd, cfl: v } })}
490+ />
491+ {fdGpu && <GpuToggle {...fdGpu} />}
492+ </Group>
493+ )}
494+ </div>
495+ );
496+}
497+
498+function MountFields({
499+ mount: m,
500+ spec,
501+ setMount,
502+ invalid,
503+}: {
504+ mount: Mount;
505+ spec: ProblemSpec;
506+ setMount: (patch: Partial<Mount>) => void;
507+ invalid: (path: string) => boolean;
508+}) {
509+ const padPresetId = (k: number, c: number) => PAD_PRESETS.find((p) => p.k === k && p.c === c)?.id;
510+ switch (m.type) {
511+ case "thread": {
512+ const threadId = THREAD_PRESETS.find(
513+ (t) => t.thread.diameter === m.thread.diameter && t.thread.G === m.thread.G,
514+ )?.id;
515+ return (
516+ <>
517+ <NumberField
518+ label="Hole x"
519+ value={m.x}
520+ scale={1000}
521+ unit="mm"
522+ invalid={invalid("mount")}
523+ onChange={(v) => setMount({ x: v })}
524+ />
525+ <NumberField
526+ label="Hole y"
527+ value={m.y}
528+ scale={1000}
529+ unit="mm"
530+ invalid={invalid("mount")}
531+ onChange={(v) => setMount({ y: v })}
532+ />
533+ <NumberField
534+ label="Thread length"
535+ value={m.length}
536+ scale={1000}
537+ unit="mm"
538+ step={10}
539+ invalid={invalid("mount.length")}
540+ onChange={(v) => setMount({ length: v })}
541+ />
542+ <div className="pl-field pl-field-wide">
543+ <label>Thread</label>
544+ <select
545+ className="pl-select"
546+ value={threadId ?? ""}
547+ onChange={(ev) => {
548+ const t = THREAD_PRESETS.find((x) => x.id === ev.target.value);
549+ if (t) setMount({ thread: { ...t.thread } });
550+ }}
551+ >
552+ <option value="">custom</option>
553+ {THREAD_PRESETS.map((t) => (
554+ <option key={t.id} value={t.id}>
555+ {t.thread.name}
556+ </option>
557+ ))}
558+ </select>
559+ </div>
560+ <NumberField
561+ label="Thread diameter"
562+ value={m.thread.diameter}
563+ scale={1000}
564+ unit="mm"
565+ step={0.1}
566+ invalid={invalid("mount.thread")}
567+ onChange={(v) =>
568+ setMount({ thread: { ...m.thread, diameter: v, name: customName(m.thread.name) } })
569+ }
570+ />
571+ <NumberField
572+ label="Thread shear modulus"
573+ value={m.thread.G}
574+ scale={1e-9}
575+ unit="GPa"
576+ step={0.1}
577+ invalid={invalid("mount.thread")}
578+ onChange={(v) =>
579+ setMount({ thread: { ...m.thread, G: v, name: customName(m.thread.name) } })
580+ }
581+ />
582+ <div className="pl-small pl-muted">
583+ The plate turns in its plane until its centre of mass hangs below the hole. The thread
584+ is a transverse spring of stiffness T/L at the hole; its torsion, from the diameter and
585+ modulus, is the only thing restraining the spin.
586+ </div>
587+ </>
588+ );
589+ }
590+ case "threads":
591+ return (
592+ <>
593+ <NumberField
594+ label="Hole 1 x"
595+ value={m.x1}
596+ scale={1000}
597+ unit="mm"
598+ invalid={invalid("mount")}
599+ onChange={(v) => setMount({ x1: v })}
600+ />
601+ <NumberField
602+ label="Hole 1 y"
603+ value={m.y1}
604+ scale={1000}
605+ unit="mm"
606+ invalid={invalid("mount")}
607+ onChange={(v) => setMount({ y1: v })}
608+ />
609+ <NumberField
610+ label="Hole 2 x"
611+ value={m.x2}
612+ scale={1000}
613+ unit="mm"
614+ invalid={invalid("mount")}
615+ onChange={(v) => setMount({ x2: v })}
616+ />
617+ <NumberField
618+ label="Hole 2 y"
619+ value={m.y2}
620+ scale={1000}
621+ unit="mm"
622+ invalid={invalid("mount")}
623+ onChange={(v) => setMount({ y2: v })}
624+ />
625+ <NumberField
626+ label="Thread length"
627+ value={m.length}
628+ scale={1000}
629+ unit="mm"
630+ step={10}
631+ invalid={invalid("mount.length")}
632+ onChange={(v) => setMount({ length: v })}
633+ />
634+ <div className="pl-small pl-muted">
635+ Two threads of one length from hooks at one height: the line of the holes hangs
636+ horizontal and statics shares the weight. The centre of mass must lie between the holes.
637+ </div>
638+ </>
639+ );
640+ case "pads": {
641+ const presetId = padPresetId(m.k, m.c);
642+ const setPad = (k: number, patch: Partial<Pad>) =>
643+ setMount({ pads: m.pads.map((p, i) => (i === k ? { ...p, ...patch } : p)) });
644+ return (
645+ <>
646+ <div className="pl-field pl-field-wide">
647+ <label>Pad material</label>
648+ <select
649+ className="pl-select"
650+ value={presetId ?? ""}
651+ onChange={(ev) => {
652+ const p = PAD_PRESETS.find((x) => x.id === ev.target.value);
653+ if (p) setMount({ k: p.k, c: p.c });
654+ }}
655+ >
656+ <option value="">custom</option>
657+ {PAD_PRESETS.map((p) => (
658+ <option key={p.id} value={p.id}>
659+ {p.name}
660+ </option>
661+ ))}
662+ </select>
663+ </div>
664+ {presetId && (
665+ <div className="pl-small pl-muted">
666+ {PAD_PRESETS.find((p) => p.id === presetId)?.note}
667+ </div>
668+ )}
669+ <NumberField
670+ label="Stiffness per area"
671+ value={m.k}
672+ scale={1e-6}
673+ unit="MN/m³"
674+ step={0.1}
675+ invalid={invalid("mount.k")}
676+ onChange={(v) => setMount({ k: v })}
677+ />
678+ <NumberField
679+ label="Damping per area"
680+ value={m.c}
681+ scale={1e-3}
682+ unit="kN·s/m³"
683+ step={0.1}
684+ invalid={invalid("mount.c")}
685+ onChange={(v) => setMount({ c: v })}
686+ />
687+ {m.pads.map((p, k) => (
688+ <div key={k} className="pl-subgroup">
689+ <div className="pl-row pl-small" style={{ justifyContent: "space-between" }}>
690+ <strong>Pad {k + 1}</strong>
691+ <button
692+ className="pl-linkbtn"
693+ onClick={() => setMount({ pads: m.pads.filter((_, i) => i !== k) })}
694+ disabled={m.pads.length <= 1}
695+ >
696+ remove
697+ </button>
698+ </div>
699+ <NumberField
700+ label="Centre x"
701+ value={p.x}
702+ scale={1000}
703+ unit="mm"
704+ onChange={(v) => setPad(k, { x: v })}
705+ />
706+ <NumberField
707+ label="Centre y"
708+ value={p.y}
709+ scale={1000}
710+ unit="mm"
711+ onChange={(v) => setPad(k, { y: v })}
712+ />
713+ <NumberField
714+ label="Half-width along x"
715+ value={p.rx}
716+ scale={1000}
717+ unit="mm"
718+ step={1}
719+ onChange={(v) => setPad(k, { rx: v })}
720+ />
721+ <NumberField
722+ label="Half-width along y"
723+ value={p.ry}
724+ scale={1000}
725+ unit="mm"
726+ step={1}
727+ onChange={(v) => setPad(k, { ry: v })}
728+ />
729+ </div>
730+ ))}
731+ <button
732+ className="pl-btn"
733+ onClick={() =>
734+ setMount({
735+ pads: [
736+ ...m.pads,
737+ { x: spec.plate.Lx / 2, y: spec.plate.Ly / 2, rx: 0.02, ry: 0.02 },
738+ ],
739+ })
740+ }
741+ >
742+ Add a pad
743+ </button>
744+ <div className="pl-small pl-muted">
745+ The plate lies flat on the pads. A pad pushes only while compressed, so a hard strike
746+ can lift the plate off it. A long narrow pad is a knife edge or a strip of felt.
747+ </div>
748+ </>
749+ );
750+ }
751+ case "cymbal": {
752+ const presetId = padPresetId(m.k, m.c);
753+ return (
754+ <>
755+ <NumberField
756+ label="Rod radius"
757+ value={m.r_rod}
758+ scale={1000}
759+ unit="mm"
760+ step={0.5}
761+ invalid={invalid("mount")}
762+ onChange={(v) => setMount({ r_rod: v })}
763+ />
764+ <NumberField
765+ label="Felt outer radius"
766+ value={m.r_felt}
767+ scale={1000}
768+ unit="mm"
769+ step={1}
770+ invalid={invalid("mount")}
771+ onChange={(v) => setMount({ r_felt: v })}
772+ />
773+ <div className="pl-field pl-field-wide">
774+ <label>Felt</label>
775+ <select
776+ className="pl-select"
777+ value={presetId ?? ""}
778+ onChange={(ev) => {
779+ const p = PAD_PRESETS.find((x) => x.id === ev.target.value);
780+ if (p) setMount({ k: p.k, c: p.c });
781+ }}
782+ >
783+ <option value="">custom</option>
784+ {PAD_PRESETS.map((p) => (
785+ <option key={p.id} value={p.id}>
786+ {p.name}
787+ </option>
788+ ))}
789+ </select>
790+ </div>
791+ <NumberField
792+ label="Felt stiffness per area"
793+ value={m.k}
794+ scale={1e-6}
795+ unit="MN/m³"
796+ step={1}
797+ invalid={invalid("mount.k")}
798+ onChange={(v) => setMount({ k: v })}
799+ />
800+ <NumberField
801+ label="Felt damping per area"
802+ value={m.c}
803+ scale={1e-3}
804+ unit="kN·s/m³"
805+ step={1}
806+ invalid={invalid("mount.c")}
807+ onChange={(v) => setMount({ c: v })}
808+ />
809+ <NumberField
810+ label="Wing-nut preload"
811+ value={m.preload}
812+ scale={1000}
813+ unit="mm"
814+ step={0.1}
815+ invalid={invalid("mount.preload")}
816+ onChange={(v) => setMount({ preload: v })}
817+ />
818+ <div className="pl-small pl-muted">
819+ A felt below and one above the plate, each compressed by the preload at rest: the centre
820+ is held two-sided until a strike separates one felt. The rod, the hole and the cymbal's
821+ bow are not modelled.
822+ </div>
823+ </>
824+ );
825+ }
826+ case "vise":
827+ return (
828+ <>
829+ <div className="pl-field pl-field-wide">
830+ <label>Edge in the jaws</label>
831+ <select
832+ className="pl-select"
833+ value={m.edge}
834+ onChange={(ev) => setMount({ edge: ev.target.value as ViseEdge })}
835+ >
836+ <option value="x0">x = 0 (left)</option>
837+ <option value="x1">x = Lx (right)</option>
838+ <option value="y0">y = 0 (bottom)</option>
839+ <option value="y1">y = Ly (top)</option>
840+ </select>
841+ </div>
842+ <NumberField
843+ label="Jaw depth"
844+ value={m.depth}
845+ scale={1000}
846+ unit="mm"
847+ step={1}
848+ invalid={invalid("mount.depth")}
849+ onChange={(v) => setMount({ depth: v })}
850+ />
851+ <NumberField
852+ label="Tilt"
853+ value={m.tilt}
854+ unit="°"
855+ step={5}
856+ min={0}
857+ max={180}
858+ invalid={invalid("mount.tilt")}
859+ onChange={(v) => setMount({ tilt: v })}
860+ />
861+ <div className="pl-small pl-muted">
862+ 0° hangs the plate straight down from the jaw, 90° is a horizontal cantilever, 180°
863+ stands it up with its weight in compression. The nodes within the jaws are held still
864+ and the clamped condition follows from the bending energy.
865+ </div>
866+ </>
867+ );
868+ case "pinched":
869+ return (
870+ <>
871+ <NumberField
872+ label="Pinch x"
873+ value={m.x}
874+ scale={1000}
875+ unit="mm"
876+ invalid={invalid("mount")}
877+ onChange={(v) => setMount({ x: v })}
878+ />
879+ <NumberField
880+ label="Pinch y"
881+ value={m.y}
882+ scale={1000}
883+ unit="mm"
884+ invalid={invalid("mount")}
885+ onChange={(v) => setMount({ y: v })}
886+ />
887+ <NumberField
888+ label="Patch radius"
889+ value={m.radius}
890+ scale={1000}
891+ unit="mm"
892+ step={1}
893+ invalid={invalid("mount.radius")}
894+ onChange={(v) => setMount({ radius: v })}
895+ />
896+ <NumberField
897+ label="Grip stiffness"
898+ value={m.k}
899+ unit="N/m"
900+ step={100}
901+ invalid={invalid("mount.k")}
902+ onChange={(v) => setMount({ k: v })}
903+ />
904+ <NumberField
905+ label="Grip damping"
906+ value={m.c}
907+ unit="N·s/m"
908+ step={1}
909+ invalid={invalid("mount.c")}
910+ onChange={(v) => setMount({ c: v })}
911+ />
912+ <NumberField
913+ label="Tilt"
914+ value={m.tilt}
915+ unit="°"
916+ step={5}
917+ min={0}
918+ max={90}
919+ invalid={invalid("mount.tilt")}
920+ onChange={(v) => setMount({ tilt: v })}
921+ />
922+ <div className="pl-small pl-muted">
923+ A two-sided spring and dashpot over the patch, both guesses of the right order for a
924+ hand; the patch is also fixed in the plane. 0° hangs the plate below the hand, 90° holds
925+ it flat. The plate hangs with its centre of mass below the pinch.
926+ </div>
927+ </>
928+ );
929+ }
930+}
931+
932+/**
933+ * The time loop has a WebGPU port, on by default: a run of a few seconds is
934+ * hundreds of thousands of steps, which numbl takes minutes over and the GPU
935+ * seconds. The double-precision loop in numbl remains the reference.
936+ */
937+function GpuToggle({
938+ enabled,
939+ onChange,
940+ support,
941+}: {
942+ enabled: boolean;
943+ onChange: (v: boolean) => void;
944+ support: WebGpuSupport;
945+}) {
946+ const available = support.status === "available";
947+ return (
948+ <div className="pl-gpu">
949+ <label className={`pl-check${available ? "" : " pl-disabled"}`}>
950+ <input
951+ type="checkbox"
952+ checked={enabled && available}
953+ disabled={!available}
954+ onChange={(ev) => onChange(ev.target.checked)}
955+ />
956+ Run the time loop on the GPU (WebGPU)
957+ </label>
958+ <div className="pl-small pl-muted">
959+ {support.status === "checking" && "Looking for WebGPU…"}
960+ {support.status === "unavailable" &&
961+ `Not available here (${support.reason}); the loop runs in numbl, which takes minutes for a run of seconds.`}
962+ {support.status === "available" &&
963+ (enabled
964+ ? `plate_fd_loop.m runs as its WebGPU port on ${support.adapter}, in single precision; setup, statics, stresses, sound and spectra stay in numbl. Editing plate_fd_loop.m sends the loop back to numbl.`
965+ : `Available on ${support.adapter}. Off, the double-precision loop runs in numbl, typically 30 to 100 times slower.`)}
966+ </div>
967+ </div>
968+ );
969+}
970+
971+function customName(name: string): string {
972+ return name.endsWith(" (edited)") ? name : `${name} (edited)`;
973+}
974+
975+function Group(props: {
976+ title: string;
977+ prefix: string;
978+ issues: ValidationIssue[];
979+ open: boolean;
980+ disabled?: boolean;
981+ children: ReactNode;
982+}) {
983+ const related = props.issues.filter(
984+ (i) => i.path === props.prefix || i.path.startsWith(props.prefix + "."),
985+ );
986+ return (
987+ <details className={`pl-group${props.disabled ? " pl-disabled" : ""}`} open={props.open}>
988+ <summary>
989+ {props.title}
990+ {related.some((i) => i.severity === "error") && (
991+ <span className="pl-badge pl-badge-error" style={{ marginLeft: 6 }}>
992+ check
993+ </span>
994+ )}
995+ </summary>
996+ {props.children}
997+ {related.map((i) => (
998+ <div
999+ key={i.path + i.message}
1000+ className={`pl-issue${i.severity === "warning" ? " pl-issue-warning" : ""}`}
1001+ >
1002+ {i.path}: {i.message}
1003+ </div>
1004+ ))}
1005+ </details>
1006+ );
1007+}
packages/components/src/plots/LinePlot.tsxadded+522−0View file
@@ -0,0 +1,522 @@
1+import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
2+
3+export interface Series {
4+ x: ArrayLike<number>;
5+ y: ArrayLike<number>;
6+ color?: string;
7+ label?: string;
8+ width?: number;
9+ dash?: number[];
10+}
11+
12+export type XRange = [number, number];
13+
14+export interface LinePlotProps {
15+ series: Series[];
16+ title?: string;
17+ xLabel?: string;
18+ yLabel?: string;
19+ /** Display multipliers (e.g. 1000 to plot seconds as milliseconds) */
20+ xScale?: number;
21+ yScale?: number;
22+ logY?: boolean;
23+ /** Visible x window in data units; null or undefined shows `defaultXRange` or everything */
24+ xRange?: XRange | null;
25+ /** Window shown when not zoomed (data units); zooming out past it up to the data extent is allowed */
26+ defaultXRange?: XRange;
27+ /** Enables zooming: drag to select, wheel to zoom about the pointer, double-click to reset. */
28+ onXRangeChange?: (r: XRange | null) => void;
29+ yRange?: [number, number];
30+ /** Vertical marker at this x (data units), e.g. the animation time */
31+ cursorX?: number;
32+ /** Crosshair with value readout at this x (data units), typically shared between plots */
33+ hoverX?: number | null;
34+ onHover?: (x: number | null) => void;
35+ onClick?: (x: number) => void;
36+ height?: number;
37+ /** Unit shown in the readout */
38+ yUnit?: string;
39+}
40+
41+const DEFAULT_COLORS = ["#1f5fa8", "#d1495b", "#2e8b57", "#8a5cbf", "#e08a1e", "#3aa7b8"];
42+const MARGIN = { left: 60, right: 12, top: 8, bottom: 30 };
43+
44+interface Geometry {
45+ width: number;
46+ height: number;
47+ xmin: number;
48+ xmax: number;
49+ ymin: number;
50+ ymax: number;
51+}
52+
53+/**
54+ * Canvas line plot. The data layer redraws only when the data or the window
55+ * changes; the cursor, crosshair and selection live on a separate overlay so
56+ * hovering costs nothing. Draws the solver's numbers; computes nothing about them.
57+ */
58+export function LinePlot(props: LinePlotProps) {
59+ const {
60+ series,
61+ title,
62+ xLabel,
63+ yLabel,
64+ xScale = 1,
65+ yScale = 1,
66+ logY,
67+ defaultXRange,
68+ onXRangeChange,
69+ yRange,
70+ cursorX,
71+ hoverX,
72+ onHover,
73+ onClick,
74+ height = 200,
75+ yUnit,
76+ } = props;
77+ const xRange = props.xRange ?? defaultXRange ?? null;
78+ const zoomed = !!props.xRange;
79+ const baseRef = useRef<HTMLCanvasElement>(null);
80+ const overRef = useRef<HTMLCanvasElement>(null);
81+ const boxRef = useRef<HTMLDivElement>(null);
82+ const geom = useRef<Geometry>({ width: 400, height, xmin: 0, xmax: 1, ymin: 0, ymax: 1 });
83+ const [width, setWidth] = useState(400);
84+ const [drag, setDrag] = useState<{ x0: number; x1: number } | null>(null);
85+ const dragRef = useRef<{ x0: number; x1: number } | null>(null);
86+
87+ // Track the container width
88+ useLayoutEffect(() => {
89+ const box = boxRef.current;
90+ if (!box) return;
91+ const update = () => setWidth(Math.max(120, box.clientWidth));
92+ update();
93+ const ro = new ResizeObserver(update);
94+ ro.observe(box);
95+ return () => ro.disconnect();
96+ }, []);
97+
98+ const pw = width - MARGIN.left - MARGIN.right;
99+ const ph = height - MARGIN.top - MARGIN.bottom;
100+
101+ // Data extents in display units
102+ const fullX = useRef<XRange>([0, 1]);
103+
104+ // ---- Data layer
105+ useEffect(() => {
106+ const canvas = baseRef.current;
107+ if (!canvas) return;
108+ const dpr = Math.min(2, window.devicePixelRatio || 1);
109+ canvas.width = Math.round(width * dpr);
110+ canvas.height = Math.round(height * dpr);
111+ const ctx = canvas.getContext("2d");
112+ if (!ctx) return;
113+ ctx.save();
114+ ctx.scale(dpr, dpr);
115+ ctx.clearRect(0, 0, width, height);
116+
117+ let dxmin = Infinity,
118+ dxmax = -Infinity;
119+ for (const s of series) {
120+ for (let i = 0; i < s.x.length; i++) {
121+ const x = s.x[i] * xScale;
122+ if (!Number.isFinite(x)) continue;
123+ if (x < dxmin) dxmin = x;
124+ if (x > dxmax) dxmax = x;
125+ }
126+ }
127+ if (!Number.isFinite(dxmin) || dxmin === dxmax) [dxmin, dxmax] = [0, 1];
128+ fullX.current = [dxmin, dxmax];
129+ let xmin = dxmin,
130+ xmax = dxmax;
131+ if (xRange) [xmin, xmax] = [xRange[0] * xScale, xRange[1] * xScale];
132+ if (xmin === xmax) xmax = xmin + 1;
133+
134+ // y extents over the visible window
135+ let ymin = Infinity,
136+ ymax = -Infinity;
137+ for (const s of series) {
138+ for (let i = 0; i < s.x.length; i++) {
139+ const x = s.x[i] * xScale;
140+ if (x < xmin || x > xmax) continue;
141+ let y = s.y[i] * yScale;
142+ if (!Number.isFinite(y)) continue;
143+ if (logY) {
144+ if (y <= 0) continue;
145+ y = Math.log10(y);
146+ }
147+ if (y < ymin) ymin = y;
148+ if (y > ymax) ymax = y;
149+ }
150+ }
151+ if (yRange)
152+ [ymin, ymax] = logY
153+ ? [Math.log10(yRange[0] * yScale), Math.log10(yRange[1] * yScale)]
154+ : [yRange[0] * yScale, yRange[1] * yScale];
155+ if (!Number.isFinite(ymin)) [ymin, ymax] = [0, 1];
156+ if (ymin === ymax) [ymin, ymax] = [ymin - 1, ymax + 1];
157+ if (!yRange && !logY) {
158+ const pad = 0.05 * (ymax - ymin);
159+ ymin -= pad;
160+ ymax += pad;
161+ }
162+ geom.current = { width, height, xmin, xmax, ymin, ymax };
163+ const X = (x: number) => MARGIN.left + ((x - xmin) / (xmax - xmin)) * pw;
164+ const Y = (y: number) => MARGIN.top + (1 - (y - ymin) / (ymax - ymin)) * ph;
165+
166+ ctx.font = "10px system-ui, sans-serif";
167+ ctx.fillStyle = "#5b6573";
168+ ctx.strokeStyle = "#e3e6ea";
169+ ctx.lineWidth = 1;
170+ for (const t of ticks(xmin, xmax, Math.max(3, Math.floor(pw / 70)))) {
171+ const px = X(t);
172+ ctx.beginPath();
173+ ctx.moveTo(px, MARGIN.top);
174+ ctx.lineTo(px, MARGIN.top + ph);
175+ ctx.stroke();
176+ ctx.textAlign = "center";
177+ ctx.fillText(fmtTick(t), px, MARGIN.top + ph + 12);
178+ }
179+ const yt = logY ? logTicks(ymin, ymax) : ticks(ymin, ymax, 5);
180+ for (const t of yt) {
181+ const py = Y(t);
182+ ctx.beginPath();
183+ ctx.moveTo(MARGIN.left, py);
184+ ctx.lineTo(MARGIN.left + pw, py);
185+ ctx.stroke();
186+ ctx.textAlign = "right";
187+ ctx.fillText(logY ? fmtTick(Math.pow(10, t)) : fmtTick(t), MARGIN.left - 4, py + 3);
188+ }
189+ ctx.strokeStyle = "#8a93a0";
190+ ctx.strokeRect(MARGIN.left + 0.5, MARGIN.top + 0.5, pw - 1, ph - 1);
191+ if (xLabel) {
192+ ctx.textAlign = "center";
193+ ctx.fillText(xLabel, MARGIN.left + pw / 2, height - 4);
194+ }
195+ if (yLabel) {
196+ ctx.save();
197+ ctx.translate(10, MARGIN.top + ph / 2);
198+ ctx.rotate(-Math.PI / 2);
199+ ctx.textAlign = "center";
200+ ctx.fillText(yLabel, 0, 0);
201+ ctx.restore();
202+ }
203+
204+ ctx.save();
205+ ctx.beginPath();
206+ ctx.rect(MARGIN.left, MARGIN.top, pw, ph);
207+ ctx.clip();
208+ series.forEach((s, si) => {
209+ ctx.strokeStyle = s.color ?? DEFAULT_COLORS[si % DEFAULT_COLORS.length];
210+ ctx.lineWidth = s.width ?? 1.2;
211+ ctx.setLineDash(s.dash ?? []);
212+ ctx.beginPath();
213+ let pen = false;
214+ const n = s.x.length;
215+ // Visible index window (x assumed increasing), decimated to about two points per pixel
216+ const i0 = Math.max(0, lowerBound(s.x, xmin / xScale) - 1);
217+ const i1 = Math.min(n - 1, lowerBound(s.x, xmax / xScale) + 1);
218+ const stride = Math.max(1, Math.floor((i1 - i0) / (pw * 2)));
219+ for (let i = i0; i <= i1; i += stride) {
220+ const x = s.x[i] * xScale;
221+ let y = s.y[i] * yScale;
222+ if (logY) y = y > 0 ? Math.log10(y) : NaN;
223+ if (!Number.isFinite(x) || !Number.isFinite(y)) {
224+ pen = false;
225+ continue;
226+ }
227+ const px = X(x),
228+ py = Y(y);
229+ if (pen) ctx.lineTo(px, py);
230+ else {
231+ ctx.moveTo(px, py);
232+ pen = true;
233+ }
234+ }
235+ ctx.stroke();
236+ });
237+ ctx.setLineDash([]);
238+ ctx.restore();
239+ ctx.restore();
240+ }, [series, xScale, yScale, logY, xRange, yRange, height, width, xLabel, yLabel, pw, ph]);
241+
242+ // ---- Overlay layer: cursor, crosshair with readout, drag selection
243+ useEffect(() => {
244+ const canvas = overRef.current;
245+ if (!canvas) return;
246+ const dpr = Math.min(2, window.devicePixelRatio || 1);
247+ canvas.width = Math.round(width * dpr);
248+ canvas.height = Math.round(height * dpr);
249+ const ctx = canvas.getContext("2d");
250+ if (!ctx) return;
251+ ctx.save();
252+ ctx.scale(dpr, dpr);
253+ ctx.clearRect(0, 0, width, height);
254+ const g = geom.current;
255+ const X = (x: number) => MARGIN.left + ((x - g.xmin) / (g.xmax - g.xmin)) * pw;
256+ const Y = (y: number) => MARGIN.top + (1 - (y - g.ymin) / (g.ymax - g.ymin)) * ph;
257+ ctx.beginPath();
258+ ctx.rect(MARGIN.left, MARGIN.top, pw, ph);
259+ ctx.clip();
260+ if (cursorX !== undefined && Number.isFinite(cursorX)) {
261+ const px = X(cursorX * xScale);
262+ ctx.strokeStyle = "#333";
263+ ctx.lineWidth = 1;
264+ ctx.beginPath();
265+ ctx.moveTo(px, MARGIN.top);
266+ ctx.lineTo(px, MARGIN.top + ph);
267+ ctx.stroke();
268+ }
269+ if (hoverX !== undefined && hoverX !== null && Number.isFinite(hoverX)) {
270+ const hx = hoverX * xScale;
271+ if (hx >= g.xmin && hx <= g.xmax) {
272+ const px = X(hx);
273+ ctx.strokeStyle = "rgba(0,0,0,0.35)";
274+ ctx.lineWidth = 1;
275+ ctx.setLineDash([3, 3]);
276+ ctx.beginPath();
277+ ctx.moveTo(px, MARGIN.top);
278+ ctx.lineTo(px, MARGIN.top + ph);
279+ ctx.stroke();
280+ ctx.setLineDash([]);
281+ ctx.font = "10px system-ui, sans-serif";
282+ const lines: { text: string; color: string; py: number }[] = [];
283+ series.forEach((s, si) => {
284+ const i = nearestIndex(s.x, hoverX);
285+ if (i < 0) return;
286+ const y = s.y[i] * yScale;
287+ if (!Number.isFinite(y)) return;
288+ const color = s.color ?? DEFAULT_COLORS[si % DEFAULT_COLORS.length];
289+ const py = Y(logY ? (y > 0 ? Math.log10(y) : g.ymin) : y);
290+ ctx.fillStyle = color;
291+ ctx.beginPath();
292+ ctx.arc(px, py, 3, 0, 2 * Math.PI);
293+ ctx.fill();
294+ lines.push({
295+ text: `${s.label ? s.label + ": " : ""}${fmtTick(y)}${yUnit ? " " + yUnit : ""}`,
296+ color,
297+ py,
298+ });
299+ });
300+ const boxW = Math.max(60, ...lines.map((l) => ctx.measureText(l.text).width + 10));
301+ const right = px + 8 + boxW > MARGIN.left + pw;
302+ const bx = right ? px - 8 - boxW : px + 8;
303+ const by = MARGIN.top + 4;
304+ const boxH = 14 * (lines.length + 1) + 4;
305+ ctx.fillStyle = "rgba(255,255,255,0.92)";
306+ ctx.fillRect(bx, by, boxW, boxH);
307+ ctx.strokeStyle = "#c9ced5";
308+ ctx.strokeRect(bx + 0.5, by + 0.5, boxW - 1, boxH - 1);
309+ ctx.textAlign = "left";
310+ ctx.fillStyle = "#5b6573";
311+ ctx.fillText(
312+ `${xLabel ? xLabel.replace(/\s*\[.*\]\s*$/, "") + " " : ""}${fmtTick(hx)}${unitOf(xLabel)}`,
313+ bx + 5,
314+ by + 12,
315+ );
316+ lines.forEach((l, k) => {
317+ ctx.fillStyle = l.color;
318+ ctx.fillText(l.text, bx + 5, by + 12 + 14 * (k + 1));
319+ });
320+ }
321+ }
322+ if (drag) {
323+ const a = Math.min(drag.x0, drag.x1),
324+ b = Math.max(drag.x0, drag.x1);
325+ ctx.fillStyle = "rgba(31,95,168,0.15)";
326+ ctx.fillRect(a, MARGIN.top, b - a, ph);
327+ }
328+ ctx.restore();
329+ }, [
330+ cursorX,
331+ hoverX,
332+ drag,
333+ series,
334+ xScale,
335+ yScale,
336+ logY,
337+ width,
338+ height,
339+ pw,
340+ ph,
341+ xLabel,
342+ yUnit,
343+ xRange,
344+ ]);
345+
346+ // ---- Interaction
347+ const toDataX = useCallback(
348+ (clientX: number, el: HTMLElement) => {
349+ const rect = el.getBoundingClientRect();
350+ const px = clientX - rect.left;
351+ const g = geom.current;
352+ const f = (px - MARGIN.left) / pw;
353+ return { px, x: (g.xmin + f * (g.xmax - g.xmin)) / xScale, inside: f >= 0 && f <= 1 };
354+ },
355+ [pw, xScale],
356+ );
357+
358+ const clampRange = useCallback(
359+ (a: number, b: number): XRange | null => {
360+ const [fmin, fmax] = fullX.current.map((v) => v / xScale) as XRange;
361+ let lo = Math.max(fmin, Math.min(a, b));
362+ let hi = Math.min(fmax, Math.max(a, b));
363+ if (hi - lo < (fmax - fmin) * 1e-4) return null;
364+ if (lo <= fmin && hi >= fmax) return null;
365+ return [lo, hi];
366+ },
367+ [xScale],
368+ );
369+
370+ // Wheel zoom needs a non-passive listener to prevent page scroll
371+ useEffect(() => {
372+ const el = overRef.current;
373+ if (!el || !onXRangeChange) return;
374+ const onWheel = (e: WheelEvent) => {
375+ const { x, inside } = toDataX(e.clientX, el);
376+ if (!inside) return;
377+ e.preventDefault();
378+ const g = geom.current;
379+ const factor = Math.exp(e.deltaY * 0.0015);
380+ const lo = x - (x - g.xmin / xScale) * factor;
381+ const hi = x + (g.xmax / xScale - x) * factor;
382+ onXRangeChange(clampRange(lo, hi));
383+ };
384+ el.addEventListener("wheel", onWheel, { passive: false });
385+ return () => el.removeEventListener("wheel", onWheel);
386+ }, [onXRangeChange, toDataX, clampRange, xScale]);
387+
388+ return (
389+ <div className="pl-plot" ref={boxRef}>
390+ {(title || zoomed) && (
391+ <div className="pl-plot-title">
392+ {title}
393+ {zoomed && onXRangeChange && (
394+ <button
395+ className="pl-linkbtn"
396+ onClick={() => onXRangeChange(null)}
397+ title="double-click the plot also resets"
398+ >
399+ reset zoom
400+ </button>
401+ )}
402+ </div>
403+ )}
404+ <div style={{ position: "relative", height }}>
405+ <canvas ref={baseRef} style={{ position: "absolute", inset: 0, width: "100%", height }} />
406+ <canvas
407+ ref={overRef}
408+ style={{
409+ position: "absolute",
410+ inset: 0,
411+ width: "100%",
412+ height,
413+ cursor: onXRangeChange ? "crosshair" : onClick ? "pointer" : "default",
414+ }}
415+ onMouseMove={(e) => {
416+ const { px, x, inside } = toDataX(e.clientX, e.currentTarget);
417+ if (dragRef.current) {
418+ dragRef.current = { ...dragRef.current, x1: px };
419+ setDrag(dragRef.current);
420+ }
421+ onHover?.(inside ? x : null);
422+ }}
423+ onMouseLeave={() => onHover?.(null)}
424+ onMouseDown={(e) => {
425+ if (!onXRangeChange || e.button !== 0) return;
426+ const { px, inside } = toDataX(e.clientX, e.currentTarget);
427+ if (!inside) return;
428+ dragRef.current = { x0: px, x1: px };
429+ setDrag(dragRef.current);
430+ }}
431+ onMouseUp={(e) => {
432+ const d = dragRef.current;
433+ dragRef.current = null;
434+ setDrag(null);
435+ const { x } = toDataX(e.clientX, e.currentTarget);
436+ if (d && Math.abs(d.x1 - d.x0) > 4 && onXRangeChange) {
437+ const g = geom.current;
438+ const toX = (px: number) =>
439+ (g.xmin + ((px - MARGIN.left) / pw) * (g.xmax - g.xmin)) / xScale;
440+ onXRangeChange(clampRange(toX(d.x0), toX(d.x1)));
441+ } else if (onClick) {
442+ onClick(x);
443+ }
444+ }}
445+ onDoubleClick={() => onXRangeChange?.(null)}
446+ />
447+ </div>
448+ {series.some((s) => s.label) && (
449+ <div className="pl-legend">
450+ {series.map((s, i) => (
451+ <span
452+ key={i}
453+ style={{ ["--c" as string]: s.color ?? DEFAULT_COLORS[i % DEFAULT_COLORS.length] }}
454+ >
455+ {s.label}
456+ </span>
457+ ))}
458+ {onXRangeChange && (
459+ <span className="pl-hint">drag or scroll to zoom, double-click to reset</span>
460+ )}
461+ </div>
462+ )}
463+ </div>
464+ );
465+}
466+
467+function unitOf(label?: string): string {
468+ const m = label?.match(/\[(.*)\]\s*$/);
469+ return m ? ` ${m[1]}` : "";
470+}
471+
472+/** First index with x[i] >= v (x increasing). */
473+function lowerBound(x: ArrayLike<number>, v: number): number {
474+ let lo = 0,
475+ hi = x.length;
476+ while (lo < hi) {
477+ const mid = (lo + hi) >> 1;
478+ if (x[mid] < v) lo = mid + 1;
479+ else hi = mid;
480+ }
481+ return lo;
482+}
483+
484+function nearestIndex(x: ArrayLike<number>, v: number): number {
485+ const n = x.length;
486+ if (n === 0) return -1;
487+ const i = lowerBound(x, v);
488+ if (i <= 0) return 0;
489+ if (i >= n) return n - 1;
490+ return Math.abs(x[i] - v) < Math.abs(x[i - 1] - v) ? i : i - 1;
491+}
492+
493+export function ticks(min: number, max: number, n: number): number[] {
494+ const span = max - min;
495+ if (!(span > 0)) return [min];
496+ const raw = span / n;
497+ const mag = Math.pow(10, Math.floor(Math.log10(raw)));
498+ const norm = raw / mag;
499+ const step = (norm < 1.5 ? 1 : norm < 3.5 ? 2 : norm < 7.5 ? 5 : 10) * mag;
500+ const out: number[] = [];
501+ for (let t = Math.ceil(min / step) * step; t <= max + 1e-9 * span; t += step)
502+ out.push(Number(t.toPrecision(12)));
503+ return out;
504+}
505+
506+function logTicks(lmin: number, lmax: number): number[] {
507+ const lo = Math.ceil(lmin),
508+ hi = Math.floor(lmax);
509+ if (hi < lo) return [lmin, lmax];
510+ // at most about six labelled decades, so wide dynamic ranges stay legible
511+ const step = Math.max(1, Math.ceil((hi - lo + 1) / 6));
512+ const out: number[] = [];
513+ for (let e = lo; e <= hi; e += step) out.push(e);
514+ return out;
515+}
516+
517+export function fmtTick(x: number): string {
518+ if (x === 0) return "0";
519+ const a = Math.abs(x);
520+ if (a >= 1e5 || a < 1e-3) return x.toExponential(2).replace("e+", "e");
521+ return String(Number(x.toPrecision(4)));
522+}
packages/components/src/plots/useSharedAxis.tsadded+18−0View file
@@ -0,0 +1,18 @@
1+import { useState } from "react";
2+import type { XRange } from "./LinePlot";
3+
4+/**
5+ * One x window and one hover position shared by several plots, so zooming or
6+ * pointing at one moves them all. Spread the result into each LinePlot.
7+ */
8+export function useSharedAxis() {
9+ const [xRange, setXRange] = useState<XRange | null>(null);
10+ const [hoverX, setHoverX] = useState<number | null>(null);
11+ return {
12+ xRange,
13+ hoverX,
14+ onXRangeChange: setXRange,
15+ onHover: setHoverX,
16+ reset: () => setXRange(null),
17+ };
18+}
packages/components/src/scene/Plate3DView.tsxadded+522−0View file
@@ -0,0 +1,522 @@
1+import { useEffect, useMemo, useRef, useState } from "react";
2+import {
3+ colormapTable,
4+ getColormap,
5+ type FieldFrames,
6+ type Mount,
7+ type PlateResult,
8+ type ProblemSpec,
9+ type Range,
10+} from "@plate-dyn/core";
11+
12+export interface Plate3DViewProps {
13+ result: PlateResult;
14+ spec: ProblemSpec;
15+ frameIndex: number;
16+ /** Frames that colour the surface (any per-node field) and their colour range */
17+ colorFrames?: FieldFrames;
18+ colorRange?: Range;
19+ colormap?: string;
20+ /** Exaggeration of the flexural field and the static sag */
21+ flexScale: number;
22+ /** Exaggeration of the rigid-body motion (1 is true scale) */
23+ rigidScale: number;
24+ width?: number;
25+ height?: number;
26+}
27+
28+type Vec3 = [number, number, number];
29+
30+const VS = `
31+attribute vec3 aPos;
32+attribute vec3 aNormal;
33+attribute vec3 aColor;
34+uniform mat4 uProj;
35+uniform mat4 uView;
36+varying vec3 vColor;
37+varying vec3 vNormal;
38+void main() {
39+ gl_Position = uProj * uView * vec4(aPos, 1.0);
40+ vColor = aColor;
41+ vNormal = (uView * vec4(aNormal, 0.0)).xyz;
42+}
43+`;
44+const FS = `
45+precision mediump float;
46+varying vec3 vColor;
47+varying vec3 vNormal;
48+uniform float uFlat;
49+void main() {
50+ vec3 n = normalize(vNormal);
51+ float diff = abs(dot(n, normalize(vec3(0.3, 0.6, 1.0))));
52+ float shade = mix(0.55 + 0.45 * diff, 1.0, uFlat);
53+ gl_FragColor = vec4(vColor * shade, 1.0);
54+}
55+`;
56+
57+function perspective(fovy: number, aspect: number, near: number, far: number): Float32Array {
58+ const f = 1 / Math.tan(fovy / 2);
59+ const out = new Float32Array(16);
60+ out[0] = f / aspect;
61+ out[5] = f;
62+ out[10] = (far + near) / (near - far);
63+ out[11] = -1;
64+ out[14] = (2 * far * near) / (near - far);
65+ return out;
66+}
67+
68+function lookAt(eye: Vec3, center: Vec3, up: Vec3): Float32Array {
69+ const z = norm(sub(eye, center));
70+ const x = norm(cross(up, z));
71+ const y = cross(z, x);
72+ const out = new Float32Array(16);
73+ out[0] = x[0];
74+ out[4] = x[1];
75+ out[8] = x[2];
76+ out[1] = y[0];
77+ out[5] = y[1];
78+ out[9] = y[2];
79+ out[2] = z[0];
80+ out[6] = z[1];
81+ out[10] = z[2];
82+ out[12] = -dot(x, eye);
83+ out[13] = -dot(y, eye);
84+ out[14] = -dot(z, eye);
85+ out[15] = 1;
86+ return out;
87+}
88+const sub = (a: Vec3, b: Vec3): Vec3 => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
89+const add = (a: Vec3, b: Vec3): Vec3 => [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
90+const scl = (a: Vec3, s: number): Vec3 => [a[0] * s, a[1] * s, a[2] * s];
91+const dot = (a: Vec3, b: Vec3) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
92+const cross = (a: Vec3, b: Vec3): Vec3 => [
93+ a[1] * b[2] - a[2] * b[1],
94+ a[2] * b[0] - a[0] * b[2],
95+ a[0] * b[1] - a[1] * b[0],
96+];
97+const norm = (a: Vec3): Vec3 => {
98+ const l = Math.hypot(a[0], a[1], a[2]) || 1;
99+ return [a[0] / l, a[1] / l, a[2] / l];
100+};
101+/** Rodrigues rotation of v about the unit axis k by angle a. */
102+function rotate(v: Vec3, k: Vec3, a: number): Vec3 {
103+ const c = Math.cos(a);
104+ const s = Math.sin(a);
105+ const kv = cross(k, v);
106+ const kd = dot(k, v);
107+ return [
108+ v[0] * c + kv[0] * s + k[0] * kd * (1 - c),
109+ v[1] * c + kv[1] * s + k[1] * kd * (1 - c),
110+ v[2] * c + kv[2] * s + k[2] * kd * (1 - c),
111+ ];
112+}
113+
114+/**
115+ * The plate in its mount, in the laboratory frame: gravity points down the
116+ * screen, the plate is turned as it hangs or lies, the threads go up to their
117+ * hooks, the pads sit under it, the jaws of the vise grip it. The surface is
118+ * displaced by the static sag and the flexural field (exaggerated by
119+ * flexScale) and moved as a rigid body by the frame's rigid coordinates
120+ * (applied as exact rotations about the pivot, scaled by rigidScale). Drag to
121+ * orbit, wheel to zoom.
122+ */
123+export function Plate3DView(props: Plate3DViewProps) {
124+ const {
125+ result,
126+ spec,
127+ frameIndex,
128+ colorFrames,
129+ colorRange,
130+ colormap = "bwr",
131+ flexScale,
132+ rigidScale,
133+ width = 460,
134+ height = 340,
135+ } = props;
136+ const canvasRef = useRef<HTMLCanvasElement>(null);
137+ const glRef = useRef<{
138+ gl: WebGLRenderingContext;
139+ prog: WebGLProgram;
140+ buffers: { pos: WebGLBuffer; nrm: WebGLBuffer; col: WebGLBuffer; idx: WebGLBuffer };
141+ lines: { pos: WebGLBuffer; col: WebGLBuffer; nrm: WebGLBuffer };
142+ loc: Record<string, number | WebGLUniformLocation | null>;
143+ } | null>(null);
144+ const [cam, setCam] = useState({ az: 0.6, el: 0.35, zoom: 1 });
145+ const drag = useRef<{ x: number; y: number } | null>(null);
146+
147+ const nx = result.grid.x.length;
148+ const ny = result.grid.y.length;
149+ const { Lx, Ly } = spec.plate;
150+ const size = Math.max(Lx, Ly);
151+
152+ // Lab frame: X horizontal in the plate's plane, Y up, Z the rest
153+ const frame = useMemo(() => {
154+ const s = result.static;
155+ const g: Vec3 = [s.gx, s.gy, s.gz];
156+ const gl = Math.hypot(...g);
157+ const up: Vec3 = gl > 0 ? scl(g, -1 / gl) : [0, 0, 1];
158+ const eh: Vec3 = [result.rigid.eh[0], result.rigid.eh[1], 0];
159+ const ed: Vec3 = [result.rigid.ed[0], result.rigid.ed[1], 0];
160+ let X: Vec3 = Math.abs(dot(eh, up)) < 0.99 ? eh : [1, 0, 0];
161+ X = norm(sub(X, scl(up, dot(X, up))));
162+ const Z = cross(X, up);
163+ const toLab = (p: Vec3): Vec3 => [dot(X, p), dot(up, p), dot(Z, p)];
164+ return { toLab, ed, eh, up };
165+ }, [result]);
166+
167+ // Static geometry of the triangles: which cells are on the plate
168+ const mesh = useMemo(() => {
169+ const w0 = result.static.w0.data;
170+ const inside = (i: number, j: number) => Number.isFinite(w0[i + j * ny]);
171+ const idx: number[] = [];
172+ for (let j = 0; j < nx - 1; j++)
173+ for (let i = 0; i < ny - 1; i++) {
174+ if (!(inside(i, j) && inside(i + 1, j) && inside(i, j + 1) && inside(i + 1, j + 1)))
175+ continue;
176+ const a = i + j * ny;
177+ const b = i + 1 + j * ny;
178+ const c = i + (j + 1) * ny;
179+ const d = i + 1 + (j + 1) * ny;
180+ idx.push(a, b, c, b, d, c);
181+ }
182+ return { index: new Uint16Array(idx), inside };
183+ }, [result, nx, ny]);
184+
185+ useEffect(() => {
186+ const canvas = canvasRef.current;
187+ if (!canvas) return;
188+ const gl = canvas.getContext("webgl", { antialias: true });
189+ if (!gl) return;
190+ const compile = (type: number, src: string) => {
191+ const sh = gl.createShader(type)!;
192+ gl.shaderSource(sh, src);
193+ gl.compileShader(sh);
194+ return sh;
195+ };
196+ const prog = gl.createProgram()!;
197+ gl.attachShader(prog, compile(gl.VERTEX_SHADER, VS));
198+ gl.attachShader(prog, compile(gl.FRAGMENT_SHADER, FS));
199+ gl.linkProgram(prog);
200+ const mk = () => gl.createBuffer()!;
201+ glRef.current = {
202+ gl,
203+ prog,
204+ buffers: { pos: mk(), nrm: mk(), col: mk(), idx: mk() },
205+ lines: { pos: mk(), col: mk(), nrm: mk() },
206+ loc: {
207+ aPos: gl.getAttribLocation(prog, "aPos"),
208+ aNormal: gl.getAttribLocation(prog, "aNormal"),
209+ aColor: gl.getAttribLocation(prog, "aColor"),
210+ uProj: gl.getUniformLocation(prog, "uProj"),
211+ uView: gl.getUniformLocation(prog, "uView"),
212+ uFlat: gl.getUniformLocation(prog, "uFlat"),
213+ },
214+ };
215+ return () => {
216+ glRef.current = null;
217+ };
218+ }, []);
219+
220+ useEffect(() => {
221+ const r = glRef.current;
222+ const canvas = canvasRef.current;
223+ if (!r || !canvas) return;
224+ const { gl, prog, buffers, lines, loc } = r;
225+ const dpr = Math.min(2, window.devicePixelRatio || 1);
226+ canvas.width = Math.round(width * dpr);
227+ canvas.height = Math.round(height * dpr);
228+ gl.viewport(0, 0, canvas.width, canvas.height);
229+
230+ // ---- node positions in the lab frame
231+ const N = nx * ny;
232+ const kf = Math.min(frameIndex, result.frames.t.length - 1);
233+ const phi = result.frames.w;
234+ const w0 = result.static.w0.data;
235+ const c = result.rigid.frames.map((row) => (kf >= 0 ? row[kf] : 0));
236+ const pivot: Vec3 = [result.rigid.pivot.x, result.rigid.pivot.y, 0];
237+ const { ed, eh, toLab } = frame;
238+ const flexOf = (n: number) => {
239+ const z0 = Number.isFinite(w0[n]) ? w0[n] : 0;
240+ const f = kf >= 0 ? phi.data[n + kf * N] : 0;
241+ return flexScale * (z0 + (Number.isFinite(f) ? f : 0));
242+ };
243+ const rigidPos = (p: Vec3): Vec3 => {
244+ if (result.rigid.nr === 0) return p;
245+ let q = sub(p, pivot);
246+ q = rotate(q, eh, -c[1] * rigidScale);
247+ q = rotate(q, ed, c[2] * rigidScale);
248+ q = add(q, pivot);
249+ return [q[0], q[1], q[2] + c[0] * rigidScale];
250+ };
251+ const pos = new Float32Array(3 * N);
252+ const labPos: Vec3[] = new Array(N);
253+ for (let j = 0; j < nx; j++)
254+ for (let i = 0; i < ny; i++) {
255+ const n = i + j * ny;
256+ const p: Vec3 = [result.grid.x[j], result.grid.y[i], flexOf(n)];
257+ const lp = toLab(rigidPos(p));
258+ labPos[n] = lp;
259+ pos[3 * n] = lp[0];
260+ pos[3 * n + 1] = lp[1];
261+ pos[3 * n + 2] = lp[2];
262+ }
263+ // normals from neighbouring nodes
264+ const nrm = new Float32Array(3 * N);
265+ for (let j = 0; j < nx; j++)
266+ for (let i = 0; i < ny; i++) {
267+ const n = i + j * ny;
268+ const jl = Math.max(0, j - 1);
269+ const jr = Math.min(nx - 1, j + 1);
270+ const il = Math.max(0, i - 1);
271+ const ir = Math.min(ny - 1, i + 1);
272+ const tx = sub(labPos[i + jr * ny], labPos[i + jl * ny]);
273+ const ty = sub(labPos[ir + j * ny], labPos[il + j * ny]);
274+ const nn = norm(cross(tx, ty));
275+ nrm[3 * n] = nn[0];
276+ nrm[3 * n + 1] = nn[1];
277+ nrm[3 * n + 2] = nn[2];
278+ }
279+ // colours
280+ const col = new Float32Array(3 * N);
281+ const table = colormapTable(getColormap(colormap));
282+ const range = colorRange ?? { min: -1, max: 1 };
283+ const span = range.max - range.min || 1;
284+ for (let n = 0; n < N; n++) {
285+ let v = colorFrames && kf >= 0 ? colorFrames.data[n + kf * N] : NaN;
286+ if (!Number.isFinite(v)) v = 0.5 * (range.min + range.max);
287+ let t = (v - range.min) / span;
288+ t = t < 0 ? 0 : t > 1 ? 1 : t;
289+ const k = Math.round(t * 255) * 3;
290+ col[3 * n] = table[k] / 255;
291+ col[3 * n + 1] = table[k + 1] / 255;
292+ col[3 * n + 2] = table[k + 2] / 255;
293+ }
294+
295+ // ---- the mount, as lines and boxes in the lab frame
296+ const L: number[] = [];
297+ const LC: number[] = [];
298+ const line = (a: Vec3, b: Vec3, rgb: Vec3) => {
299+ L.push(...a, ...b);
300+ LC.push(...rgb, ...rgb);
301+ };
302+ const grey: Vec3 = [0.35, 0.35, 0.38];
303+ const dark: Vec3 = [0.15, 0.15, 0.18];
304+ const red: Vec3 = [0.82, 0.29, 0.36];
305+ const box = (lo: Vec3, hi: Vec3, rgb: Vec3, toLabFn = toLab) => {
306+ const cs: Vec3[] = [];
307+ for (let k = 0; k < 8; k++)
308+ cs.push(toLabFn([k & 1 ? hi[0] : lo[0], k & 2 ? hi[1] : lo[1], k & 4 ? hi[2] : lo[2]]));
309+ const e = [
310+ [0, 1],
311+ [2, 3],
312+ [4, 5],
313+ [6, 7],
314+ [0, 2],
315+ [1, 3],
316+ [4, 6],
317+ [5, 7],
318+ [0, 4],
319+ [1, 5],
320+ [2, 6],
321+ [3, 7],
322+ ];
323+ for (const [a, b] of e) line(cs[a], cs[b], rgb);
324+ };
325+ const circle = (centre: Vec3, r: number, rgb: Vec3) => {
326+ const n = 32;
327+ for (let k = 0; k < n; k++) {
328+ const a0 = (2 * Math.PI * k) / n;
329+ const a1 = (2 * Math.PI * (k + 1)) / n;
330+ line(
331+ toLab([centre[0] + r * Math.cos(a0), centre[1] + r * Math.sin(a0), centre[2]]),
332+ toLab([centre[0] + r * Math.cos(a1), centre[1] + r * Math.sin(a1), centre[2]]),
333+ rgb,
334+ );
335+ }
336+ };
337+ const m: Mount = spec.mount;
338+ const nodeAt = (x: number, y: number): number => {
339+ let best = 0;
340+ let bd = Infinity;
341+ for (let j = 0; j < nx; j++)
342+ for (let i = 0; i < ny; i++) {
343+ const d = (result.grid.x[j] - x) ** 2 + (result.grid.y[i] - y) ** 2;
344+ if (d < bd && mesh.inside(i, j)) {
345+ bd = d;
346+ best = i + j * ny;
347+ }
348+ }
349+ return best;
350+ };
351+ const t = 0.02 * size; // a nominal thickness for the drawn parts
352+ if (m.type === "thread") {
353+ const n = nodeAt(m.x, m.y);
354+ const hole = labPos[n];
355+ const hook = add(
356+ toLab([result.grid.x[Math.floor(n / ny)], result.grid.y[n % ny], 0]),
357+ scl(frame.up, m.length),
358+ );
359+ line(hole, hook, dark);
360+ line(add(hook, [-t, 0, 0]), add(hook, [t, 0, 0]), dark);
361+ } else if (m.type === "threads") {
362+ for (const [x, y] of [
363+ [m.x1, m.y1],
364+ [m.x2, m.y2],
365+ ]) {
366+ const n = nodeAt(x, y);
367+ const hole = labPos[n];
368+ const hook = add(
369+ toLab([result.grid.x[Math.floor(n / ny)], result.grid.y[n % ny], 0]),
370+ scl(frame.up, m.length),
371+ );
372+ line(hole, hook, dark);
373+ line(add(hook, [-t, 0, 0]), add(hook, [t, 0, 0]), dark);
374+ }
375+ } else if (m.type === "pads") {
376+ for (const p of m.pads)
377+ box([p.x - p.rx, p.y - p.ry, -2 * t], [p.x + p.rx, p.y + p.ry, 0], grey);
378+ } else if (m.type === "cymbal") {
379+ const cx = Lx / 2;
380+ const cy = Ly / 2;
381+ circle([cx, cy, -t], m.r_felt, grey);
382+ circle([cx, cy, t], m.r_felt, grey);
383+ circle([cx, cy, -t], m.r_rod, dark);
384+ line(toLab([cx, cy, -6 * t]), toLab([cx, cy, 3 * t]), dark);
385+ } else if (m.type === "vise") {
386+ const d = m.depth;
387+ const lo: Vec3 =
388+ m.edge === "x0"
389+ ? [-t, -t, -t]
390+ : m.edge === "x1"
391+ ? [Lx - d, -t, -t]
392+ : m.edge === "y0"
393+ ? [-t, -t, -t]
394+ : [-t, Ly - d, -t];
395+ const hi: Vec3 =
396+ m.edge === "x0"
397+ ? [d, Ly + t, t]
398+ : m.edge === "x1"
399+ ? [Lx + t, Ly + t, t]
400+ : m.edge === "y0"
401+ ? [Lx + t, d, t]
402+ : [Lx + t, Ly + t, t];
403+ box(lo, hi, dark);
404+ } else if (m.type === "pinched") {
405+ const n = nodeAt(m.x, m.y);
406+ const p = labPos[n];
407+ const nrmL = norm(
408+ cross(
409+ sub(labPos[nodeAt(m.x + Lx / 10, m.y)], p),
410+ sub(labPos[nodeAt(m.x, m.y + Ly / 10)], p),
411+ ),
412+ );
413+ line(add(p, scl(nrmL, t)), add(p, scl(nrmL, 4 * t)), dark);
414+ line(add(p, scl(nrmL, -t)), add(p, scl(nrmL, -4 * t)), dark);
415+ }
416+ // gravity arrow beside the plate
417+ {
418+ const corner = add(toLab([Lx * 1.15, Ly * 1.05, 0]), [0, 0, 0]);
419+ const tip = add(corner, scl(frame.up, -0.25 * size));
420+ line(corner, tip, red);
421+ const side = scl(cross(frame.up, [0, 0, 1]), 0.03 * size);
422+ line(tip, add(add(tip, scl(frame.up, 0.06 * size)), side), red);
423+ line(tip, sub(add(tip, scl(frame.up, 0.06 * size)), side), red);
424+ }
425+ // outline of the undeformed plate at rest, faint
426+ {
427+ const pale: Vec3 = [0.75, 0.75, 0.78];
428+ if (spec.plate.shape === "ellipse") circle([Lx / 2, Ly / 2, 0], Lx / 2, pale);
429+ else box([0, 0, 0], [Lx, Ly, 0], pale);
430+ }
431+
432+ // ---- camera
433+ const centre = toLab([Lx / 2, Ly / 2, 0]);
434+ const dist = (2.2 * size) / cam.zoom;
435+ const eye: Vec3 = add(centre, [
436+ dist * Math.cos(cam.el) * Math.sin(cam.az),
437+ dist * Math.sin(cam.el),
438+ dist * Math.cos(cam.el) * Math.cos(cam.az),
439+ ]);
440+ const view = lookAt(eye, centre, [0, 1, 0]);
441+ const proj = perspective(0.6, width / height, 0.01 * size, 20 * size);
442+
443+ gl.clearColor(0.97, 0.97, 0.975, 1);
444+ gl.enable(gl.DEPTH_TEST);
445+ gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
446+ gl.useProgram(prog);
447+ gl.uniformMatrix4fv(loc.uProj as WebGLUniformLocation, false, proj);
448+ gl.uniformMatrix4fv(loc.uView as WebGLUniformLocation, false, view);
449+ const attr = (buf: WebGLBuffer, data: Float32Array, a: number) => {
450+ gl.bindBuffer(gl.ARRAY_BUFFER, buf);
451+ gl.bufferData(gl.ARRAY_BUFFER, data, gl.DYNAMIC_DRAW);
452+ gl.enableVertexAttribArray(a);
453+ gl.vertexAttribPointer(a, 3, gl.FLOAT, false, 0, 0);
454+ };
455+ gl.uniform1f(loc.uFlat as WebGLUniformLocation, 0);
456+ attr(buffers.pos, pos, loc.aPos as number);
457+ attr(buffers.nrm, nrm, loc.aNormal as number);
458+ attr(buffers.col, col, loc.aColor as number);
459+ gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, buffers.idx);
460+ gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, mesh.index, gl.STATIC_DRAW);
461+ gl.drawElements(gl.TRIANGLES, mesh.index.length, gl.UNSIGNED_SHORT, 0);
462+ // lines
463+ gl.uniform1f(loc.uFlat as WebGLUniformLocation, 1);
464+ const lp = new Float32Array(L);
465+ attr(lines.pos, lp, loc.aPos as number);
466+ attr(lines.nrm, new Float32Array(L.length), loc.aNormal as number);
467+ attr(lines.col, new Float32Array(LC), loc.aColor as number);
468+ gl.drawArrays(gl.LINES, 0, lp.length / 3);
469+ }, [
470+ result,
471+ spec,
472+ frameIndex,
473+ colorFrames,
474+ colorRange,
475+ colormap,
476+ flexScale,
477+ rigidScale,
478+ width,
479+ height,
480+ cam,
481+ frame,
482+ mesh,
483+ nx,
484+ ny,
485+ Lx,
486+ Ly,
487+ size,
488+ ]);
489+
490+ return (
491+ <canvas
492+ ref={canvasRef}
493+ style={{ width, height, display: "block", cursor: "grab", borderRadius: 4 }}
494+ onPointerDown={(e) => {
495+ drag.current = { x: e.clientX, y: e.clientY };
496+ (e.target as HTMLElement).setPointerCapture(e.pointerId);
497+ }}
498+ onPointerMove={(e) => {
499+ if (!drag.current) return;
500+ const dx = e.clientX - drag.current.x;
501+ const dy = e.clientY - drag.current.y;
502+ drag.current = { x: e.clientX, y: e.clientY };
503+ setCam((c) => ({
504+ ...c,
505+ az: c.az - dx * 0.01,
506+ el: Math.max(-1.4, Math.min(1.4, c.el + dy * 0.01)),
507+ }));
508+ }}
509+ onPointerUp={() => {
510+ drag.current = null;
511+ }}
512+ onWheel={(e) => {
513+ e.preventDefault();
514+ setCam((c) => ({
515+ ...c,
516+ zoom: Math.max(0.3, Math.min(5, c.zoom * Math.exp(-e.deltaY * 0.001))),
517+ }));
518+ }}
519+ aria-label="the plate in its mount"
520+ />
521+ );
522+}
packages/components/src/status/EquationsView.tsxadded+23−0View file
@@ -0,0 +1,23 @@
1+import { useMemo } from "react";
2+import katex from "katex";
3+import "katex/dist/katex.min.css";
4+import type { Equation } from "@plate-dyn/core";
5+
6+/** Renders a method's equations with their captions. */
7+export function EquationsView({ equations }: { equations: Equation[] }) {
8+ const html = useMemo(
9+ () =>
10+ equations.map((e) => katex.renderToString(e.tex, { displayMode: true, throwOnError: false })),
11+ [equations],
12+ );
13+ return (
14+ <div className="pl-equations">
15+ {equations.map((e, i) => (
16+ <div className="pl-equation" key={i}>
17+ <div dangerouslySetInnerHTML={{ __html: html[i] }} />
18+ <div className="pl-equation-caption">{e.caption}</div>
19+ </div>
20+ ))}
21+ </div>
22+ );
23+}
packages/components/src/status/RunStatus.tsxadded+160−0View file
@@ -0,0 +1,160 @@
1+import type { BackendInfo, MethodDefinition, PlateResult } from "@plate-dyn/core";
2+import type { SolveState } from "../hooks/useSolve";
3+
4+export interface RunStatusProps {
5+ state: SolveState<unknown>;
6+ result?: PlateResult | null;
7+ method?: MethodDefinition;
8+ backend?: BackendInfo | null;
9+ /** Predicted wall time for the next run [ms], shown while idle */
10+ estimateMs?: number;
11+ onCancel?: () => void;
12+}
13+
14+/** Makes the execution visible: what ran, where, how big it was and how long it took. */
15+export function RunStatus({
16+ state,
17+ result,
18+ method,
19+ backend,
20+ estimateMs,
21+ onCancel,
22+}: RunStatusProps) {
23+ const be = state.status === "done" || state.status === "error" ? state.run.backend : backend;
24+ const wallMs = state.status === "done" || state.status === "error" ? state.run.wallMs : null;
25+ const compute =
26+ state.status === "done" || state.status === "error" ? state.run.compute : undefined;
27+ const onGpu = compute?.loop === "webgpu";
28+ return (
29+ <div className="pl-status">
30+ <div className="pl-row">
31+ {state.status === "idle" && <span className="pl-badge pl-badge-info">ready</span>}
32+ {state.status === "running" && (
33+ <>
34+ <span className="pl-badge pl-badge-info">running</span>
35+ {onCancel && (
36+ <button className="pl-btn pl-btn-danger" onClick={onCancel}>
37+ Cancel
38+ </button>
39+ )}
40+ </>
41+ )}
42+ {state.status === "done" && <span className="pl-badge pl-badge-ok">finished</span>}
43+ {state.status === "error" && (
44+ <span className="pl-badge pl-badge-error">
45+ {state.run.cancelled ? "cancelled" : "error"}
46+ </span>
47+ )}
48+ {result && !result.info.stable && (
49+ <span className="pl-badge pl-badge-error">
50+ diverged at step {result.info.diverged_step}
51+ </span>
52+ )}
53+ {method && <span className="pl-muted">{method.name}</span>}
54+ </div>
55+ {state.status === "running" && (
56+ <div className="pl-progress">
57+ <div />
58+ </div>
59+ )}
60+ {state.status === "error" && <div className="pl-error">{state.run.error}</div>}
61+ <table>
62+ <tbody>
63+ <tr>
64+ <td>Backend</td>
65+ <td>
66+ {be
67+ ? `${be.engine} ${be.version}, ${be.optimization === "1" ? "JS JIT (opt 1)" : "interpreter (opt 0)"}, ${be.host === "web-worker" ? "Web Worker in this browser" : "Node.js"}`
68+ : "numbl (not yet started)"}
69+ </td>
70+ </tr>
71+ <tr>
72+ <td>Wall time</td>
73+ <td>
74+ {wallMs !== null ? (
75+ onGpu ? (
76+ <>
77+ {fmtMs(wallMs)} in total: {fmtMs(compute.gpuMs ?? 0)} for the time loop on the
78+ GPU, {fmtMs(compute.numblMs ?? 0)} in numbl for setup and output
79+ </>
80+ ) : (
81+ <>
82+ {fmtMs(wallMs)} in numbl
83+ {result
84+ ? ` (${fmtMs(result.info.wall_s * 1000)} inside the solver by tic/toc)`
85+ : ""}
86+ </>
87+ )
88+ ) : state.status === "running" ? (
89+ "…"
90+ ) : estimateMs !== undefined ? (
91+ `about ${fmtMs(estimateMs)} expected`
92+ ) : (
93+ "–"
94+ )}
95+ </td>
96+ </tr>
97+ {compute && (
98+ <tr>
99+ <td>Time loop</td>
100+ <td>
101+ {onGpu
102+ ? `WebGPU port of plate_fd_loop.m on ${compute.adapter ?? "the GPU"}, single precision`
103+ : `numbl${compute.note ? `: ${compute.note}` : ""}`}
104+ </td>
105+ </tr>
106+ )}
107+ {result && (
108+ <tr>
109+ <td>Problem size</td>
110+ <td>{problemSize(result)}</td>
111+ </tr>
112+ )}
113+ {result && (
114+ <tr>
115+ <td>Time step</td>
116+ <td>
117+ {fmtTime(result.info.dt)}, {result.info.nsteps.toLocaleString()} steps,{" "}
118+ {result.t.length.toLocaleString()} samples in the series
119+ </td>
120+ </tr>
121+ )}
122+ </tbody>
123+ </table>
124+ {result && result.info.notes.length > 0 && (
125+ <div>
126+ {result.info.notes.map((n, i) => (
127+ <div className="pl-note" key={i}>
128+ {n}
129+ </div>
130+ ))}
131+ </div>
132+ )}
133+ {(state.status === "running" || state.status === "done" || state.status === "error") &&
134+ state.output.length > 0 && (
135+ <details>
136+ <summary className="pl-small pl-muted">
137+ Script output ({state.output.length} lines)
138+ </summary>
139+ <div className="pl-log">{state.output.join("")}</div>
140+ </details>
141+ )}
142+ </div>
143+ );
144+}
145+
146+function problemSize(r: PlateResult): string {
147+ const x = r.info.extra;
148+ return `${x.nx} × ${x.ny} = ${r.info.ncells.toLocaleString()} nodes, ${Number(x.nunknowns).toLocaleString()} unknowns, ${r.rigid.nr} rigid-body coordinates, ${Number(x.cell_steps).toLocaleString()} cell-steps, ${r.frames.t.length} frames`;
149+}
150+
151+export function fmtMs(ms: number): string {
152+ if (ms < 1000) return `${ms.toFixed(0)} ms`;
153+ return `${(ms / 1000).toFixed(2)} s`;
154+}
155+
156+export function fmtTime(s: number): string {
157+ if (s >= 1) return `${s.toPrecision(3)} s`;
158+ if (s >= 1e-3) return `${(s * 1e3).toPrecision(3)} ms`;
159+ return `${(s * 1e6).toPrecision(3)} µs`;
160+}
packages/components/src/status/ValidityNotes.tsxadded+32−0View file
@@ -0,0 +1,32 @@
1+import type { MethodDefinition, PlateResult } from "@plate-dyn/core";
2+
3+/** What the method idealizes in general, and what this particular run approximated. */
4+export function ValidityNotes({
5+ method,
6+ result,
7+}: {
8+ method: MethodDefinition;
9+ result?: PlateResult | null;
10+}) {
11+ return (
12+ <div className="pl-small">
13+ <ul style={{ margin: "4px 0", paddingLeft: 18 }}>
14+ {method.validity.map((v, i) => (
15+ <li key={i}>{v}</li>
16+ ))}
17+ </ul>
18+ {result && result.info.notes.length > 0 && (
19+ <>
20+ <div className="pl-muted" style={{ marginTop: 6 }}>
21+ This run:
22+ </div>
23+ <ul style={{ margin: "4px 0", paddingLeft: 18 }}>
24+ {result.info.notes.map((n, i) => (
25+ <li key={i}>{n}</li>
26+ ))}
27+ </ul>
28+ </>
29+ )}
30+ </div>
31+ );
32+}
packages/components/src/styles.cssadded+459−0View file
@@ -0,0 +1,459 @@
1+/* Plate lab components. Prefix: pl- */
2+:root {
3+ --pl-bg: #ffffff;
4+ --pl-panel: #f6f7f9;
5+ --pl-border: #d9dde3;
6+ --pl-text: #1c2430;
7+ --pl-muted: #5b6573;
8+ --pl-accent: #1f5fa8;
9+ --pl-warn: #a35a00;
10+ --pl-warn-bg: #fff4e0;
11+ --pl-error: #a11d2b;
12+ --pl-error-bg: #fde9eb;
13+ --pl-ok: #1f7a3a;
14+ --pl-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
15+ --pl-font: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
16+}
17+.pl-panel {
18+ background: var(--pl-panel);
19+ border: 1px solid var(--pl-border);
20+ border-radius: 6px;
21+ padding: 10px 12px;
22+}
23+.pl-panel h3 {
24+ margin: 0 0 8px;
25+ font-size: 13px;
26+ text-transform: uppercase;
27+ letter-spacing: 0.04em;
28+ color: var(--pl-muted);
29+}
30+.pl-muted {
31+ color: var(--pl-muted);
32+}
33+.pl-small {
34+ font-size: 12px;
35+}
36+.pl-mono {
37+ font-family: var(--pl-mono);
38+}
39+.pl-row {
40+ display: flex;
41+ gap: 8px;
42+ align-items: center;
43+ flex-wrap: wrap;
44+}
45+.pl-badge {
46+ display: inline-block;
47+ padding: 1px 7px;
48+ border-radius: 10px;
49+ font-size: 11px;
50+ font-weight: 600;
51+}
52+.pl-badge-ok {
53+ background: #e3f4e8;
54+ color: var(--pl-ok);
55+}
56+.pl-badge-warn {
57+ background: var(--pl-warn-bg);
58+ color: var(--pl-warn);
59+}
60+.pl-badge-error {
61+ background: var(--pl-error-bg);
62+ color: var(--pl-error);
63+}
64+.pl-badge-info {
65+ background: #e6eef9;
66+ color: var(--pl-accent);
67+}
68+.pl-note {
69+ padding: 6px 8px;
70+ border-left: 3px solid var(--pl-warn);
71+ background: var(--pl-warn-bg);
72+ font-size: 12px;
73+ margin: 4px 0;
74+}
75+.pl-error {
76+ padding: 8px 10px;
77+ border-left: 3px solid var(--pl-error);
78+ background: var(--pl-error-bg);
79+ font-family: var(--pl-mono);
80+ font-size: 12px;
81+ white-space: pre-wrap;
82+}
83+button.pl-btn {
84+ font: inherit;
85+ padding: 5px 12px;
86+ border-radius: 5px;
87+ border: 1px solid var(--pl-border);
88+ background: #fff;
89+ cursor: pointer;
90+ color: var(--pl-text);
91+}
92+button.pl-btn:hover {
93+ border-color: var(--pl-accent);
94+}
95+button.pl-btn:disabled {
96+ opacity: 0.5;
97+ cursor: default;
98+}
99+button.pl-btn-primary {
100+ background: var(--pl-accent);
101+ color: #fff;
102+ border-color: var(--pl-accent);
103+}
104+button.pl-btn-danger {
105+ background: #fff;
106+ color: var(--pl-error);
107+ border-color: var(--pl-error);
108+}
109+
110+/* Parameter panel */
111+.pl-params {
112+ display: flex;
113+ flex-direction: column;
114+ gap: 10px;
115+}
116+.pl-group {
117+ border: 1px solid var(--pl-border);
118+ border-radius: 6px;
119+ padding: 8px 10px;
120+ background: #fff;
121+}
122+.pl-group > summary,
123+.pl-group-title {
124+ font-weight: 600;
125+ font-size: 13px;
126+ cursor: pointer;
127+ margin-bottom: 6px;
128+}
129+.pl-field {
130+ display: grid;
131+ grid-template-columns: 1fr 100px 46px;
132+ gap: 6px;
133+ align-items: center;
134+ font-size: 12px;
135+ margin: 3px 0;
136+}
137+.pl-field label {
138+ color: var(--pl-text);
139+}
140+.pl-field input,
141+.pl-field select,
142+.pl-select {
143+ font: inherit;
144+ font-size: 12px;
145+ padding: 3px 5px;
146+ border: 1px solid var(--pl-border);
147+ border-radius: 4px;
148+ width: 100%;
149+ box-sizing: border-box;
150+}
151+.pl-field input.pl-invalid {
152+ border-color: var(--pl-error);
153+ background: var(--pl-error-bg);
154+}
155+.pl-field .pl-unit {
156+ color: var(--pl-muted);
157+ font-size: 11px;
158+}
159+.pl-field-wide {
160+ grid-template-columns: 1fr 2fr;
161+}
162+.pl-slider {
163+ display: grid;
164+ grid-template-columns: 1fr 70px;
165+ gap: 8px;
166+ align-items: center;
167+ font-size: 12px;
168+}
169+.pl-slider input[type="range"] {
170+ width: 100%;
171+}
172+.pl-issue {
173+ font-size: 11px;
174+ color: var(--pl-error);
175+ margin: 2px 0 4px;
176+}
177+.pl-issue-warning {
178+ color: var(--pl-warn);
179+}
180+.pl-disabled {
181+ opacity: 0.55;
182+}
183+.pl-gpu {
184+ margin-top: 8px;
185+ padding-top: 6px;
186+ border-top: 1px dashed var(--pl-border);
187+}
188+.pl-check {
189+ display: flex;
190+ gap: 6px;
191+ align-items: center;
192+ font-size: 12px;
193+ cursor: pointer;
194+}
195+.pl-check input {
196+ margin: 0;
197+}
198+
199+/* Code view */
200+.pl-code {
201+ display: flex;
202+ flex-direction: column;
203+ border: 1px solid var(--pl-border);
204+ border-radius: 6px;
205+ overflow: hidden;
206+ background: #fff;
207+ min-height: 0;
208+}
209+.pl-code-tabs {
210+ display: flex;
211+ flex-wrap: wrap;
212+ gap: 2px;
213+ padding: 4px 6px;
214+ background: var(--pl-panel);
215+ border-bottom: 1px solid var(--pl-border);
216+}
217+.pl-code-tab {
218+ font: inherit;
219+ font-size: 12px;
220+ font-family: var(--pl-mono);
221+ padding: 3px 8px;
222+ border: 1px solid transparent;
223+ background: transparent;
224+ border-radius: 4px;
225+ cursor: pointer;
226+ color: var(--pl-muted);
227+}
228+.pl-code-tab.pl-active {
229+ background: #fff;
230+ border-color: var(--pl-border);
231+ color: var(--pl-text);
232+}
233+.pl-code-tab.pl-modified::after {
234+ content: " *";
235+ color: var(--pl-warn);
236+}
237+.pl-code-toolbar {
238+ display: flex;
239+ gap: 8px;
240+ align-items: center;
241+ padding: 4px 8px;
242+ font-size: 12px;
243+ border-bottom: 1px solid var(--pl-border);
244+ color: var(--pl-muted);
245+}
246+.pl-code-editor {
247+ flex: 1;
248+ min-height: 200px;
249+ overflow: auto;
250+}
251+.pl-code-editor .cm-editor {
252+ height: 100%;
253+ font-size: 12.5px;
254+}
255+.pl-code-editor .cm-scroller {
256+ font-family: var(--pl-mono);
257+}
258+
259+/* Field view */
260+.pl-fieldview {
261+ display: flex;
262+ flex-direction: column;
263+ gap: 6px;
264+}
265+.pl-fieldview-head {
266+ display: flex;
267+ justify-content: space-between;
268+ align-items: baseline;
269+ font-size: 12px;
270+}
271+.pl-fieldview-body {
272+ display: grid;
273+ grid-template-columns: 1fr 56px;
274+ gap: 8px;
275+ align-items: start;
276+}
277+.pl-fieldview canvas {
278+ width: 100%;
279+ height: auto;
280+ display: block;
281+ image-rendering: auto;
282+ border: 1px solid var(--pl-border);
283+ background: #fff;
284+}
285+.pl-colorbar {
286+ display: grid;
287+ grid-template-rows: auto minmax(0, 1fr) auto;
288+ font-size: 10px;
289+ color: var(--pl-muted);
290+ text-align: left;
291+}
292+.pl-colorbar canvas {
293+ width: 14px;
294+ height: 100%;
295+ min-height: 0;
296+ border: 1px solid var(--pl-border);
297+}
298+.pl-player {
299+ display: grid;
300+ grid-template-columns: auto 1fr auto auto;
301+ gap: 8px;
302+ align-items: center;
303+ font-size: 12px;
304+}
305+.pl-player input[type="range"] {
306+ width: 100%;
307+}
308+
309+/* Plots */
310+.pl-plot {
311+ position: relative;
312+}
313+.pl-plot canvas {
314+ width: 100%;
315+ height: auto;
316+ display: block;
317+}
318+.pl-plot-title {
319+ font-size: 12px;
320+ font-weight: 600;
321+ margin-bottom: 2px;
322+}
323+.pl-legend {
324+ display: flex;
325+ gap: 12px;
326+ font-size: 11px;
327+ color: var(--pl-muted);
328+ flex-wrap: wrap;
329+}
330+.pl-legend span::before {
331+ content: "";
332+ display: inline-block;
333+ width: 12px;
334+ height: 3px;
335+ margin-right: 4px;
336+ vertical-align: middle;
337+ background: var(--c);
338+}
339+
340+/* Equations */
341+.pl-equations {
342+ display: flex;
343+ flex-direction: column;
344+ gap: 10px;
345+}
346+.pl-equation {
347+ font-size: 13px;
348+}
349+.pl-equation .katex-display {
350+ margin: 4px 0;
351+ overflow-x: auto;
352+ overflow-y: hidden;
353+}
354+.pl-equation-caption {
355+ font-size: 12px;
356+ color: var(--pl-muted);
357+}
358+
359+/* Status */
360+.pl-status {
361+ font-size: 12px;
362+ display: flex;
363+ flex-direction: column;
364+ gap: 6px;
365+}
366+.pl-status table {
367+ border-collapse: collapse;
368+}
369+.pl-status td {
370+ padding: 1px 10px 1px 0;
371+ vertical-align: top;
372+}
373+.pl-status td:first-child {
374+ color: var(--pl-muted);
375+}
376+.pl-log {
377+ font-family: var(--pl-mono);
378+ font-size: 11px;
379+ white-space: pre-wrap;
380+ max-height: 120px;
381+ overflow: auto;
382+ background: #fff;
383+ border: 1px solid var(--pl-border);
384+ padding: 4px 6px;
385+}
386+.pl-progress {
387+ height: 4px;
388+ background: var(--pl-border);
389+ border-radius: 2px;
390+ overflow: hidden;
391+}
392+.pl-progress > div {
393+ height: 100%;
394+ background: var(--pl-accent);
395+ animation: pl-indeterminate 1.2s infinite ease-in-out;
396+ width: 30%;
397+}
398+@keyframes pl-indeterminate {
399+ 0% {
400+ margin-left: -30%;
401+ }
402+ 100% {
403+ margin-left: 100%;
404+ }
405+}
406+
407+/* Sub-groups (pads) */
408+.pl-subgroup {
409+ border-top: 1px dashed var(--pl-border);
410+ margin-top: 6px;
411+ padding-top: 4px;
412+}
413+.pl-linkbtn:disabled {
414+ opacity: 0.4;
415+ cursor: default;
416+}
417+
418+/* 3D scene */
419+.pl-scene {
420+ display: flex;
421+ flex-direction: column;
422+ gap: 6px;
423+}
424+.pl-scene-controls {
425+ display: flex;
426+ gap: 12px;
427+ align-items: center;
428+ flex-wrap: wrap;
429+ font-size: 12px;
430+}
431+.pl-scene-controls label {
432+ display: flex;
433+ gap: 6px;
434+ align-items: center;
435+}
436+.pl-scene-controls input[type="range"] {
437+ width: 110px;
438+}
439+
440+/* Plot interaction */
441+.pl-linkbtn {
442+ font: inherit;
443+ font-size: 11px;
444+ font-weight: 400;
445+ margin-left: 8px;
446+ border: 0;
447+ background: none;
448+ color: var(--pl-accent);
449+ cursor: pointer;
450+ text-decoration: underline;
451+ padding: 0;
452+}
453+.pl-hint {
454+ margin-left: auto;
455+ font-style: italic;
456+}
457+.pl-legend .pl-hint::before {
458+ display: none;
459+}
packages/components/tsconfig.jsonadded+9−0View file
@@ -0,0 +1,9 @@
1+{
2+ "extends": "../../tsconfig.base.json",
3+ "compilerOptions": {
4+ "noEmit": true
5+ },
6+ "include": [
7+ "src"
8+ ]
9+}
packages/core/package.jsonadded+19−0View file
@@ -0,0 +1,19 @@
1+{
2+ "name": "@plate-dyn/core",
3+ "version": "0.1.0",
4+ "private": true,
5+ "type": "module",
6+ "description": "Problem specification, .m source generation, numbl runner, WebGPU time loop, and field primitives for the plate dynamics lab. Framework-agnostic.",
7+ "exports": {
8+ ".": "./src/index.ts",
9+ "./worker": "./src/runner/worker.ts",
10+ "./node": "./src/node.ts"
11+ },
12+ "scripts": {
13+ "typecheck": "tsc -p tsconfig.json --noEmit",
14+ "test": "vitest run"
15+ },
16+ "dependencies": {
17+ "numbl": "0.4.21"
18+ }
19+}
packages/core/src/audio/buffer.tsadded+36−0View file
@@ -0,0 +1,36 @@
1+/**
2+ * Turn a pressure signal into something Web Audio can play. Peak normalization
3+ * is a playback convenience (the absolute level of the Rayleigh estimate is not
4+ * calibrated anyway) and is reported so the interface can say so.
5+ */
6+export interface AudioPrep {
7+ samples: Float32Array;
8+ fs: number;
9+ /** Peak of the original signal [Pa] */
10+ peakPa: number;
11+ /** Gain applied so the peak sits at `target` */
12+ gain: number;
13+ durationS: number;
14+}
15+
16+export function prepareAudio(p: Float64Array, fs: number, target = 0.8): AudioPrep {
17+ let peak = 0;
18+ for (let i = 0; i < p.length; i++) {
19+ const a = Math.abs(p[i]);
20+ if (a > peak) peak = a;
21+ }
22+ const gain = peak > 0 ? target / peak : 1;
23+ const samples = new Float32Array(p.length);
24+ for (let i = 0; i < p.length; i++) samples[i] = Number.isFinite(p[i]) ? p[i] * gain : 0;
25+ return { samples, fs, peakPa: peak, gain, durationS: p.length / fs };
26+}
27+
28+export function toAudioBuffer(ctx: BaseAudioContext, prep: AudioPrep): AudioBuffer {
29+ const n = Math.max(1, prep.samples.length);
30+ const buf = ctx.createBuffer(1, n, prep.fs);
31+ const data = (
32+ prep.samples.length ? prep.samples : new Float32Array(1)
33+ ) as Float32Array<ArrayBuffer>;
34+ buf.copyToChannel(data, 0);
35+ return buf;
36+}
packages/core/src/audio/wav.tsadded+35−0View file
@@ -0,0 +1,35 @@
1+/**
2+ * Encode a mono signal as a 16-bit PCM RIFF/WAVE file. The input is expected
3+ * to be peak-normalized already (see `prepareAudio`); values outside [-1, 1]
4+ * are clipped rather than wrapped. Non-finite samples are written as silence.
5+ */
6+export function encodeWav(samples: ArrayLike<number>, fs: number): Uint8Array {
7+ const n = samples.length;
8+ const bytesPerSample = 2;
9+ const dataBytes = n * bytesPerSample;
10+ const buf = new ArrayBuffer(44 + dataBytes);
11+ const v = new DataView(buf);
12+ const ascii = (off: number, s: string) => {
13+ for (let i = 0; i < s.length; i++) v.setUint8(off + i, s.charCodeAt(i));
14+ };
15+ ascii(0, "RIFF");
16+ v.setUint32(4, 36 + dataBytes, true);
17+ ascii(8, "WAVE");
18+ ascii(12, "fmt ");
19+ v.setUint32(16, 16, true); // fmt chunk size
20+ v.setUint16(20, 1, true); // PCM
21+ v.setUint16(22, 1, true); // mono
22+ v.setUint32(24, fs, true);
23+ v.setUint32(28, fs * bytesPerSample, true); // byte rate
24+ v.setUint16(32, bytesPerSample, true); // block align
25+ v.setUint16(34, 16, true); // bits per sample
26+ ascii(36, "data");
27+ v.setUint32(40, dataBytes, true);
28+ let off = 44;
29+ for (let i = 0; i < n; i++, off += 2) {
30+ const x = samples[i];
31+ const c = Number.isFinite(x) ? Math.max(-1, Math.min(1, x)) : 0;
32+ v.setInt16(off, Math.round(c < 0 ? c * 32768 : c * 32767), true);
33+ }
34+ return new Uint8Array(buf);
35+}
packages/core/src/cost.tsadded+38−0View file
@@ -0,0 +1,38 @@
1+import type { MethodDefinition } from "./methods/types";
2+
3+/**
4+ * Wall-time estimate for grid-based methods. This is a calibration, not
5+ * physics: the plan (cell count, steps) comes from the method's plan function
6+ * running in numbl; only the seconds-per-cell-step constant lives here, seeded
7+ * from the method definition and refined by each finished run. The GPU path of
8+ * a method is calibrated separately: its cost is nearly independent of the
9+ * cell count at the grids used here, so its per-cell-step rate is a stand-in
10+ * that the measurements correct for the grid in use.
11+ */
12+export class CostModel {
13+ private us = new Map<string, number>();
14+ private readonly overheadMs = 150;
15+
16+ private key(method: MethodDefinition, gpu: boolean): string {
17+ return gpu ? `${method.id}:gpu` : method.id;
18+ }
19+
20+ private rate(method: MethodDefinition, gpu: boolean): number {
21+ return (
22+ this.us.get(this.key(method, gpu)) ??
23+ (gpu ? (method.gpu?.usPerCellStep ?? method.usPerCellStep) : method.usPerCellStep)
24+ );
25+ }
26+
27+ estimateMs(method: MethodDefinition, cellSteps: number, gpu = false): number {
28+ return (cellSteps * this.rate(method, gpu)) / 1000 + this.overheadMs;
29+ }
30+
31+ /** Blend a measurement in, weighting recent runs more. */
32+ observe(method: MethodDefinition, cellSteps: number, wallMs: number, gpu = false): void {
33+ if (cellSteps <= 0 || wallMs <= 0) return;
34+ const measured = ((wallMs - this.overheadMs) * 1000) / cellSteps;
35+ if (!Number.isFinite(measured) || measured <= 0) return;
36+ this.us.set(this.key(method, gpu), 0.5 * this.rate(method, gpu) + 0.5 * measured);
37+ }
38+}
packages/core/src/fields/colormap.tsadded+95−0View file
@@ -0,0 +1,95 @@
1+/**
2+ * Colormaps for field display. Display only: these map numbers the solver
3+ * produced to pixels and never change the numbers.
4+ */
5+export type RGB = [number, number, number];
6+
7+export interface Colormap {
8+ id: string;
9+ name: string;
10+ /** Kind of data it suits */
11+ kind: "sequential" | "diverging";
12+ /** t in [0, 1] -> RGB in [0, 255] */
13+ at(t: number): RGB;
14+}
15+
16+// Polynomial fit of matplotlib's viridis (Matt Zucker, public domain).
17+function viridis(t: number): RGB {
18+ const c0 = [0.2777273272234177, 0.005407344544966578, 0.3340998053353061];
19+ const c1 = [0.1050930431085774, 1.404613529898575, 1.384590162594685];
20+ const c2 = [-0.3308618287255563, 0.214847559468213, 0.09509516302823659];
21+ const c3 = [-4.634230498983486, -5.799100973351585, -19.33244095627987];
22+ const c4 = [6.228269936347081, 14.17993336680509, 56.69055260068105];
23+ const c5 = [4.776384997670288, -13.74514537774601, -65.35303263337234];
24+ const c6 = [-5.435455855934631, 4.645852612178535, 26.3124352495832];
25+ const out: RGB = [0, 0, 0];
26+ for (let i = 0; i < 3; i++) {
27+ const v =
28+ c0[i] + t * (c1[i] + t * (c2[i] + t * (c3[i] + t * (c4[i] + t * (c5[i] + t * c6[i])))));
29+ out[i] = Math.round(255 * Math.min(1, Math.max(0, v)));
30+ }
31+ return out;
32+}
33+
34+// Polynomial fit of matplotlib's magma (same source).
35+function magma(t: number): RGB {
36+ const c0 = [-0.002136485053939582, -0.000749655052795221, -0.005386127855323933];
37+ const c1 = [0.2516605407371642, 0.6775232436837668, 2.494026599312351];
38+ const c2 = [8.353717279216625, -3.577719514958484, 0.3144679030132573];
39+ const c3 = [-27.66873308576866, 14.26473078096533, -13.64921318813922];
40+ const c4 = [52.17613981234068, -27.94360607168351, 12.94416944238394];
41+ const c5 = [-50.76852536473588, 29.04658282127291, 4.23415299384598];
42+ const c6 = [18.65570506591883, -11.48977351997711, -5.601961508734096];
43+ const out: RGB = [0, 0, 0];
44+ for (let i = 0; i < 3; i++) {
45+ const v =
46+ c0[i] + t * (c1[i] + t * (c2[i] + t * (c3[i] + t * (c4[i] + t * (c5[i] + t * c6[i])))));
47+ out[i] = Math.round(255 * Math.min(1, Math.max(0, v)));
48+ }
49+ return out;
50+}
51+
52+// Blue-white-red diverging map for signed fields.
53+function blueWhiteRed(t: number): RGB {
54+ const stops: RGB[] = [
55+ [5, 48, 97],
56+ [67, 147, 195],
57+ [209, 229, 240],
58+ [247, 247, 247],
59+ [253, 219, 199],
60+ [214, 96, 77],
61+ [103, 0, 31],
62+ ];
63+ const x = Math.min(1, Math.max(0, t)) * (stops.length - 1);
64+ const i = Math.min(stops.length - 2, Math.floor(x));
65+ const f = x - i;
66+ const a = stops[i];
67+ const b = stops[i + 1];
68+ return [
69+ Math.round(a[0] + f * (b[0] - a[0])),
70+ Math.round(a[1] + f * (b[1] - a[1])),
71+ Math.round(a[2] + f * (b[2] - a[2])),
72+ ];
73+}
74+
75+export const COLORMAPS: Colormap[] = [
76+ { id: "viridis", name: "Viridis", kind: "sequential", at: viridis },
77+ { id: "magma", name: "Magma", kind: "sequential", at: magma },
78+ { id: "bwr", name: "Blue-white-red", kind: "diverging", at: blueWhiteRed },
79+];
80+
81+export function getColormap(id: string): Colormap {
82+ return COLORMAPS.find((c) => c.id === id) ?? COLORMAPS[0];
83+}
84+
85+/** Precompute a 256-entry lookup table for fast pixel fills. */
86+export function colormapTable(cm: Colormap): Uint8ClampedArray {
87+ const table = new Uint8ClampedArray(256 * 3);
88+ for (let k = 0; k < 256; k++) {
89+ const [r, g, b] = cm.at(k / 255);
90+ table[3 * k] = r;
91+ table[3 * k + 1] = g;
92+ table[3 * k + 2] = b;
93+ }
94+ return table;
95+}
packages/core/src/fields/field.tsadded+49−0View file
@@ -0,0 +1,49 @@
1+/**
2+ * Column-major 2-D fields matching numbl's layout: rows index y, columns index x,
3+ * element (i, j) at data[i + j*ny].
4+ */
5+export interface Field2D {
6+ nx: number;
7+ ny: number;
8+ data: Float64Array;
9+}
10+
11+/** A stack of frames: element (i, j, k) at data[i + j*ny + k*ny*nx]. */
12+export interface FieldFrames {
13+ nx: number;
14+ ny: number;
15+ nf: number;
16+ data: Float64Array;
17+}
18+
19+export function frameOf(frames: FieldFrames, k: number): Field2D {
20+ const n = frames.nx * frames.ny;
21+ return { nx: frames.nx, ny: frames.ny, data: frames.data.subarray(k * n, (k + 1) * n) };
22+}
23+
24+export function fieldAt(f: Field2D, i: number, j: number): number {
25+ return f.data[i + j * f.ny];
26+}
27+
28+export interface Range {
29+ min: number;
30+ max: number;
31+}
32+
33+export function rangeOf(data: ArrayLike<number>): Range {
34+ let min = Infinity;
35+ let max = -Infinity;
36+ for (let k = 0; k < data.length; k++) {
37+ const v = data[k];
38+ if (v < min) min = v;
39+ if (v > max) max = v;
40+ }
41+ if (!Number.isFinite(min)) return { min: 0, max: 0 };
42+ return { min, max };
43+}
44+
45+/** Symmetric range about zero, for signed fields drawn with a diverging colormap. */
46+export function symmetricRange(r: Range): Range {
47+ const a = Math.max(Math.abs(r.min), Math.abs(r.max));
48+ return { min: -a, max: a };
49+}
packages/core/src/fields/raster.tsadded+37−0View file
@@ -0,0 +1,37 @@
1+import type { Field2D, Range } from "./field";
2+import { colormapTable, type Colormap } from "./colormap";
3+
4+/**
5+ * Paint a field into RGBA pixels with y increasing upward (row 0 of the image is
6+ * the top of the plate, y = Ly). Non-finite values are drawn transparent.
7+ */
8+export function rasterizeField(
9+ field: Field2D,
10+ range: Range,
11+ cm: Colormap,
12+ out?: Uint8ClampedArray,
13+): Uint8ClampedArray {
14+ const { nx, ny, data } = field;
15+ const table = colormapTable(cm);
16+ const rgba = out && out.length === nx * ny * 4 ? out : new Uint8ClampedArray(nx * ny * 4);
17+ const span = range.max - range.min || 1;
18+ for (let i = 0; i < ny; i++) {
19+ const row = ny - 1 - i;
20+ for (let j = 0; j < nx; j++) {
21+ const v = data[i + j * ny];
22+ const px = (row * nx + j) * 4;
23+ if (!Number.isFinite(v)) {
24+ rgba[px + 3] = 0;
25+ continue;
26+ }
27+ let t = (v - range.min) / span;
28+ t = t < 0 ? 0 : t > 1 ? 1 : t;
29+ const k = Math.round(t * 255) * 3;
30+ rgba[px] = table[k];
31+ rgba[px + 1] = table[k + 1];
32+ rgba[px + 2] = table[k + 2];
33+ rgba[px + 3] = 255;
34+ }
35+ }
36+ return rgba;
37+}
packages/core/src/generate/bundle.tsadded+120−0View file
@@ -0,0 +1,120 @@
1+import type { ProblemSpec } from "../spec/types";
2+import type { MethodDefinition } from "../methods/types";
3+import type { GpuStages } from "../runner/types";
4+import { generateParamsBlock } from "./params";
5+import { LIBRARY_FILES, type SourceFile } from "./library";
6+
7+/** A complete, self-contained set of `.m` files plus the one to run. */
8+export interface SourceBundle {
9+ files: SourceFile[];
10+ /** File executed as the script */
11+ entry: string;
12+ /** Workspace variable read back after the run */
13+ resultVariable: string;
14+}
15+
16+export const MAIN_FILE = "main.m";
17+
18+/**
19+ * The script a run executes: the params block followed by the solver call. With
20+ * `gpu` the call is written out as its three stages, since that is what the
21+ * host then runs (see buildGpuStages).
22+ */
23+export function generateMain(
24+ spec: ProblemSpec,
25+ method: MethodDefinition,
26+ opts: { gpu?: boolean } = {},
27+): string {
28+ const g = opts.gpu ? method.gpu : undefined;
29+ const solve = g
30+ ? [
31+ `% Solve. With the GPU option the host runs ${g.setupFunction} and ${g.outputFunction} here in numbl and`,
32+ `% ${g.loopFile} as its WebGPU port; without WebGPU, or when this script or ${g.loopFile} is edited,`,
33+ `% all three run in numbl. The library files are in the other tabs.`,
34+ `S = ${g.setupFunction}(params);`,
35+ `Rec = ${g.loopFile.replace(/\.m$/, "")}(S); % replaced by the WebGPU port when the loop runs on the GPU`,
36+ `result = ${g.outputFunction}(S, Rec);`,
37+ ]
38+ : [
39+ `% Solve. ${method.solverFunction} and the functions it calls are the library files in the other tabs.`,
40+ `result = ${method.solverFunction}(params);`,
41+ ];
42+ return [generateParamsBlock(spec), "", ...solve, ""].join("\n");
43+}
44+
45+/** Bundle for running the method on a spec. */
46+export function buildRunBundle(
47+ spec: ProblemSpec,
48+ method: MethodDefinition,
49+ opts: { gpu?: boolean } = {},
50+): SourceBundle {
51+ return {
52+ files: [{ name: MAIN_FILE, source: generateMain(spec, method, opts) }, ...LIBRARY_FILES],
53+ entry: MAIN_FILE,
54+ resultVariable: "result",
55+ };
56+}
57+
58+/**
59+ * The two numbl scripts of a GPU run (see runner/executeGpu.ts): the setup,
60+ * whose struct S the host reads (sparse operators as find() triplets, since
61+ * a sparse matrix does not cross to the host), and the output, which takes the
62+ * loop's record as the workspace variable Rec. Null for a method without a port.
63+ */
64+export function buildGpuStages(spec: ProblemSpec, method: MethodDefinition): GpuStages | null {
65+ const g = method.gpu;
66+ if (!g) return null;
67+ const params = generateParamsBlock(spec);
68+ const setup = [
69+ params,
70+ "",
71+ `% Stage 1 of a GPU run: the setup, read by the WebGPU port of ${g.loopFile}.`,
72+ `S = ${g.setupFunction}(params);`,
73+ "% the sparse operators cross to the host as triplets",
74+ "[ki, kj, kv] = find(S.KB); S.KB_i = ki; S.KB_j = kj; S.KB_v = kv;",
75+ "[ki, kj, kv] = find(S.KG); S.KG_i = ki; S.KG_j = kj; S.KG_v = kv;",
76+ "[ki, kj, kv] = find(S.Gs); S.Gs_i = ki; S.Gs_j = kj; S.Gs_v = kv;",
77+ "[ki, kj, kv] = find(S.WB); S.WB_i = ki; S.WB_j = kj; S.WB_v = kv;",
78+ "[ki, kj, kv] = find(S.WG); S.WG_i = ki; S.WG_j = kj; S.WG_v = kv;",
79+ "[ki, kj, kv] = find(S.WL); S.WL_i = ki; S.WL_j = kj; S.WL_v = kv;",
80+ "S = rmfield(S, 'mdl'); S = rmfield(S, 'KB'); S = rmfield(S, 'KG'); S = rmfield(S, 'Lm');",
81+ "S = rmfield(S, 'Gs'); S = rmfield(S, 'WB'); S = rmfield(S, 'WG'); S = rmfield(S, 'WL');",
82+ "",
83+ ].join("\n");
84+ const output = [
85+ params,
86+ "",
87+ `% Stage 3 of a GPU run: Rec is the record of the WebGPU loop, passed in by the host.`,
88+ `S = ${g.setupFunction}(params);`,
89+ `result = ${g.outputFunction}(S, Rec);`,
90+ "",
91+ ].join("\n");
92+ return { setup, output, loopFile: g.loopFile };
93+}
94+
95+/** Bundle that evaluates the cost plan. */
96+export function buildPlanBundle(spec: ProblemSpec, method: MethodDefinition): SourceBundle | null {
97+ if (!method.planFunction) return null;
98+ const main = [generateParamsBlock(spec), "", `plan = ${method.planFunction}(params);`, ""].join(
99+ "\n",
100+ );
101+ return {
102+ files: [{ name: "plan.m", source: main }, ...LIBRARY_FILES],
103+ entry: "plan.m",
104+ resultVariable: "plan",
105+ };
106+}
107+
108+/**
109+ * Replace files in a bundle with edited versions, keeping everything else. Used
110+ * when the user edits source in the code view: the run then uses exactly the
111+ * text on screen.
112+ */
113+export function withEdits(bundle: SourceBundle, edits: Record<string, string>): SourceBundle {
114+ return {
115+ ...bundle,
116+ files: bundle.files.map((f) =>
117+ edits[f.name] !== undefined ? { ...f, source: edits[f.name] } : f,
118+ ),
119+ };
120+}
packages/core/src/generate/format.tsadded+37−0View file
@@ -0,0 +1,37 @@
1+/** Format a number as a MATLAB literal that round-trips exactly and reads well. */
2+export function formatNumber(x: number): string {
3+ if (!Number.isFinite(x)) return x > 0 ? "Inf" : x < 0 ? "-Inf" : "NaN";
4+ if (x === 0) return "0";
5+ const ax = Math.abs(x);
6+ if (Number.isInteger(x) && ax < 1e6) return String(x);
7+ if (ax >= 1e5 || ax < 1e-4) {
8+ // shortest exponential form that round-trips, written like 200e9 rather than 2e+11
9+ for (let p = 0; p <= 16; p++) {
10+ const s = x.toExponential(p);
11+ if (Number(s) === x) return tidyExponential(s);
12+ }
13+ return tidyExponential(x.toExponential());
14+ }
15+ return String(x);
16+}
17+
18+function tidyExponential(s: string): string {
19+ const [mant, expo] = s.split("e");
20+ const e = Number(expo);
21+ // Prefer engineering-style exponents (multiples of 3) when the mantissa stays short
22+ const shift = ((e % 3) + 3) % 3;
23+ if (shift !== 0) {
24+ const mNum = Number(mant) * Math.pow(10, shift);
25+ const mStr = String(Number(mNum.toPrecision(15)));
26+ if (mStr.length <= 8) return `${mStr}e${e - shift}`;
27+ }
28+ return `${mant}e${e}`;
29+}
30+
31+export function formatString(s: string): string {
32+ return `'${s.replace(/'/g, "''")}'`;
33+}
34+
35+export function formatVector(v: readonly number[]): string {
36+ return `[${v.map(formatNumber).join(" ")}]`;
37+}
packages/core/src/generate/library.tsadded+147−0View file
@@ -0,0 +1,147 @@
1+import plate_fd from "../m/plate_fd.m?raw";
2+import plate_fd_plan from "../m/plate_fd_plan.m?raw";
3+import plate_fd_setup from "../m/plate_fd_setup.m?raw";
4+import plate_fd_loop from "../m/plate_fd_loop.m?raw";
5+import plate_fd_output from "../m/plate_fd_output.m?raw";
6+import plate_model from "../m/plate_model.m?raw";
7+import plate_setup from "../m/plate_setup.m?raw";
8+import plate_domain from "../m/plate_domain.m?raw";
9+import plate_mount from "../m/plate_mount.m?raw";
10+import plate_bending from "../m/plate_bending.m?raw";
11+import plate_prestress from "../m/plate_prestress.m?raw";
12+import plate_static from "../m/plate_static.m?raw";
13+import plate_rigid_basis from "../m/plate_rigid_basis.m?raw";
14+import plate_force_time from "../m/plate_force_time.m?raw";
15+import plate_force_footprint from "../m/plate_force_footprint.m?raw";
16+import plate_stress from "../m/plate_stress.m?raw";
17+import plate_listener from "../m/plate_listener.m?raw";
18+import plate_spectrum from "../m/plate_spectrum.m?raw";
19+import plate_curvature_free from "../m/plate_curvature_free.m?raw";
20+import plate_curvature_domain from "../m/plate_curvature_domain.m?raw";
21+import plate_result_schema from "../m/plate_result_schema.m?raw";
22+
23+export interface SourceFile {
24+ name: string;
25+ source: string;
26+}
27+
28+export interface LibraryFile extends SourceFile {
29+ /** One line for the code view's file list */
30+ summary: string;
31+}
32+
33+/** Every `.m` file the solver can call, in reading order. The runner ships all of them with each run. */
34+export const LIBRARY_FILES: LibraryFile[] = [
35+ {
36+ name: "plate_fd.m",
37+ source: plate_fd,
38+ summary: "Explicit finite differences: setup, time loop and output in turn",
39+ },
40+ {
41+ name: "plate_fd_setup.m",
42+ source: plate_fd_setup,
43+ summary: "Load, probe, listener, initial state and constants for the time loop",
44+ },
45+ {
46+ name: "plate_fd_loop.m",
47+ source: plate_fd_loop,
48+ summary:
49+ "Explicit time stepping of rigid coordinates plus flexural field; the lab has a WebGPU port of this file",
50+ },
51+ {
52+ name: "plate_fd_output.m",
53+ source: plate_fd_output,
54+ summary: "Stresses, audio, spectra and the result struct from the loop's record",
55+ },
56+ {
57+ name: "plate_fd_plan.m",
58+ source: plate_fd_plan,
59+ summary: "Time step, cost, rigid-body frequencies and statics before a run",
60+ },
61+ {
62+ name: "plate_model.m",
63+ source: plate_model,
64+ summary: "Operators, statics, rigid-body system and the stable time step",
65+ },
66+ {
67+ name: "plate_setup.m",
68+ source: plate_setup,
69+ summary: "Bending stiffness and other derived constants",
70+ },
71+ {
72+ name: "plate_domain.m",
73+ source: plate_domain,
74+ summary:
75+ "The plate's footprint on the grid: every node for a rectangle, a staircase for an ellipse",
76+ },
77+ {
78+ name: "plate_mount.m",
79+ source: plate_mount,
80+ summary:
81+ "Threads, pads, felts, vise or fingers as gravity direction, springs, contacts and constraints",
82+ },
83+ {
84+ name: "plate_bending.m",
85+ source: plate_bending,
86+ summary:
87+ "Bending and Laplacian operators from the discrete energy; free edges natural, held nodes clamped",
88+ },
89+ {
90+ name: "plate_prestress.m",
91+ source: plate_prestress,
92+ summary: "Plane-stress solve for the self-weight tension and the membrane operator it adds",
93+ },
94+ {
95+ name: "plate_static.m",
96+ source: plate_static,
97+ summary: "Static equilibrium under gravity with one-sided contacts",
98+ },
99+ {
100+ name: "plate_rigid_basis.m",
101+ source: plate_rigid_basis,
102+ summary: "Rigid-body coordinates about the mount's pivot and their mass-orthogonal projector",
103+ },
104+ {
105+ name: "plate_force_time.m",
106+ source: plate_force_time,
107+ summary: "Force history F(t): hammer pulse, or nothing for a release",
108+ },
109+ {
110+ name: "plate_force_footprint.m",
111+ source: plate_force_footprint,
112+ summary: "Raised-cosine spatial footprint of the load, restricted to the plate's nodes",
113+ },
114+ {
115+ name: "plate_stress.m",
116+ source: plate_stress,
117+ summary: "Surface stresses, von Mises and principal stress from curvatures",
118+ },
119+ {
120+ name: "plate_listener.m",
121+ source: plate_listener,
122+ summary:
123+ "Weights and delays to the listener: Rayleigh integral, baffled or the unbaffled estimate",
124+ },
125+ { name: "plate_spectrum.m", source: plate_spectrum, summary: "Windowed amplitude spectrum" },
126+ {
127+ name: "plate_curvature_free.m",
128+ source: plate_curvature_free,
129+ summary: "Curvatures of a rectangle, edges from the free-edge moment condition",
130+ },
131+ {
132+ name: "plate_curvature_domain.m",
133+ source: plate_curvature_domain,
134+ summary: "Curvatures on an ellipse: central differences inside, copied along the staircase",
135+ },
136+ {
137+ name: "plate_result_schema.m",
138+ source: plate_result_schema,
139+ summary: "Documentation of the result struct",
140+ },
141+];
142+
143+export function libraryFile(name: string): LibraryFile {
144+ const f = LIBRARY_FILES.find((x) => x.name === name);
145+ if (!f) throw new Error(`unknown library file ${name}`);
146+ return f;
147+}
packages/core/src/generate/params.tsadded+51−0View file
@@ -0,0 +1,51 @@
1+import type { Mount, ProblemSpec } from "../spec/types";
2+import { formatNumber as n, formatString as s } from "./format";
3+
4+function mountLine(m: Mount): string {
5+ switch (m.type) {
6+ case "thread":
7+ return `struct('type', 'thread', 'x', ${n(m.x)}, 'y', ${n(m.y)}, 'length', ${n(m.length)}, 'thread', struct('name', ${s(m.thread.name)}, 'diameter', ${n(m.thread.diameter)}, 'G', ${n(m.thread.G)})); % one thread: hole [m], length [m], thread diameter [m] and shear modulus [Pa] for its torsion`;
8+ case "threads":
9+ return `struct('type', 'threads', 'x1', ${n(m.x1)}, 'y1', ${n(m.y1)}, 'x2', ${n(m.x2)}, 'y2', ${n(m.y2)}, 'length', ${n(m.length)}); % two threads of one length from hooks at one height: the two holes [m]`;
10+ case "pads": {
11+ const pads = m.pads
12+ .map((p) => `struct('x', ${n(p.x)}, 'y', ${n(p.y)}, 'rx', ${n(p.rx)}, 'ry', ${n(p.ry)})`)
13+ .join(", ");
14+ return `struct('type', 'pads', 'pads', [${pads}], 'k', ${n(m.k)}, 'c', ${n(m.c)}); % pads under a horizontal plate: centre and half-widths [m]; stiffness [N/m^3] and damping [N s/m^3] per area, one-sided`;
15+ }
16+ case "cymbal":
17+ return `struct('type', 'cymbal', 'r_rod', ${n(m.r_rod)}, 'r_felt', ${n(m.r_felt)}, 'k', ${n(m.k)}, 'c', ${n(m.c)}, 'preload', ${n(m.preload)}); % stand: rod radius, felt radius [m], felt stiffness [N/m^3] and damping [N s/m^3], preload compression [m]`;
18+ case "vise":
19+ return `struct('type', 'vise', 'edge', ${s(m.edge)}, 'depth', ${n(m.depth)}, 'tilt', ${n(m.tilt)}); % one edge in a vise: which edge, jaw depth [m], tilt [deg] (0 hangs down, 90 horizontal, 180 stands up)`;
20+ case "pinched":
21+ return `struct('type', 'pinched', 'x', ${n(m.x)}, 'y', ${n(m.y)}, 'radius', ${n(m.radius)}, 'k', ${n(m.k)}, 'c', ${n(m.c)}, 'tilt', ${n(m.tilt)}); % between finger and thumb: patch centre and radius [m], grip stiffness [N/m] and damping [N s/m], tilt [deg]`;
22+ }
23+}
24+
25+/**
26+ * Render the specification as the MATLAB `params` struct. The controls edit the
27+ * spec; this block is what the solver actually reads, so the two cannot drift.
28+ */
29+export function generateParamsBlock(spec: ProblemSpec): string {
30+ const e = spec.excitation;
31+ const excitation =
32+ e.type === "impulse"
33+ ? `struct('type', 'impulse', 'x', ${n(e.x)}, 'y', ${n(e.y)}, 'force', ${n(e.force)}, 'duration', ${n(e.duration)}, 'radius', ${n(e.radius)}); % half-sine hammer pulse: strike point [m], peak force [N], contact time [s], footprint radius [m]`
34+ : `struct('type', 'release', 'x', ${n(e.x)}, 'y', ${n(e.y)}, 'displacement', ${n(e.displacement)}, 'radius', ${n(e.radius)}); % pull aside and let go: pull point [m], displacement there at release [m], footprint radius [m]`;
35+ const lines = [
36+ "% Problem specification, generated from the controls. SI units: m, s, N, Pa, kg/m^3.",
37+ "% Edit anything here or in the library files; the controls regenerate only this block.",
38+ "params = struct();",
39+ `params.plate = struct('shape', ${s(spec.plate.shape)}, 'Lx', ${n(spec.plate.Lx)}, 'Ly', ${n(spec.plate.Ly)}, 'h', ${n(spec.plate.h)}); % 'rectangle' or 'ellipse' in the box Lx x Ly, thickness h [m]`,
40+ `params.material = struct('name', ${s(spec.material.name)}, 'E', ${n(spec.material.E)}, 'nu', ${n(spec.material.nu)}, 'rho', ${n(spec.material.rho)}); % Young's modulus [Pa], Poisson's ratio, density [kg/m^3]`,
41+ `params.gravity = struct('g', ${n(spec.gravity.g)}); % [m/s^2]; 0 switches gravity off`,
42+ `params.mount = ${mountLine(spec.mount)}`,
43+ `params.damping = struct('sigma0', ${n(spec.damping.sigma0)}, 'sigma1', ${n(spec.damping.sigma1)}); % loss rates: frequency-independent [1/s], frequency-dependent [m^2/s]`,
44+ `params.excitation = ${excitation}`,
45+ `params.probe = struct('x', ${n(spec.probe.x)}, 'y', ${n(spec.probe.y)}); % where the time series are recorded [m]`,
46+ `params.listener = struct('x', ${n(spec.listener.x)}, 'y', ${n(spec.listener.y)}, 'z', ${n(spec.listener.z)}, 'c', ${n(spec.listener.c)}, 'rho_air', ${n(spec.listener.rho_air)}, 'model', ${s(spec.listener.model)}); % microphone in plate coordinates [m], sound speed [m/s], air density [kg/m^3], 'baffled' or 'unbaffled'`,
47+ `params.output = struct('fs', ${n(spec.output.fs)}, 'nframes', ${n(spec.output.nframes)}); % sample rate of the series and the audio [Hz], animation frames`,
48+ `params.fd = struct('nx', ${n(spec.fd.nx)}, 'ny', ${n(spec.fd.ny)}, 'cfl', ${n(spec.fd.cfl)}, 'duration', ${n(spec.fd.duration)}); % grid nodes, fraction of the stable time step (> 1 blows up), simulated time [s]`,
49+ ];
50+ return lines.join("\n");
51+}
packages/core/src/gpu/fdLoop.tsadded+983−0View file
@@ -0,0 +1,983 @@
1+/**
2+ * WebGPU port of plate_fd_loop.m: the explicit time stepping of the plate,
3+ * run as compute shaders. Everything around the loop stays in numbl: the host
4+ * runs the unmodified plate_fd_setup.m, hands its struct in here as FdLoopSetup,
5+ * and passes the record this returns to the unmodified plate_fd_output.m, which
6+ * computes the stresses, the audio and the spectra in double precision.
7+ *
8+ * One time step is five compute dispatches, the dispatch boundaries being the
9+ * global synchronization points the stencils need:
10+ * strain e = G phi: every difference stencil of the energy method (second
11+ * differences at the nodes, twists at the cells, first differences
12+ * along the edges) as an exact integer stencil applied to phi
13+ * force at every node, G' applied to the weighted strains: the bending
14+ * force, the prestress and spring force, and the Laplacian, each
15+ * from its own weights. This is the assembled operator applied in
16+ * its factored form. In single precision the assembled 13-point
17+ * stencil loses the smooth modes to rounding (the biharmonic of a
18+ * smooth field is a small residual of large terms, and the rounding
19+ * of the field alone is percent of it at low wavenumbers); the
20+ * factored form keeps them to about 1e-5, because a second
21+ * difference of nearby values is nearly exact in f32 and the
22+ * scales live in the weights, where rounding is harmless
23+ * dots one workgroup: the nodal forces f (strike, contacts, dashpots) from
24+ * the total field w = R c + phi and its velocity, the reductions
25+ * R' f, (AR)' phi and the support sums, the rigid-body update
26+ * c -> cn (thread 0), and the series of this step (probe, rigid
27+ * coordinates, support forces) averaged into their samples
28+ * update phin = c_new (2 phi - c_old phim + dt^2 (f - B - AR c)/M - dt^2 R cacc
29+ * + 2 sigma1 dt (Lphi - Lphi_prev)), zeroed at nodes that are not
30+ * unknowns; frames recorded
31+ * reduce the acceleration of the total field summed per listener delay
32+ * bin into the audio samples; max |phin| plus the rigid extent for
33+ * the blow-up test
34+ *
35+ * Steps are recorded in chunks of a few thousand into one command buffer each,
36+ * with the step index a chunk base (ctl[0], written by the host once per chunk)
37+ * plus a within-chunk offset from a dynamic-offset uniform; every recorded
38+ * dispatch is identical. Two chunks are in flight at a time and the blow-up
39+ * flag is read back after each, so a diverging run stops a chunk late rather
40+ * than at the end.
41+ *
42+ * Precision. The GPU works in single precision. The flexural field is carried
43+ * as (phi, d = phi - phim) and the rigid coordinates as (c, dc): the update is
44+ * algebraically the .m's,
45+ * dn = (d + rest) - cs (2 d + rest), phin = phi + dn, cs = sigma0 dt / (1 + sigma0 dt),
46+ * with rest = dt^2 acc (the sigma1 loss being part of the nodal force), so that no factor of
47+ * the form 1 - epsilon is stored in f32 and the rounding of the field does not
48+ * feed back into the velocity. The split into rigid coordinates and flexural
49+ * remainder is the .m's own (plate_rigid_basis); bending is never applied to
50+ * the rigid part, whose forces come from the precomputed AR and Krr.
51+ */
52+
53+export interface Triplets {
54+ /** 1-based row and column indices, as MATLAB's find returns them */
55+ i: Float64Array;
56+ j: Float64Array;
57+ v: Float64Array;
58+}
59+
60+/** The fields of plate_fd_setup's struct that the loop reads; indices are 1-based as in the .m. */
61+export interface FdLoopSetup {
62+ nx: number;
63+ ny: number;
64+ nsteps: number;
65+ dx: number;
66+ dy: number;
67+ dt: number;
68+ fs: number;
69+ /** Number of output samples */
70+ na: number;
71+ keep: Float64Array;
72+ Mvec: Float64Array;
73+ /**
74+ * The operators in factored form (plate_fd_setup): Gs stacks the difference
75+ * stencils (ng rows over the N nodes), Gscale the scale of each row, and the
76+ * symmetric weights give KB = Gs' WB Gs, KG - diag(ks) = Gs' WG Gs and
77+ * Lm = diag(inva) Gs' WL Gs on the unknowns.
78+ */
79+ Gs: Triplets;
80+ Gscale: Float64Array;
81+ ng: number;
82+ WB: Triplets;
83+ WG: Triplets;
84+ WL: Triplets;
85+ inva: Float64Array;
86+ /** Linear springs, the diagonal part of KG */
87+ ks: Float64Array;
88+ nr: number;
89+ /** N x nr column-major */
90+ R: Float64Array;
91+ AR: Float64Array;
92+ /** nr x nr column-major */
93+ Krr: Float64Array;
94+ Ginv: Float64Array;
95+ kb: Float64Array;
96+ cb: Float64Array;
97+ gb: Float64Array;
98+ kt: Float64Array;
99+ ct: Float64Array;
100+ gt: Float64Array;
101+ cs: Float64Array;
102+ /** Static force at rest of the springs, the bottom contacts and the top contacts */
103+ fstatS: Float64Array;
104+ fstatB: Float64Array;
105+ fstatT: Float64Array;
106+ /** Supports as a CSR list of 1-based node indices, each reporting one kind of force (0 spring, 1 bottom, 2 top) */
107+ supPtr: Float64Array;
108+ supNodes: Float64Array;
109+ supKind: Float64Array;
110+ nsup: number;
111+ /** Force per node per unit F */
112+ sa: Float64Array;
113+ /** Force at the nsteps + 1 time levels */
114+ F: Float64Array;
115+ ip: number;
116+ jp: number;
117+ frameSteps: Float64Array;
118+ /** Listener entries: node (1-based), delay bin (1-based), weight */
119+ lisNode: Float64Array;
120+ lisBin: Float64Array;
121+ lisWgt: Float64Array;
122+ nbins: number;
123+ c0: Float64Array;
124+ phi0: Float64Array;
125+ sigma0: number;
126+ /** The sigma1 loss as a nodal force per unit Lm(w - wm): 2 sigma1 M / dt, per node */
127+ s1f: Float64Array;
128+ /** Lm R, N x nr column-major: the Laplacian per node of the rigid basis */
129+ LR: Float64Array;
130+ dt2: number;
131+ /** Weight of a step in its output sample (1 over the steps the sample contains), na entries */
132+ wsamp: Float64Array;
133+ blowup: number;
134+}
135+
136+/** What plate_fd_loop.m returns, in single precision, before conversion for plate_fd_output.m. */
137+export interface FdLoopRecord {
138+ w_p: Float32Array;
139+ wxx_p: Float32Array;
140+ wyy_p: Float32Array;
141+ wxy_p: Float32Array;
142+ /** nr x na, row k at C[k * na ..] */
143+ C: Float32Array;
144+ /** nsup x na */
145+ Fsup: Float32Array;
146+ /** ny x nx x nf */
147+ Wf: Float32Array;
148+ /** nr x nf, row k at Cf[k * nf ..] */
149+ Cf: Float32Array;
150+ /** nbins x na */
151+ Pbins: Float32Array;
152+ divergedStep: number;
153+ gpuMs: number;
154+}
155+
156+export interface GpuHandle {
157+ device: GPUDevice;
158+ adapter: string;
159+}
160+
161+export function describeAdapter(adapter: GPUAdapter): string {
162+ const info = (adapter as { info?: Partial<GPUAdapterInfo> }).info;
163+ if (!info) return "unknown adapter";
164+ const name = [info.vendor, info.architecture].filter(Boolean).join(" ");
165+ return name || info.description || "unknown adapter";
166+}
167+
168+/** The device request the browser worker and the Node test both make. */
169+export async function requestGpuDevice(): Promise<GpuHandle> {
170+ const gpu = (globalThis as { navigator?: { gpu?: GPU } }).navigator?.gpu;
171+ if (!gpu) throw new Error("WebGPU is not available");
172+ const adapter = await gpu.requestAdapter();
173+ if (!adapter) throw new Error("no WebGPU adapter");
174+ const device = await adapter.requestDevice({
175+ requiredLimits: {
176+ maxStorageBufferBindingSize: adapter.limits.maxStorageBufferBindingSize,
177+ maxBufferSize: adapter.limits.maxBufferSize,
178+ },
179+ });
180+ return { device, adapter: describeAdapter(adapter) };
181+}
182+
183+const WG = 64;
184+const RWG = 256;
185+const CHUNK = 2048;
186+const NONE = 0xffffffff;
187+
188+const fmt = (x: number): string => {
189+ if (!Number.isFinite(x)) throw new Error("non-finite kernel constant");
190+ let s = x.toPrecision(9);
191+ if (!/[.e]/.test(s)) s += ".0";
192+ return s + "f";
193+};
194+
195+interface Csr {
196+ ptr: Uint32Array;
197+ col: Uint32Array;
198+ val: Float32Array;
199+}
200+
201+function toCsr(t: Triplets, n: number): Csr {
202+ const count = new Uint32Array(n);
203+ for (let p = 0; p < t.i.length; p++) count[t.i[p] - 1]++;
204+ const ptr = new Uint32Array(n + 1);
205+ for (let r = 0; r < n; r++) ptr[r + 1] = ptr[r] + count[r];
206+ const fill = ptr.slice(0, n);
207+ const col = new Uint32Array(t.i.length);
208+ const val = new Float32Array(t.i.length);
209+ for (let p = 0; p < t.i.length; p++) {
210+ const q = fill[t.i[p] - 1]++;
211+ col[q] = t.j[p] - 1;
212+ val[q] = t.v[p];
213+ }
214+ return { ptr, col, val };
215+}
216+
217+/** Sparse product in double. */
218+function spmv(t: Triplets, x: Float64Array, n: number): Float64Array {
219+ const y = new Float64Array(n);
220+ for (let p = 0; p < t.i.length; p++) y[t.i[p] - 1] += t.v[p] * x[t.j[p] - 1];
221+ return y;
222+}
223+
224+/** Transposed sparse product in double. */
225+function spmvT(t: Triplets, x: Float64Array, n: number): Float64Array {
226+ const y = new Float64Array(n);
227+ for (let p = 0; p < t.i.length; p++) y[t.j[p] - 1] += t.v[p] * x[t.i[p] - 1];
228+ return y;
229+}
230+
231+/** The weight matrices as one CSR pattern with three value arrays. */
232+function weightsCsr(
233+ ng: number,
234+ scale: Float64Array,
235+ WB: Triplets,
236+ WG: Triplets,
237+ WL: Triplets,
238+): { ptr: Uint32Array; col: Uint32Array; vb: Float32Array; vg: Float32Array; vl: Float32Array } {
239+ const rows: Map<number, { b: number; g: number; l: number }>[] = Array.from(
240+ { length: ng },
241+ () => new Map(),
242+ );
243+ const add = (t: Triplets, key: "b" | "g" | "l") => {
244+ for (let p = 0; p < t.i.length; p++) {
245+ const r = t.i[p] - 1;
246+ const c = t.j[p] - 1;
247+ let e = rows[r].get(c);
248+ if (!e) {
249+ e = { b: 0, g: 0, l: 0 };
250+ rows[r].set(c, e);
251+ }
252+ // the stencils are integers: their scales move into the weights
253+ e[key] += t.v[p] * scale[r] * scale[c];
254+ }
255+ };
256+ add(WB, "b");
257+ add(WG, "g");
258+ add(WL, "l");
259+ const ptr = new Uint32Array(ng + 1);
260+ for (let r = 0; r < ng; r++) ptr[r + 1] = ptr[r] + rows[r].size;
261+ const nnz = ptr[ng];
262+ const col = new Uint32Array(nnz);
263+ const vb = new Float32Array(nnz);
264+ const vg = new Float32Array(nnz);
265+ const vl = new Float32Array(nnz);
266+ let q = 0;
267+ for (let r = 0; r < ng; r++)
268+ for (const [c, e] of rows[r]) {
269+ col[q] = c;
270+ vb[q] = e.b;
271+ vg[q] = e.g;
272+ vl[q] = e.l;
273+ q++;
274+ }
275+ return { ptr, col, vb, vg, vl };
276+}
277+
278+/** Listener entries grouped by delay bin: entry -> node, entry -> weight, in bin order. */
279+function binLists(
280+ bins: Float64Array,
281+ nodes: Float64Array,
282+ wgt: Float64Array,
283+ nbins: number,
284+): { ptr: Uint32Array; node: Uint32Array; wgt: Float32Array } {
285+ const n = bins.length;
286+ const count = new Uint32Array(nbins);
287+ for (let e = 0; e < n; e++) count[bins[e] - 1]++;
288+ const ptr = new Uint32Array(nbins + 1);
289+ for (let b = 0; b < nbins; b++) ptr[b + 1] = ptr[b] + count[b];
290+ const fill = ptr.slice(0, nbins);
291+ const node = new Uint32Array(n);
292+ const w = new Float32Array(n);
293+ for (let e = 0; e < n; e++) {
294+ const q = fill[bins[e] - 1]++;
295+ node[q] = nodes[e] - 1;
296+ w[q] = wgt[e];
297+ }
298+ return { ptr, node, wgt: w };
299+}
300+
301+function pack<T extends Uint32Array | Float32Array>(
302+ parts: [string, ArrayLike<number>][],
303+ make: (n: number) => T,
304+): { data: T; off: Record<string, number> } {
305+ const off: Record<string, number> = {};
306+ let cursor = 0;
307+ for (const [name, arr] of parts) {
308+ off[name] = cursor;
309+ cursor += arr.length;
310+ }
311+ const data = make(Math.max(1, cursor));
312+ for (const [name, arr] of parts) data.set(arr, off[name]);
313+ return { data, off };
314+}
315+
316+function buildShaders(su: FdLoopSetup) {
317+ const nx = su.nx | 0;
318+ const ny = su.ny | 0;
319+ const N = nx * ny;
320+ const nsteps = su.nsteps | 0;
321+ const nbins = su.nbins | 0;
322+ const na = su.na | 0;
323+ const nf = su.frameSteps.length;
324+ const nr = su.nr | 0;
325+ const nsup = su.nsup | 0;
326+ if (nr !== 0 && nr !== 3) throw new Error("the loop expects 0 or 3 rigid-body coordinates");
327+ if (su.sa.length !== N || su.keep.length !== N || su.Mvec.length !== N)
328+ throw new Error("setup arrays do not match the grid");
329+ if (su.F.length < nsteps) throw new Error("force history shorter than the step count");
330+ if (su.wsamp.length !== na) throw new Error("sample weights do not match the sample count");
331+ if (nr > 0 && (su.R.length !== N * nr || su.AR.length !== N * nr || su.LR.length !== N * nr))
332+ throw new Error("rigid-body arrays do not match the grid");
333+ if (su.s1f.length !== N) throw new Error("loss array does not match the grid");
334+ if (nsup > 8) throw new Error("at most 8 supports");
335+
336+ // ---- per-step tables, decided in f64 exactly as the .m decides them
337+ const frameOf = new Uint32Array(nsteps).fill(NONE);
338+ for (let kf = 0; kf < nf; kf++) {
339+ const step = su.frameSteps[kf];
340+ if (step >= 1 && step <= nsteps) frameOf[step - 1] = kf;
341+ }
342+ const iaOf = new Uint32Array(nsteps);
343+ for (let k = 0; k < nsteps; k++) {
344+ const ia = Math.floor(k * su.dt * su.fs); // ia = floor((nstep - 1) dt fs) + 1, made 0-based
345+ if (ia >= na) throw new Error("output sample index out of range");
346+ iaOf[k] = ia;
347+ }
348+ const bl = binLists(su.lisBin, su.lisNode, su.lisWgt, nbins);
349+ const ng = su.ng | 0;
350+ if (su.Gscale.length !== ng) throw new Error("strain scales do not match the strain count");
351+ // the stencils as exact integers (their scale is folded into the weights), and the transpose
352+ const Gi: Triplets = {
353+ i: su.Gs.i,
354+ j: su.Gs.j,
355+ v: Float64Array.from(su.Gs.v, (v, p) => Math.round(v / su.Gscale[su.Gs.i[p] - 1])),
356+ };
357+ for (let p = 0; p < Gi.v.length; p++)
358+ if (Math.abs(Gi.v[p] - su.Gs.v[p] / su.Gscale[su.Gs.i[p] - 1]) > 1e-6)
359+ throw new Error("a stencil coefficient is not an integer");
360+ const G = toCsr(Gi, ng);
361+ const GT = toCsr({ i: Gi.j, j: Gi.i, v: Gi.v }, N);
362+ const W = weightsCsr(ng, su.Gscale, su.WB, su.WG, su.WL);
363+ const minv = Float32Array.from(su.Mvec, (m) => 1 / m);
364+ const f32 = (a: ArrayLike<number>) => Float32Array.from(a);
365+ const rmax = new Float32Array(3);
366+ for (let k = 0; k < nr; k++) {
367+ let m = 0;
368+ for (let i = 0; i < N; i++) m = Math.max(m, Math.abs(su.R[i + k * N]));
369+ rmax[k] = m;
370+ }
371+ const supPtr = Uint32Array.from(su.supPtr, (p) => p - 1);
372+ const supNodes = Uint32Array.from(su.supNodes, (p) => p - 1);
373+ const supKind = Uint32Array.from(su.supKind);
374+
375+ const opsParts: [string, ArrayLike<number>][] = [
376+ ["SA", f32(su.sa)],
377+ ["F", f32(su.F.subarray(0, nsteps))],
378+ ["MINV", minv],
379+ ["KEEP", f32(su.keep)],
380+ ["INVA", f32(su.inva)],
381+ ["KSD", f32(su.ks)],
382+ ["GV", G.val],
383+ ["GTV", GT.val],
384+ ["WBV", W.vb],
385+ ["WGV", W.vg],
386+ ["WLV", W.vl],
387+ ["R", f32(nr ? su.R : [0])],
388+ ["AR", f32(nr ? su.AR : [0])],
389+ ["LR", f32(nr ? su.LR : [0])],
390+ ["S1F", f32(su.s1f)],
391+ ["KRR", f32(nr ? su.Krr : [0])],
392+ ["GINV", f32(nr ? su.Ginv : [0])],
393+ ["KB", f32(su.kb)],
394+ ["CB", f32(su.cb)],
395+ ["GB", f32(su.gb)],
396+ ["KT", f32(su.kt)],
397+ ["CT", f32(su.ct)],
398+ ["GT", f32(su.gt)],
399+ ["CS", f32(su.cs)],
400+ ["KS", f32(su.ks)],
401+ ["FSTATS", f32(su.fstatS)],
402+ ["FSTATB", f32(su.fstatB)],
403+ ["FSTATT", f32(su.fstatT)],
404+ ["WGT", bl.wgt],
405+ ["WSAMP", f32(su.wsamp)],
406+ ];
407+ const idxParts: [string, ArrayLike<number>][] = [
408+ ["GPTR", G.ptr],
409+ ["GCOL", G.col],
410+ ["GTPTR", GT.ptr],
411+ ["GTCOL", GT.col],
412+ ["WPTR", W.ptr],
413+ ["WCOL", W.col],
414+ ["BINPTR", bl.ptr],
415+ ["BINNODE", bl.node],
416+ ["SUPPTR", supPtr],
417+ ["SUPNODES", supNodes],
418+ ["SUPKIND", supKind],
419+ ["FRAME", frameOf],
420+ ["IA", iaOf],
421+ ];
422+ const ops = pack(opsParts, (n) => new Float32Array(n));
423+ const idx = pack(idxParts, (n) => new Uint32Array(n));
424+
425+ // ---- state buffer: two slots each of phi, d = phi - phim and Lphi; B; f; fsup; rigid scalars
426+ const ST_PHI = 0;
427+ const ST_D = 2 * N;
428+ const ST_L = 4 * N;
429+ const ST_B = 6 * N;
430+ const ST_F = 7 * N;
431+ const ST_FS = 8 * N; // three arrays: spring, bottom and top support forces
432+ const ST_AUX = 11 * N; // c[2][3], dc[2][3], cacc[3], reserve
433+ const ST_E = ST_AUX + 32; // the strains, ng of them
434+ const stLen = ST_E + ng;
435+ const AUX_C = 0; // c slot s at AUX_C + 3 s
436+ const AUX_DC = 6;
437+ const AUX_CACC = 12;
438+
439+ // ---- output buffer
440+ const OUT_WP = 0;
441+ const OUT_WXX = na;
442+ const OUT_WYY = 2 * na;
443+ const OUT_WXY = 3 * na;
444+ const OUT_C = 4 * na;
445+ const OUT_FS = OUT_C + nr * na;
446+ const OUT_PB = OUT_FS + nsup * na;
447+ const OUT_CF = OUT_PB + nbins * na;
448+ const outLen = OUT_CF + nr * nf;
449+
450+ const s0dt = su.sigma0 * su.dt;
451+ const c: Record<string, number> = {
452+ INV_DX2: 1 / (su.dx * su.dx),
453+ INV_DY2: 1 / (su.dy * su.dy),
454+ INV_DXDY4: 1 / (4 * su.dx * su.dy),
455+ INV_DT: 1 / su.dt,
456+ DT2: su.dt2,
457+ CSIG: s0dt / (1 + s0dt),
458+ INV_DT2: 1 / su.dt2,
459+ BLOWUP: su.blowup,
460+ RMAX0: rmax[0],
461+ RMAX1: rmax[1],
462+ RMAX2: rmax[2],
463+ };
464+ const ip = su.ip - 1;
465+ const jp = su.jp - 1;
466+ if (ip < 1 || ip > ny - 2 || jp < 1 || jp > nx - 2)
467+ throw new Error("probe is not an interior node");
468+
469+ const header = `
470+const NX = ${nx}u; const NY = ${ny}u; const N = ${N}u;
471+const NSTEPS = ${nsteps}u; const NA = ${na}u;
472+const NBINS = ${nbins}u; const NR = ${nr}u; const NSUP = ${nsup}u; const NF = ${nf}u; const NG = ${ng}u;
473+const IPROBE = ${ip + jp * ny}u;
474+${Object.entries(c)
475+ .map(([k, v]) => `const ${k}: f32 = ${fmt(v)};`)
476+ .join("\n")}
477+${Object.entries(ops.off)
478+ .map(([k, v]) => `const OP_${k} = ${v}u;`)
479+ .join("\n")}
480+${Object.entries(idx.off)
481+ .map(([k, v]) => `const IX_${k} = ${v}u;`)
482+ .join("\n")}
483+const ST_PHI = ${ST_PHI}u; const ST_D = ${ST_D}u; const ST_L = ${ST_L}u; const ST_B = ${ST_B}u;
484+const ST_F = ${ST_F}u; const ST_FS = ${ST_FS}u; const ST_AUX = ${ST_AUX}u; const ST_E = ${ST_E}u;
485+const AUX_C = ${AUX_C}u; const AUX_DC = ${AUX_DC}u; const AUX_CACC = ${AUX_CACC}u;
486+const OUT_WP = ${OUT_WP}u; const OUT_WXX = ${OUT_WXX}u; const OUT_WYY = ${OUT_WYY}u; const OUT_WXY = ${OUT_WXY}u;
487+const OUT_C = ${OUT_C}u; const OUT_FS = ${OUT_FS}u; const OUT_PB = ${OUT_PB}u; const OUT_CF = ${OUT_CF}u;
488+
489+@group(0) @binding(0) var<storage, read> ops: array<f32>;
490+@group(0) @binding(1) var<storage, read> idx: array<u32>;
491+@group(0) @binding(2) var<storage, read_write> st: array<f32>;
492+@group(0) @binding(3) var<storage, read_write> frames: array<f32>;
493+@group(0) @binding(4) var<storage, read_write> outb: array<f32>;
494+@group(0) @binding(5) var<storage, read_write> ctl: array<u32>;
495+@group(0) @binding(6) var<uniform> uni: vec4<u32>;
496+
497+// The blow-up flag holds the 1-based step at which the field passed the limit;
498+// the steps after it do nothing, the step itself still records, as the .m does.
499+fn stopped(k: u32) -> bool {
500+ let d = ctl[1];
501+ return d != 0u && d <= k;
502+}
503+// Slot of the current field (phi, d, Lphi, c, dc) at step k and of the one being written
504+fn cur(k: u32) -> u32 { return (k & 1u) * N; }
505+fn nxt(k: u32) -> u32 { return ((k + 1u) & 1u) * N; }
506+fn curc(k: u32) -> u32 { return (k & 1u) * 3u; }
507+fn nxtc(k: u32) -> u32 { return ((k + 1u) & 1u) * 3u; }
508+// Rigid coordinate j of the current step, zero without rigid coordinates
509+fn cget(k: u32, j: u32) -> f32 { if (NR == 0u) { return 0.0; } return st[ST_AUX + AUX_C + curc(k) + j]; }
510+fn dcget(k: u32, j: u32) -> f32 { if (NR == 0u) { return 0.0; } return st[ST_AUX + AUX_DC + curc(k) + j]; }
511+fn rget(i: u32, j: u32) -> f32 { if (NR == 0u) { return 0.0; } return ops[OP_R + i + j * N]; }
512+// Total field and its backward-difference velocity at node i, step k
513+fn wtot(k: u32, i: u32) -> f32 {
514+ var w = st[ST_PHI + cur(k) + i];
515+ if (NR > 0u) { w = w + rget(i, 0u) * cget(k, 0u) + rget(i, 1u) * cget(k, 1u) + rget(i, 2u) * cget(k, 2u); }
516+ return w;
517+}
518+fn vtot(k: u32, i: u32) -> f32 {
519+ var d = st[ST_D + cur(k) + i];
520+ if (NR > 0u) { d = d + rget(i, 0u) * dcget(k, 0u) + rget(i, 1u) * dcget(k, 1u) + rget(i, 2u) * dcget(k, 2u); }
521+ return d * INV_DT;
522+}
523+`;
524+
525+ // Every difference stencil of the energy method applied to phi; the stencils
526+ // are integers, so a strain is a sum of nearby field values with small
527+ // integer coefficients, nearly exact in f32.
528+ const strain = `${header}
529+@compute @workgroup_size(${WG})
530+fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
531+ let k = ctl[0] + uni.x;
532+ if (k >= NSTEPS) { return; }
533+ let q = gid.x;
534+ if (q >= NG) { return; }
535+ if (stopped(k)) { return; }
536+ let pS = ST_PHI + cur(k);
537+ var e: f32 = 0.0;
538+ for (var p = idx[IX_GPTR + q]; p < idx[IX_GPTR + q + 1u]; p = p + 1u) {
539+ e = e + ops[OP_GV + p] * st[pS + idx[IX_GCOL + p]];
540+ }
541+ st[ST_E + q] = e;
542+}
543+`;
544+
545+ // G' applied to the weighted strains: bending, prestress and Laplacian at once
546+ const force = `${header}
547+@compute @workgroup_size(${WG})
548+fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
549+ let k = ctl[0] + uni.x;
550+ if (k >= NSTEPS) { return; }
551+ let i = gid.x;
552+ if (i >= N) { return; }
553+ if (stopped(k)) { return; }
554+ var yb: f32 = 0.0; var yg: f32 = 0.0; var yl: f32 = 0.0;
555+ for (var p = idx[IX_GTPTR + i]; p < idx[IX_GTPTR + i + 1u]; p = p + 1u) {
556+ let q = idx[IX_GTCOL + p];
557+ let g = ops[OP_GTV + p];
558+ var sb: f32 = 0.0; var sg: f32 = 0.0; var sl: f32 = 0.0;
559+ for (var r = idx[IX_WPTR + q]; r < idx[IX_WPTR + q + 1u]; r = r + 1u) {
560+ let e = st[ST_E + idx[IX_WCOL + r]];
561+ sb = sb + ops[OP_WBV + r] * e;
562+ sg = sg + ops[OP_WGV + r] * e;
563+ sl = sl + ops[OP_WLV + r] * e;
564+ }
565+ yb = yb + g * sb; yg = yg + g * sg; yl = yl + g * sl;
566+ }
567+ let phi = st[ST_PHI + cur(k) + i];
568+ st[ST_B + i] = yb + (yg + ops[OP_KSD + i] * phi);
569+ st[ST_L + cur(k) + i] = yl * ops[OP_INVA + i];
570+}
571+`;
572+
573+ // Nodal forces, the reductions of the rigid-body equations and the supports,
574+ // the rigid update, and this step's series, all in one workgroup.
575+ const dots = `${header}
576+var<workgroup> sh0: array<f32, ${RWG}>;
577+var<workgroup> sh1: array<f32, ${RWG}>;
578+var<workgroup> sh2: array<f32, ${RWG}>;
579+var<workgroup> sh3: array<f32, ${RWG}>;
580+var<workgroup> sh4: array<f32, ${RWG}>;
581+var<workgroup> sh5: array<f32, ${RWG}>;
582+var<workgroup> shs: array<f32, ${RWG}>;
583+fn reduce6(lid: u32) {
584+ for (var s = ${RWG / 2}u; s > 0u; s = s >> 1u) {
585+ if (lid < s) {
586+ sh0[lid] = sh0[lid] + sh0[lid + s]; sh1[lid] = sh1[lid] + sh1[lid + s]; sh2[lid] = sh2[lid] + sh2[lid + s];
587+ sh3[lid] = sh3[lid] + sh3[lid + s]; sh4[lid] = sh4[lid] + sh4[lid + s]; sh5[lid] = sh5[lid] + sh5[lid + s];
588+ }
589+ workgroupBarrier();
590+ }
591+}
592+@compute @workgroup_size(${RWG})
593+fn main(@builtin(local_invocation_index) lid: u32) {
594+ let k = ctl[0] + uni.x;
595+ let stop = stopped(k) || k >= NSTEPS;
596+ let pS = ST_PHI + cur(k);
597+ let lS = ST_L + cur(k); let lmS = ST_L + nxt(k);
598+ let Fk = ops[OP_F + min(k, NSTEPS - 1u)];
599+ var dc0: f32 = dcget(k, 0u); var dc1: f32 = dcget(k, 1u); var dc2: f32 = dcget(k, 2u);
600+ // nodal forces about rest, and the partial sums R' f and (AR)' phi
601+ var a0: f32 = 0.0; var a1: f32 = 0.0; var a2: f32 = 0.0;
602+ var g0: f32 = 0.0; var g1: f32 = 0.0; var g2: f32 = 0.0;
603+ for (var i = lid; i < N; i = i + ${RWG}u) {
604+ let w = wtot(k, i);
605+ let v = vtot(k, i);
606+ // the sigma1 loss on the total velocity: 2 sigma1 M Lm (w - wm) / dt
607+ var dlw = st[lS + i] - st[lmS + i];
608+ if (NR > 0u) { dlw = dlw + ops[OP_LR + i] * dc0 + ops[OP_LR + N + i] * dc1 + ops[OP_LR + 2u * N + i] * dc2; }
609+ var f = Fk * ops[OP_SA + i] + ops[OP_S1F + i] * dlw;
610+ var fsS = ops[OP_FSTATS + i] - ops[OP_KS + i] * w;
611+ var fsB = ops[OP_FSTATB + i];
612+ var fsT = ops[OP_FSTATT + i];
613+ let kb = ops[OP_KB + i];
614+ if (kb > 0.0) {
615+ let gb = ops[OP_GB + i];
616+ let comp = gb - w;
617+ var fb = kb * max(comp, 0.0) - kb * max(gb, 0.0);
618+ if (comp > 0.0) { fb = fb - ops[OP_CB + i] * v; }
619+ f = f + fb; fsB = fsB + fb;
620+ }
621+ let kt = ops[OP_KT + i];
622+ if (kt > 0.0) {
623+ let gt = ops[OP_GT + i];
624+ let comp = gt + w;
625+ var ft = -kt * max(comp, 0.0) + kt * max(gt, 0.0);
626+ if (comp > 0.0) { ft = ft - ops[OP_CT + i] * v; }
627+ f = f + ft; fsT = fsT + ft;
628+ }
629+ let cs = ops[OP_CS + i];
630+ if (cs > 0.0) { let fl = -cs * v; f = f + fl; fsS = fsS + fl; }
631+ st[ST_F + i] = f;
632+ st[ST_FS + i] = fsS;
633+ st[ST_FS + N + i] = fsB;
634+ st[ST_FS + 2u * N + i] = fsT;
635+ if (NR > 0u) {
636+ let phi = st[pS + i];
637+ a0 = a0 + rget(i, 0u) * f; a1 = a1 + rget(i, 1u) * f; a2 = a2 + rget(i, 2u) * f;
638+ g0 = g0 + ops[OP_AR + i] * phi; g1 = g1 + ops[OP_AR + N + i] * phi; g2 = g2 + ops[OP_AR + 2u * N + i] * phi;
639+ }
640+ }
641+ sh0[lid] = a0; sh1[lid] = a1; sh2[lid] = a2; sh3[lid] = g0; sh4[lid] = g1; sh5[lid] = g2;
642+ workgroupBarrier();
643+ reduce6(lid);
644+ // rigid-body update and the rigid series, on one thread
645+ if (lid == 0u && NR > 0u) {
646+ let cS = ST_AUX + AUX_C + curc(k); let cnS = ST_AUX + AUX_C + nxtc(k);
647+ let dS = ST_AUX + AUX_DC + curc(k); let dnS = ST_AUX + AUX_DC + nxtc(k);
648+ var rf = array<f32, 3>(sh0[0], sh1[0], sh2[0]);
649+ var g = array<f32, 3>(sh3[0], sh4[0], sh5[0]);
650+ var cc = array<f32, 3>(st[cS], st[cS + 1u], st[cS + 2u]);
651+ var dc = array<f32, 3>(st[dS], st[dS + 1u], st[dS + 2u]);
652+ var tmp = array<f32, 3>(0.0, 0.0, 0.0);
653+ for (var p = 0u; p < 3u; p = p + 1u) {
654+ var s = rf[p] - g[p];
655+ for (var q = 0u; q < 3u; q = q + 1u) { s = s - ops[OP_KRR + p + 3u * q] * cc[q]; }
656+ tmp[p] = s;
657+ }
658+ if (!stop) {
659+ let ia = idx[IX_IA + k];
660+ for (var p = 0u; p < 3u; p = p + 1u) {
661+ var acc: f32 = 0.0;
662+ for (var q = 0u; q < 3u; q = q + 1u) { acc = acc + ops[OP_GINV + p + 3u * q] * tmp[q]; }
663+ let rest = DT2 * acc;
664+ let dn = (dc[p] + rest) - CSIG * (2.0 * dc[p] + rest);
665+ st[ST_AUX + AUX_CACC + p] = acc;
666+ st[dnS + p] = dn;
667+ st[cnS + p] = cc[p] + dn;
668+ outb[OUT_C + p * NA + ia] = outb[OUT_C + p * NA + ia] + cc[p] * ops[OP_WSAMP + ia];
669+ }
670+ }
671+ }
672+ // the probe series of this step
673+ if (lid == 0u && !stop) {
674+ let ia = idx[IX_IA + k];
675+ let cw = st[pS + IPROBE];
676+ let e = st[pS + IPROBE + NY]; let w = st[pS + IPROBE - NY];
677+ let n = st[pS + IPROBE + 1u]; let so = st[pS + IPROBE - 1u];
678+ let ne = st[pS + IPROBE + NY + 1u]; let nw = st[pS + IPROBE - NY + 1u];
679+ let se = st[pS + IPROBE + NY - 1u]; let sw = st[pS + IPROBE - NY - 1u];
680+ outb[OUT_WP + ia] = outb[OUT_WP + ia] + wtot(k, IPROBE) * ops[OP_WSAMP + ia];
681+ outb[OUT_WXX + ia] = outb[OUT_WXX + ia] + (e - 2.0 * cw + w) * INV_DX2 * ops[OP_WSAMP + ia];
682+ outb[OUT_WYY + ia] = outb[OUT_WYY + ia] + (n - 2.0 * cw + so) * INV_DY2 * ops[OP_WSAMP + ia];
683+ outb[OUT_WXY + ia] = outb[OUT_WXY + ia] + (ne - se - nw + sw) * INV_DXDY4 * ops[OP_WSAMP + ia];
684+ }
685+ // support forces, one reduction per support
686+ for (var q = 0u; q < NSUP; q = q + 1u) {
687+ workgroupBarrier();
688+ let p0 = idx[IX_SUPPTR + q]; let p1 = idx[IX_SUPPTR + q + 1u];
689+ let base = ST_FS + idx[IX_SUPKIND + q] * N;
690+ var a: f32 = 0.0;
691+ for (var p = p0 + lid; p < p1; p = p + ${RWG}u) { a = a + st[base + idx[IX_SUPNODES + p]]; }
692+ shs[lid] = a;
693+ workgroupBarrier();
694+ for (var s = ${RWG / 2}u; s > 0u; s = s >> 1u) {
695+ if (lid < s) { shs[lid] = shs[lid] + shs[lid + s]; }
696+ workgroupBarrier();
697+ }
698+ if (lid == 0u && !stop) {
699+ let ia = idx[IX_IA + k];
700+ outb[OUT_FS + q * NA + ia] = outb[OUT_FS + q * NA + ia] + shs[0] * ops[OP_WSAMP + ia];
701+ }
702+ }
703+}
704+`;
705+
706+ const update = `${header}
707+@compute @workgroup_size(${WG})
708+fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
709+ let k = ctl[0] + uni.x;
710+ if (k >= NSTEPS) { return; }
711+ let i = gid.x;
712+ if (i >= N) { return; }
713+ if (stopped(k)) { return; }
714+ let pS = ST_PHI + cur(k); let pnS = ST_PHI + nxt(k);
715+ let dS = ST_D + cur(k); let dnS = ST_D + nxt(k);
716+ var fi = st[ST_F + i] - st[ST_B + i];
717+ if (NR > 0u) {
718+ // the force of the rigid displacement on the plate, AR c, with c of the current step
719+ fi = fi - ops[OP_AR + i] * cget(k, 0u) - ops[OP_AR + N + i] * cget(k, 1u) - ops[OP_AR + 2u * N + i] * cget(k, 2u);
720+ }
721+ var acc = fi * ops[OP_MINV + i];
722+ if (NR > 0u) {
723+ acc = acc - rget(i, 0u) * st[ST_AUX + AUX_CACC] - rget(i, 1u) * st[ST_AUX + AUX_CACC + 1u] - rget(i, 2u) * st[ST_AUX + AUX_CACC + 2u];
724+ }
725+ let rest = DT2 * acc;
726+ let d = st[dS + i];
727+ var dn = (d + rest) - CSIG * (2.0 * d + rest);
728+ var pn = st[pS + i] + dn;
729+ let kp = ops[OP_KEEP + i];
730+ pn = pn * kp; dn = dn * kp;
731+ st[pnS + i] = pn;
732+ st[dnS + i] = dn;
733+ let f = idx[IX_FRAME + k];
734+ if (f != ${NONE}u) {
735+ frames[f * N + i] = pn;
736+ if (NR > 0u && i < 3u) { outb[OUT_CF + i * NF + f] = st[ST_AUX + AUX_C + nxtc(k) + i]; }
737+ }
738+}
739+`;
740+
741+ // Workgroups 0..NBINS-1 sum the weighted acceleration of their bin's entries
742+ // into the audio sample of this step; workgroup NBINS takes the extent of the
743+ // new field for the blow-up test.
744+ const reduce = `${header}
745+var<workgroup> sh: array<f32, ${RWG}>;
746+@compute @workgroup_size(${RWG})
747+fn main(@builtin(local_invocation_index) lid: u32, @builtin(workgroup_id) wgid: vec3<u32>) {
748+ let k = ctl[0] + uni.x;
749+ let stop = stopped(k) || k >= NSTEPS;
750+ let pnS = ST_PHI + nxt(k);
751+ let dS = ST_D + cur(k); let dnS = ST_D + nxt(k);
752+ let wg = wgid.x;
753+ if (wg < NBINS) {
754+ var dd = array<f32, 3>(0.0, 0.0, 0.0);
755+ if (NR > 0u) {
756+ for (var p = 0u; p < 3u; p = p + 1u) { dd[p] = st[ST_AUX + AUX_DC + nxtc(k) + p] - st[ST_AUX + AUX_DC + curc(k) + p]; }
757+ }
758+ let p0 = idx[IX_BINPTR + wg]; let p1 = idx[IX_BINPTR + wg + 1u];
759+ var a: f32 = 0.0;
760+ for (var p = p0 + lid; p < p1; p = p + ${RWG}u) {
761+ let i = idx[IX_BINNODE + p];
762+ var accw = st[dnS + i] - st[dS + i];
763+ if (NR > 0u) { accw = accw + rget(i, 0u) * dd[0] + rget(i, 1u) * dd[1] + rget(i, 2u) * dd[2]; }
764+ a = a + ops[OP_WGT + p] * (accw * INV_DT2);
765+ }
766+ sh[lid] = a;
767+ workgroupBarrier();
768+ for (var s = ${RWG / 2}u; s > 0u; s = s >> 1u) {
769+ if (lid < s) { sh[lid] = sh[lid] + sh[lid + s]; }
770+ workgroupBarrier();
771+ }
772+ if (lid == 0u && !stop) {
773+ let ia = idx[IX_IA + k];
774+ let o = OUT_PB + wg + ia * NBINS;
775+ outb[o] = outb[o] + sh[0] * ops[OP_WSAMP + ia];
776+ }
777+ } else {
778+ var m: f32 = 0.0;
779+ for (var i = lid; i < N; i = i + ${RWG}u) { m = max(m, abs(st[pnS + i])); }
780+ sh[lid] = m;
781+ workgroupBarrier();
782+ for (var s = ${RWG / 2}u; s > 0u; s = s >> 1u) {
783+ if (lid < s) { sh[lid] = max(sh[lid], sh[lid + s]); }
784+ workgroupBarrier();
785+ }
786+ if (lid == 0u && !stop) {
787+ var peak = sh[0];
788+ if (NR > 0u) {
789+ let cn = ST_AUX + AUX_C + nxtc(k);
790+ peak = peak + abs(st[cn]) * RMAX0 + abs(st[cn + 1u]) * RMAX1 + abs(st[cn + 2u]) * RMAX2;
791+ }
792+ if (!(peak < BLOWUP)) { ctl[1] = k + 1u; }
793+ }
794+ }
795+}
796+`;
797+
798+ const sources = [strain, force, dots, update, reduce];
799+ const groups = [Math.ceil(ng / WG), Math.ceil(N / WG), 1, Math.ceil(N / WG), nbins + 1];
800+
801+ // ---- initial state, in f64 as the .m has it
802+ const st0 = new Float32Array(stLen);
803+ const e0 = spmv(su.Gs, su.phi0, ng);
804+ const wl0 = spmv(su.WL, e0, ng);
805+ const lphi0 = spmvT(su.Gs, wl0, N);
806+ for (let i = 0; i < N; i++) lphi0[i] *= su.inva[i];
807+ for (let i = 0; i < N; i++) {
808+ st0[ST_PHI + i] = su.phi0[i];
809+ st0[ST_L + i] = lphi0[i];
810+ st0[ST_L + N + i] = lphi0[i];
811+ }
812+ for (let j = 0; j < nr; j++) st0[ST_AUX + AUX_C + j] = su.c0[j];
813+
814+ return {
815+ N,
816+ nf,
817+ nr,
818+ nsup,
819+ nbins,
820+ na,
821+ outLen,
822+ stLen,
823+ st0,
824+ ops: ops.data,
825+ idx: idx.data,
826+ sources,
827+ groups,
828+ out: { OUT_WP, OUT_WXX, OUT_WYY, OUT_WXY, OUT_C, OUT_FS, OUT_PB, OUT_CF },
829+ };
830+}
831+
832+export async function runFdLoopGpu(
833+ device: GPUDevice,
834+ su: FdLoopSetup,
835+ onProgress?: (stepsDone: number, nsteps: number) => void,
836+): Promise<FdLoopRecord> {
837+ const nsteps = su.nsteps | 0;
838+ const b = buildShaders(su);
839+
840+ const bgl = device.createBindGroupLayout({
841+ entries: [
842+ { binding: 0, visibility: GPUShaderStage.COMPUTE, buffer: { type: "read-only-storage" } },
843+ { binding: 1, visibility: GPUShaderStage.COMPUTE, buffer: { type: "read-only-storage" } },
844+ { binding: 2, visibility: GPUShaderStage.COMPUTE, buffer: { type: "storage" } },
845+ { binding: 3, visibility: GPUShaderStage.COMPUTE, buffer: { type: "storage" } },
846+ { binding: 4, visibility: GPUShaderStage.COMPUTE, buffer: { type: "storage" } },
847+ { binding: 5, visibility: GPUShaderStage.COMPUTE, buffer: { type: "storage" } },
848+ {
849+ binding: 6,
850+ visibility: GPUShaderStage.COMPUTE,
851+ buffer: { type: "uniform", hasDynamicOffset: true, minBindingSize: 16 },
852+ },
853+ ],
854+ });
855+ const layout = device.createPipelineLayout({ bindGroupLayouts: [bgl] });
856+ const pipelines: GPUComputePipeline[] = [];
857+ for (const code of b.sources) {
858+ const module = device.createShaderModule({ code });
859+ const info = await module.getCompilationInfo();
860+ const errs = info.messages.filter((m) => m.type === "error");
861+ if (errs.length)
862+ throw new Error(
863+ "WGSL: " + errs.map((m) => `${m.lineNum}:${m.linePos} ${m.message}`).join("; "),
864+ );
865+ pipelines.push(
866+ device.createComputePipeline({ layout, compute: { module, entryPoint: "main" } }),
867+ );
868+ }
869+
870+ const mk = (size: number, usage: number) =>
871+ device.createBuffer({ size: Math.max(16, size), usage });
872+ const opsBuf = mk(b.ops.byteLength, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
873+ device.queue.writeBuffer(opsBuf, 0, b.ops);
874+ const idxBuf = mk(b.idx.byteLength, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
875+ device.queue.writeBuffer(idxBuf, 0, b.idx);
876+ const stBuf = mk(b.stLen * 4, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
877+ device.queue.writeBuffer(stBuf, 0, b.st0);
878+ const framesBuf = mk(b.N * b.nf * 4, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC);
879+ const outBuf = mk(b.outLen * 4, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC);
880+ const ctlBuf = mk(16, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST);
881+ const uniBuf = mk(CHUNK * 256, GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST);
882+ {
883+ const uni = new Uint32Array(CHUNK * 64);
884+ for (let s = 0; s < CHUNK; s++) uni[s * 64] = s;
885+ device.queue.writeBuffer(uniBuf, 0, uni);
886+ }
887+ const ctlStage = [0, 1].map(() => mk(16, GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST));
888+ const readFrames = mk(b.N * b.nf * 4, GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST);
889+ const readOut = mk(b.outLen * 4, GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST);
890+ const all = [
891+ opsBuf,
892+ idxBuf,
893+ stBuf,
894+ framesBuf,
895+ outBuf,
896+ ctlBuf,
897+ uniBuf,
898+ ...ctlStage,
899+ readFrames,
900+ readOut,
901+ ];
902+
903+ const bindGroup = device.createBindGroup({
904+ layout: bgl,
905+ entries: [
906+ { binding: 0, resource: { buffer: opsBuf } },
907+ { binding: 1, resource: { buffer: idxBuf } },
908+ { binding: 2, resource: { buffer: stBuf } },
909+ { binding: 3, resource: { buffer: framesBuf } },
910+ { binding: 4, resource: { buffer: outBuf } },
911+ { binding: 5, resource: { buffer: ctlBuf } },
912+ { binding: 6, resource: { buffer: uniBuf, offset: 0, size: 16 } },
913+ ],
914+ });
915+
916+ try {
917+ const t0 = performance.now();
918+ let divergedStep = 0;
919+ const pending: { done: Promise<undefined>; stage: GPUBuffer; kend: number }[] = [];
920+ const settle = async () => {
921+ const p = pending.shift()!;
922+ await p.done;
923+ await p.stage.mapAsync(GPUMapMode.READ);
924+ const flag = new Uint32Array(p.stage.getMappedRange().slice(0))[1];
925+ p.stage.unmap();
926+ onProgress?.(p.kend, nsteps);
927+ if (flag !== 0) divergedStep = flag;
928+ };
929+ let kbase = 0;
930+ let c = 0;
931+ while (kbase < nsteps && divergedStep === 0) {
932+ const n = Math.min(CHUNK, nsteps - kbase);
933+ device.queue.writeBuffer(ctlBuf, 0, Uint32Array.of(kbase));
934+ const enc = device.createCommandEncoder();
935+ const pass = enc.beginComputePass();
936+ for (let s = 0; s < n; s++) {
937+ pass.setBindGroup(0, bindGroup, [s * 256]);
938+ for (let q = 0; q < pipelines.length; q++) {
939+ pass.setPipeline(pipelines[q]);
940+ pass.dispatchWorkgroups(b.groups[q]);
941+ }
942+ }
943+ pass.end();
944+ const stage = ctlStage[c % 2];
945+ enc.copyBufferToBuffer(ctlBuf, 0, stage, 0, 16);
946+ device.queue.submit([enc.finish()]);
947+ pending.push({ done: device.queue.onSubmittedWorkDone(), stage, kend: kbase + n });
948+ kbase += n;
949+ c++;
950+ if (pending.length >= 2) await settle();
951+ }
952+ while (pending.length) await settle();
953+
954+ const enc = device.createCommandEncoder();
955+ enc.copyBufferToBuffer(framesBuf, 0, readFrames, 0, Math.max(16, b.N * b.nf * 4));
956+ enc.copyBufferToBuffer(outBuf, 0, readOut, 0, Math.max(16, b.outLen * 4));
957+ device.queue.submit([enc.finish()]);
958+ await readFrames.mapAsync(GPUMapMode.READ);
959+ await readOut.mapAsync(GPUMapMode.READ);
960+ const Wf = new Float32Array(readFrames.getMappedRange().slice(0)).subarray(0, b.N * b.nf);
961+ const out = new Float32Array(readOut.getMappedRange().slice(0));
962+ readFrames.unmap();
963+ readOut.unmap();
964+ const gpuMs = performance.now() - t0;
965+ const o = b.out;
966+ const na = b.na;
967+ return {
968+ w_p: out.slice(o.OUT_WP, o.OUT_WP + na),
969+ wxx_p: out.slice(o.OUT_WXX, o.OUT_WXX + na),
970+ wyy_p: out.slice(o.OUT_WYY, o.OUT_WYY + na),
971+ wxy_p: out.slice(o.OUT_WXY, o.OUT_WXY + na),
972+ C: out.slice(o.OUT_C, o.OUT_C + b.nr * na),
973+ Fsup: out.slice(o.OUT_FS, o.OUT_FS + b.nsup * na),
974+ Wf: Float32Array.from(Wf),
975+ Cf: out.slice(o.OUT_CF, o.OUT_CF + b.nr * b.nf),
976+ Pbins: out.slice(o.OUT_PB, o.OUT_PB + b.nbins * na),
977+ divergedStep,
978+ gpuMs,
979+ };
980+ } finally {
981+ for (const buf of all) buf.destroy();
982+ }
983+}
packages/core/src/gpu/fdStage.tsadded+153−0View file
@@ -0,0 +1,153 @@
1+/**
2+ * The two hand-offs of a GPU run: plate_fd_setup's struct (as numbl returns it,
3+ * with the sparse operators already turned into find() triplets by the stage
4+ * script) into the loop's input, and the loop's record into the struct Rec that
5+ * plate_fd_output.m takes. Both sides keep the .m's names and 1-based indices.
6+ */
7+import { isPlainStruct, isPlainTensor, type PlainStruct, type PlainValue } from "../runner/plain";
8+import type { FdLoopRecord, FdLoopSetup, Triplets } from "./fdLoop";
9+
10+class SetupShapeError extends Error {}
11+
12+function field(s: PlainStruct, name: string): PlainValue {
13+ const v = s.fields[name];
14+ if (v === undefined) throw new SetupShapeError(`setup struct is missing "${name}"`);
15+ return v;
16+}
17+
18+function num(s: PlainStruct, name: string): number {
19+ const v = field(s, name);
20+ if (typeof v === "number") return v;
21+ if (typeof v === "boolean") return v ? 1 : 0;
22+ if (isPlainTensor(v) && v.data.length === 1) return v.data[0];
23+ throw new SetupShapeError(`"${name}" should be a scalar`);
24+}
25+
26+function vec(s: PlainStruct, name: string): Float64Array {
27+ const v = field(s, name);
28+ if (typeof v === "number") return Float64Array.of(v);
29+ if (typeof v === "boolean") return Float64Array.of(v ? 1 : 0);
30+ if (isPlainTensor(v)) return v.data;
31+ throw new SetupShapeError(`"${name}" should be numeric`);
32+}
33+
34+function sub(s: PlainStruct, name: string): PlainStruct {
35+ const v = field(s, name);
36+ if (!isPlainStruct(v)) throw new SetupShapeError(`"${name}" should be a struct`);
37+ return v;
38+}
39+
40+function triplets(s: PlainStruct, name: string): Triplets {
41+ return { i: vec(s, `${name}_i`), j: vec(s, `${name}_j`), v: vec(s, `${name}_v`) };
42+}
43+
44+/** Read the loop's input from the struct the GPU setup script leaves in S. */
45+export function fdLoopSetupFromPlain(v: PlainValue): FdLoopSetup {
46+ if (!isPlainStruct(v)) throw new SetupShapeError("the setup variable is not a struct");
47+ const P = sub(v, "P");
48+ const nr = num(v, "nr");
49+ const nsup = num(v, "nsup");
50+ return {
51+ nx: num(v, "nx"),
52+ ny: num(v, "ny"),
53+ nsteps: num(v, "nsteps"),
54+ dx: num(v, "dx"),
55+ dy: num(v, "dy"),
56+ dt: num(v, "dt"),
57+ fs: num(v, "fs"),
58+ na: num(v, "na"),
59+ keep: vec(v, "keep"),
60+ Mvec: vec(v, "Mvec"),
61+ Gs: triplets(v, "Gs"),
62+ Gscale: vec(v, "Gscale"),
63+ ng: num(v, "ng"),
64+ WB: triplets(v, "WB"),
65+ WG: triplets(v, "WG"),
66+ WL: triplets(v, "WL"),
67+ inva: vec(v, "inva"),
68+ ks: vec(v, "ks"),
69+ nr,
70+ R: nr > 0 ? vec(v, "R") : new Float64Array(0),
71+ AR: nr > 0 ? vec(v, "AR") : new Float64Array(0),
72+ Krr: nr > 0 ? vec(v, "Krr") : new Float64Array(0),
73+ Ginv: nr > 0 ? vec(v, "Ginv") : new Float64Array(0),
74+ kb: vec(v, "kb"),
75+ cb: vec(v, "cb"),
76+ gb: vec(v, "gb"),
77+ kt: vec(v, "kt"),
78+ ct: vec(v, "ct"),
79+ gt: vec(v, "gt"),
80+ cs: vec(v, "cs"),
81+ fstatS: vec(v, "fstat_s"),
82+ fstatB: vec(v, "fstat_b"),
83+ fstatT: vec(v, "fstat_t"),
84+ supPtr: vec(v, "sup_ptr"),
85+ supNodes: nsup > 0 ? vec(v, "sup_nodes") : new Float64Array(0),
86+ supKind: nsup > 0 ? vec(v, "sup_kind") : new Float64Array(0),
87+ nsup,
88+ sa: vec(v, "sa"),
89+ F: vec(v, "F"),
90+ ip: num(v, "ip"),
91+ jp: num(v, "jp"),
92+ frameSteps: vec(v, "frame_steps"),
93+ lisNode: vec(v, "lis_node"),
94+ lisBin: vec(v, "lis_bin"),
95+ lisWgt: vec(v, "lis_wgt"),
96+ nbins: num(v, "nbins"),
97+ c0: nr > 0 ? vec(v, "c0") : new Float64Array(0),
98+ phi0: vec(v, "phi0"),
99+ sigma0: num(sub(P, "damping"), "sigma0"),
100+ s1f: vec(v, "s1f"),
101+ LR: nr > 0 ? vec(v, "LR") : new Float64Array(0),
102+ dt2: num(v, "dt2"),
103+ wsamp: vec(v, "wsamp"),
104+ blowup: num(v, "blowup"),
105+ };
106+}
107+
108+/** The record as plate_fd_loop.m would return it, for plate_fd_output.m. */
109+export function fdLoopRecordToPlain(
110+ rec: FdLoopRecord,
111+ su: FdLoopSetup,
112+ adapter: string,
113+): PlainStruct {
114+ const row = (a: Float32Array): PlainValue => ({
115+ kind: "tensor",
116+ shape: [1, a.length],
117+ data: Float64Array.from(a),
118+ });
119+ // rows of an m x n matrix stored row-major (row k at a[k * n ..]) -> column-major tensor
120+ const mat = (a: Float32Array, m: number, n: number): PlainValue => {
121+ const data = new Float64Array(m * n);
122+ for (let k = 0; k < m; k++) for (let j = 0; j < n; j++) data[k + j * m] = a[k * n + j];
123+ return { kind: "tensor", shape: [m, n], data };
124+ };
125+ const nf = su.frameSteps.length;
126+ const na = su.na;
127+ const notes: string[] = [];
128+ if (rec.divergedStep > 0)
129+ notes.push(
130+ `solution diverged at step ${rec.divergedStep} (t = ${(rec.divergedStep * su.dt).toPrecision(3)} s); results are truncated there`,
131+ );
132+ notes.push(
133+ `time loop ran on the GPU in single precision (${adapter}): ${su.nsteps.toLocaleString()} steps in ${(rec.gpuMs / 1000).toFixed(2)} s`,
134+ );
135+ return {
136+ kind: "struct",
137+ fields: {
138+ w_p: row(rec.w_p),
139+ wxx_p: row(rec.wxx_p),
140+ wyy_p: row(rec.wyy_p),
141+ wxy_p: row(rec.wxy_p),
142+ C: mat(rec.C, su.nr, na),
143+ Fsup: mat(rec.Fsup, su.nsup, na),
144+ Wf: { kind: "tensor", shape: [su.ny, su.nx, nf], data: Float64Array.from(rec.Wf) },
145+ Cf: mat(rec.Cf, su.nr, nf),
146+ Pbins: { kind: "tensor", shape: [su.nbins, na], data: Float64Array.from(rec.Pbins) },
147+ diverged_step: rec.divergedStep,
148+ notes: { kind: "cell", shape: [1, notes.length], items: notes },
149+ backend: "webgpu",
150+ loop_s: rec.gpuMs / 1000,
151+ },
152+ };
153+}
packages/core/src/index.tsadded+24−0View file
@@ -0,0 +1,24 @@
1+export * from "./spec/types";
2+export * from "./spec/presets";
3+export * from "./spec/defaults";
4+export * from "./spec/validate";
5+export * from "./methods/types";
6+export * from "./methods/registry";
7+export { FD_METHOD } from "./methods/fd";
8+export * from "./methods/common";
9+export * from "./generate/format";
10+export * from "./generate/params";
11+export * from "./generate/library";
12+export * from "./generate/bundle";
13+export * from "./runner/plain";
14+export * from "./runner/types";
15+export { WorkerRunner } from "./runner/workerRunner";
16+export { describeAdapter, type GpuHandle } from "./gpu/fdLoop";
17+export * from "./results/types";
18+export * from "./results/parse";
19+export * from "./fields/field";
20+export * from "./fields/colormap";
21+export * from "./fields/raster";
22+export * from "./audio/buffer";
23+export * from "./audio/wav";
24+export * from "./cost";
packages/core/src/m/plate_bending.madded+126−0View file
@@ -0,0 +1,126 @@
1+function [K, L, ops] = plate_bending(dom, held, nu, D)
2+% PLATE_BENDING Sparse bending and Laplacian operators on the plate by the energy method.
3+%
4+% [K, L, ops] = plate_bending(dom, held, nu, D) assembles, on every node of
5+% the plate (dom.inside from plate_domain), the stiffness matrix K of the
6+% bending energy
7+%
8+% U = D/2 * integral( w_xx^2 + w_yy^2 + 2 nu w_xx w_yy + 2 (1-nu) w_xy^2 ) dA
9+%
10+% and the operator L of the Laplacian energy integral |grad w|^2 dA, both as
11+% sparse N x N matrices over all nx ny grid nodes (rows and columns of nodes
12+% off the plate are empty). Second differences are taken at the nodes that
13+% have both neighbours on the plate, the cross difference at the centre of
14+% each cell whose four corners are on the plate, each weighted by its area
15+% (the finite-difference energy method; Bushnell, Computers & Structures
16+% 1973). Inside the plate K is D times the 13-point biharmonic stencil
17+% times the cell area; on the edges the free-edge conditions, no bending
18+% moment and no Kirchhoff shear, are the natural conditions of the discrete
19+% energy, met to first order in the spacing. K is symmetric positive
20+% semidefinite with the three rigid-body motions 1, x, y as its null space.
21+%
22+% Held nodes (the logical ny x nx array held, the jaws of a vise) are part
23+% of the energy but not of the unknowns: their rows and columns are dropped
24+% after assembly. A second difference centred on a held node that reaches a
25+% free node then penalizes the slope at the clamp, which is exactly the
26+% clamped condition w = w_n = 0; it needs a held node behind every held edge
27+% node, which plate_mount guarantees.
28+%
29+% L is the Laplacian energy operator with the natural condition of zero
30+% normal slope, each grid edge weighted by its share of cells; the loss
31+% term of the plate equation uses it divided by the node areas.
32+%
33+% ops returns the difference operators the prestress term shares
34+% (plate_prestress): Bx, By first differences along the x and y edges of the
35+% plate (edges x areas), Bxc, Byc the cell-centred gradients, and the edge and
36+% cell weights, all on the numbering ops.idx of the plate's nodes.
37+
38+nx = dom.nx; ny = dom.ny; dx = dom.dx; dy = dom.dy;
39+N = nx * ny;
40+inside = dom.inside;
41+idx = find(inside);
42+n = numel(idx);
43+me = (1:n)';
44+unk = inside & ~held;
45+
46+% Node number of every grid node (0 off the plate), padded so every node has four neighbours to look up
47+num_in = zeros(ny, nx);
48+num_in(idx) = me;
49+num = zeros(ny + 2, nx + 2);
50+num(2:end - 1, 2:end - 1) = num_in;
51+nbL = num(2:end - 1, 1:end - 2); nbR = num(2:end - 1, 3:end);
52+nbD = num(1:end - 2, 2:end - 1); nbU = num(3:end, 2:end - 1);
53+uL = nbL(idx); uR = nbR(idx); uD = nbD(idx); uU = nbU(idx);
54+
55+area = dx * dy;
56+mu = dom.m(idx) * area; % node areas
57+hasX = uL > 0 & uR > 0;
58+hasY = uD > 0 & uU > 0;
59+kx = find(hasX); nkx = numel(kx);
60+ky = find(hasY); nky = numel(ky);
61+Dxx = sparse([(1:nkx)'; (1:nkx)'; (1:nkx)'], [uL(kx); me(kx); uR(kx)], [ones(nkx, 1); -2 * ones(nkx, 1); ones(nkx, 1)] / dx^2, nkx, n);
62+Dyy = sparse([(1:nky)'; (1:nky)'; (1:nky)'], [uD(ky); me(ky); uU(ky)], [ones(nky, 1); -2 * ones(nky, 1); ones(nky, 1)] / dy^2, nky, n);
63+Kxx = Dxx' * spdiags(mu(kx), 0, nkx, nkx) * Dxx;
64+Kyy = Dyy' * spdiags(mu(ky), 0, nky, nky) * Dyy;
65+kc = find(hasX & hasY); nkc = numel(kc);
66+Dxc = sparse([(1:nkc)'; (1:nkc)'; (1:nkc)'], [uL(kc); me(kc); uR(kc)], [ones(nkc, 1); -2 * ones(nkc, 1); ones(nkc, 1)] / dx^2, nkc, n);
67+Dyc = sparse([(1:nkc)'; (1:nkc)'; (1:nkc)'], [uD(kc); me(kc); uU(kc)], [ones(nkc, 1); -2 * ones(nkc, 1); ones(nkc, 1)] / dy^2, nkc, n);
68+Wc = spdiags(mu(kc), 0, nkc, nkc);
69+Kc = nu * (Dxc' * Wc * Dyc + Dyc' * Wc * Dxc);
70+
71+% Cells with four corners on the plate: the twist at the centre
72+A00 = num_in(1:end - 1, 1:end - 1); A10 = num_in(2:end, 1:end - 1);
73+A01 = num_in(1:end - 1, 2:end); A11 = num_in(2:end, 2:end);
74+cu = A00 > 0 & A10 > 0 & A01 > 0 & A11 > 0;
75+kcell = find(cu); ncell = numel(kcell);
76+c00 = A00(kcell); c10 = A10(kcell); c01 = A01(kcell); c11 = A11(kcell);
77+ic = (1:ncell)';
78+Dxy = sparse([ic; ic; ic; ic], [c00; c10; c01; c11], [ones(ncell, 1); -ones(ncell, 1); -ones(ncell, 1); ones(ncell, 1)] / (dx * dy), ncell, n);
79+Kxy = 2 * (1 - nu) * area * (Dxy' * Dxy);
80+Ku = D * (Kxx + Kyy + Kc + Kxy);
81+
82+% Laplacian energy: first differences along the grid edges, weighted by the
83+% cell area each edge shares (a whole cell inside, half a cell on the boundary)
84+cpad = zeros(ny + 1, nx + 1);
85+cpad(2:end - 1, 2:end - 1) = cu;
86+WX = (cpad(2:end, 2:end) + cpad(1:end - 1, 2:end)) / 2; % x-edge from (i, j) to (i, j+1)
87+WY = (cpad(2:end, 2:end) + cpad(2:end, 1:end - 1)) / 2; % y-edge from (i, j) to (i+1, j)
88+ex = find(uR > 0); nex = numel(ex);
89+ey = find(uU > 0); ney = numel(ey);
90+Bx = sparse([(1:nex)'; (1:nex)'], [me(ex); uR(ex)], [-ones(nex, 1); ones(nex, 1)] / dx, nex, n);
91+By = sparse([(1:ney)'; (1:ney)'], [me(ey); uU(ey)], [-ones(ney, 1); ones(ney, 1)] / dy, ney, n);
92+wx = WX(idx(ex)) * area; wy = WY(idx(ey)) * area;
93+Lu = -(Bx' * spdiags(wx, 0, nex, nex) * Bx + By' * spdiags(wy, 0, ney, ney) * By);
94+
95+% Cell-centred gradients: the average of the two x-edges (bottom and top) and the two y-edges (left and right)
96+xe_of = zeros(n, 1); xe_of(me(ex)) = (1:nex)';
97+ye_of = zeros(n, 1); ye_of(me(ey)) = (1:ney)';
98+Cx = sparse([ic; ic], [xe_of(c00); xe_of(c10)], 0.5 * ones(2 * ncell, 1), ncell, nex);
99+Cy = sparse([ic; ic], [ye_of(c00); ye_of(c01)], 0.5 * ones(2 * ncell, 1), ncell, ney);
100+Bxc = Cx * Bx;
101+Byc = Cy * By;
102+
103+% Drop the held nodes, then embed over all N grid nodes
104+sel = double(unk(idx));
105+Pz = spdiags(sel, 0, n, n);
106+Ku = Pz * Ku * Pz;
107+Lu = Pz * Lu * Pz;
108+[ki, kj, kv] = find(Ku);
109+K = sparse(idx(ki), idx(kj), kv, N, N);
110+[li, lj, lv] = find(Lu);
111+L = sparse(idx(li), idx(lj), lv, N, N);
112+
113+ops.idx = idx; ops.n = n; ops.mu = mu;
114+ops.Bx = Bx; ops.By = By; ops.Bxc = Bxc; ops.Byc = Byc; ops.Cx = Cx; ops.Cy = Cy;
115+ops.wx = wx; ops.wy = wy; ops.ncell = ncell; ops.area = area;
116+ops.unk = sel;
117+ops.corners = [c00 c10 c01 c11];
118+% The factors of K: strain operators and the weights between them, so that
119+% K = D (Dxx' Mx Dxx + Dyy' My Dyy + nu (Dxc' Wc Dyc + Dyc' Wc Dxc) + 2 (1 - nu) A Dxy' Dxy).
120+% The GPU port applies K in this form (a second difference, weights, a second
121+% difference transposed) because in single precision the assembled 13-point
122+% stencil loses the smooth modes to rounding; see plate_fd_setup.
123+ops.Dxx = Dxx; ops.Dyy = Dyy; ops.Dxc = Dxc; ops.Dyc = Dyc; ops.Dxy = Dxy;
124+ops.mkx = mu(kx); ops.mky = mu(ky); ops.mkc = mu(kc);
125+ops.D = D; ops.nu = nu;
126+end
packages/core/src/m/plate_curvature_domain.madded+36−0View file
@@ -0,0 +1,36 @@
1+function [wxx, wyy, wxy] = plate_curvature_domain(w, dom)
2+% PLATE_CURVATURE_DOMAIN Second derivatives of the displacement on an embedded plate.
3+%
4+% [wxx, wyy, wxy] = plate_curvature_domain(w, dom) returns the curvatures of
5+% the ny x nx field w on the footprint dom from plate_domain (rows index y,
6+% columns index x). At every node whose eight neighbours belong to the plate
7+% (dom.full3) they are the usual central differences. The remaining nodes of
8+% the plate form the staircase ring along a curved edge, where no single
9+% formula is reliable: an edge node of the staircase has neighbours outside
10+% the plate in directions that depend on where the true edge happens to cut
11+% the grid. Those nodes take the curvatures of the nearest node with a full
12+% stencil (dom.near), a first-order extrapolation that matches the accuracy
13+% of the staircase itself. Outside the plate the curvatures are NaN, so the
14+% stress fields drawn from them are blank there.
15+%
16+% For a clamped edge the largest stress sits on the edge, and this puts it
17+% about one grid spacing inside; refine the grid to sharpen it. The
18+% rectangle keeps its own edge formulas (plate_curvature, plate_curvature_free).
19+
20+dx = dom.dx; dy = dom.dy;
21+[ny, nx] = size(w);
22+wxx = zeros(ny, nx); wyy = zeros(ny, nx); wxy = zeros(ny, nx);
23+c = w(2:end - 1, 2:end - 1);
24+wxx(2:end - 1, 2:end - 1) = (w(2:end - 1, 3:end) - 2 * c + w(2:end - 1, 1:end - 2)) / dx^2;
25+wyy(2:end - 1, 2:end - 1) = (w(3:end, 2:end - 1) - 2 * c + w(1:end - 2, 2:end - 1)) / dy^2;
26+wxy(2:end - 1, 2:end - 1) = (w(3:end, 3:end) - w(3:end, 1:end - 2) - w(1:end - 2, 3:end) + w(1:end - 2, 1:end - 2)) / (4 * dx * dy);
27+ring = find(dom.inside & ~dom.full3);
28+src = dom.near(ring);
29+wxx(ring) = wxx(src);
30+wyy(ring) = wyy(src);
31+wxy(ring) = wxy(src);
32+out = ~dom.inside;
33+wxx(out) = NaN;
34+wyy(out) = NaN;
35+wxy(out) = NaN;
36+end
packages/core/src/m/plate_curvature_free.madded+33−0View file
@@ -0,0 +1,33 @@
1+function [wxx, wyy, wxy] = plate_curvature_free(w, dx, dy, nu)
2+% PLATE_CURVATURE_FREE Second derivatives of the displacement of a plate with free edges.
3+%
4+% [wxx, wyy, wxy] = plate_curvature_free(w, dx, dy, nu) returns the curvatures
5+% at every node of the ny x nx field w (rows index y, columns index x).
6+% Inside they are the usual central differences. On the edges there is no
7+% ghost layer to lean on, so the free-edge conditions themselves supply the
8+% missing values:
9+%
10+% no bending moment on an edge w_nn = -nu w_tt (n normal, t tangent)
11+% no twisting moment at a corner w_xy = 0, hence also w_xx = w_yy = 0 there
12+%
13+% The tangential curvature along an edge is a central difference along the
14+% edge, and the twist on an edge is one-sided in the normal direction. Both
15+% direct stresses therefore vanish at a corner, and on an edge the normal
16+% stress vanishes while the tangential stress is -E z w_tt.
17+
18+[ny, nx] = size(w);
19+wxx = zeros(ny, nx);
20+wyy = zeros(ny, nx);
21+wxy = zeros(ny, nx);
22+wxx(:, 2:end - 1) = (w(:, 3:end) - 2 * w(:, 2:end - 1) + w(:, 1:end - 2)) / dx^2;
23+wyy(2:end - 1, :) = (w(3:end, :) - 2 * w(2:end - 1, :) + w(1:end - 2, :)) / dy^2;
24+% Edge columns and rows: the moment condition, using the tangential curvature just computed
25+wxx(2:end - 1, [1 end]) = -nu * wyy(2:end - 1, [1 end]);
26+wyy([1 end], 2:end - 1) = -nu * wxx([1 end], 2:end - 1);
27+% Twist: central inside, one-sided in the normal direction on the edges, zero at the corners
28+wxy(2:end - 1, 2:end - 1) = (w(3:end, 3:end) - w(3:end, 1:end - 2) - w(1:end - 2, 3:end) + w(1:end - 2, 1:end - 2)) / (4 * dx * dy);
29+wxy(2:end - 1, 1) = (w(3:end, 2) - w(3:end, 1) - w(1:end - 2, 2) + w(1:end - 2, 1)) / (2 * dx * dy);
30+wxy(2:end - 1, end) = (w(3:end, end) - w(3:end, end - 1) - w(1:end - 2, end) + w(1:end - 2, end - 1)) / (2 * dx * dy);
31+wxy(1, 2:end - 1) = (w(2, 3:end) - w(2, 1:end - 2) - w(1, 3:end) + w(1, 1:end - 2)) / (2 * dx * dy);
32+wxy(end, 2:end - 1) = (w(end, 3:end) - w(end, 1:end - 2) - w(end - 1, 3:end) + w(end - 1, 1:end - 2)) / (2 * dx * dy);
33+end
packages/core/src/m/plate_domain.madded+135−0View file
@@ -0,0 +1,135 @@
1+function dom = plate_domain(plate, nx, ny)
2+% PLATE_DOMAIN The footprint of the plate on a uniform nx x ny grid.
3+%
4+% dom = plate_domain(plate, nx, ny) lays the grid over the bounding box
5+% [0, Lx] x [0, Ly] of plate and marks which nodes belong to the plate.
6+% For plate.shape = 'rectangle' every node does. For 'ellipse' the plate is
7+% the ellipse inscribed in the box, (x - Lx/2)^2 / (Lx/2)^2 +
8+% (y - Ly/2)^2 / (Ly/2)^2 <= 1, a disk when Lx = Ly, and its nodes are those
9+% within half a grid spacing of the inside, so that the staircase boundary
10+% they form straddles the true edge rather than lying to one side of it.
11+% Nodes that do not belong to a grid cell with all four corners inside are
12+% dropped, so every node carries some area. Both finite-difference methods
13+% assemble their operators on this footprint (plate_matrices); the modal
14+% method has closed-form modes only for the rectangle.
15+%
16+% Fields.
17+% shape, nx, ny, dx, dy, x, y, X, Y the grid; w(i, j) lives at (x(j), y(i))
18+% inside ny x nx logical, the plate's nodes
19+% cell (ny-1) x (nx-1) logical, cells with all four corners inside
20+% m ny x nx trapezoidal node weights: adjacent plate cells / 4
21+% (1 inside, 1/2 on a straight edge, 1/4 at a convex corner)
22+% boundary inside nodes with a 4-neighbour outside: the ring where
23+% w = 0 is imposed for supported and clamped edges
24+% interior inside and not boundary
25+% full3 inside nodes whose eight neighbours are inside, where the
26+% curvatures can be taken by central differences
27+% near ny x nx index of the nearest full3 node, for every inside
28+% node (0 outside); plate_curvature_domain copies from it
29+% nunknowns_dirichlet, nunknowns_free sizes of the two unknown sets
30+% kappa, normal_x, normal_y ellipse only: curvature and outward unit
31+% normal of the true edge nearest each boundary node (zero
32+% elsewhere); the simply supported ellipse needs them, see
33+% plate_matrices
34+%
35+% Accuracy. A staircase approximates a curved edge to first order in the
36+% grid spacing: the geometry, the edge conditions and the stress near the
37+% edge all carry O(dx) errors there, against O(dx^2) inside. Refining the
38+% grid is the check.
39+
40+if ~isfield(plate, 'shape')
41+ shape = 'rectangle';
42+else
43+ shape = plate.shape;
44+end
45+if ~any(strcmp(shape, {'rectangle', 'ellipse'}))
46+ error('plate_domain: unknown plate shape "%s" (expected rectangle or ellipse)', shape);
47+end
48+Lx = plate.Lx; Ly = plate.Ly;
49+dx = Lx / (nx - 1);
50+dy = Ly / (ny - 1);
51+x = linspace(0, Lx, nx);
52+y = linspace(0, Ly, ny);
53+[X, Y] = meshgrid(x, y);
54+N = nx * ny;
55+
56+kappa = zeros(ny, nx);
57+normal_x = zeros(ny, nx);
58+normal_y = zeros(ny, nx);
59+if strcmp(shape, 'rectangle')
60+ in0 = true(ny, nx);
61+else
62+ xc = Lx / 2; yc = Ly / 2; a = Lx / 2; b = Ly / 2;
63+ phi = ((X - xc) / a).^2 + ((Y - yc) / b).^2; % 1 on the edge
64+ gx = 2 * (X - xc) / a^2; gy = 2 * (Y - yc) / b^2; % gradient of phi
65+ g = sqrt(gx.^2 + gy.^2);
66+ g(g == 0) = eps; % the centre
67+ d = (phi - 1) ./ g; % signed distance to the edge, first order
68+ hn = 1 ./ sqrt((gx ./ g / dx).^2 + (gy ./ g / dy).^2); % grid spacing along the normal
69+ in0 = d <= 0.5 * hn; % round the edge to the nearest node
70+end
71+cell = in0(1:end - 1, 1:end - 1) & in0(2:end, 1:end - 1) & in0(1:end - 1, 2:end) & in0(2:end, 2:end);
72+cnt = zeros(ny, nx);
73+cnt(1:end - 1, 1:end - 1) = cnt(1:end - 1, 1:end - 1) + cell;
74+cnt(2:end, 1:end - 1) = cnt(2:end, 1:end - 1) + cell;
75+cnt(1:end - 1, 2:end) = cnt(1:end - 1, 2:end) + cell;
76+cnt(2:end, 2:end) = cnt(2:end, 2:end) + cell;
77+inside = cnt > 0;
78+m = cnt / 4;
79+
80+% Neighbourhoods, with everything off the grid counting as outside
81+pad = false(ny + 2, nx + 2);
82+pad(2:end - 1, 2:end - 1) = inside;
83+nb4 = pad(1:end - 2, 2:end - 1) & pad(3:end, 2:end - 1) & pad(2:end - 1, 1:end - 2) & pad(2:end - 1, 3:end);
84+nb8 = nb4 & pad(1:end - 2, 1:end - 2) & pad(1:end - 2, 3:end) & pad(3:end, 1:end - 2) & pad(3:end, 3:end);
85+interior = inside & nb4;
86+boundary = inside & ~nb4;
87+full3 = inside & nb8;
88+if ~any(full3(:))
89+ error('plate_domain: the %d x %d grid is too coarse for this %s; no node has a full stencil inside the plate', nx, ny, shape);
90+end
91+
92+% Nearest node with a full stencil, by flooding outwards from those nodes
93+near = zeros(ny, nx);
94+kf = find(full3);
95+near(kf) = kf;
96+todo = inside & ~full3;
97+for pass = 1:max(nx, ny)
98+ if ~any(todo(:))
99+ break
100+ end
101+ np = zeros(ny + 2, nx + 2);
102+ np(2:end - 1, 2:end - 1) = near;
103+ shifts = [-1 0; 1 0; 0 -1; 0 1];
104+ for k = 1:4
105+ sh = np(2 + shifts(k, 1):end - 1 + shifts(k, 1), 2 + shifts(k, 2):end - 1 + shifts(k, 2));
106+ take = todo & sh > 0;
107+ near(take) = sh(take);
108+ todo(take) = false;
109+ end
110+end
111+
112+% Ellipse: curvature and normal of the true edge at the point nearest each
113+% boundary node (the radial projection of the node onto the ellipse)
114+if strcmp(shape, 'ellipse')
115+ kb = find(boundary);
116+ t = 1 ./ sqrt(max(phi(kb), eps));
117+ xe = xc + (X(kb) - xc) .* t;
118+ ye = yc + (Y(kb) - yc) .* t;
119+ px = 2 * (xe - xc) / a^2; py = 2 * (ye - yc) / b^2; % gradient of phi on the edge
120+ pg = sqrt(px.^2 + py.^2);
121+ normal_x(kb) = px ./ pg;
122+ normal_y(kb) = py ./ pg;
123+ kappa(kb) = ((2 / a^2) * py.^2 + (2 / b^2) * px.^2) ./ pg.^3; % curvature of the level set
124+end
125+
126+dom.shape = shape;
127+dom.nx = nx; dom.ny = ny; dom.dx = dx; dom.dy = dy;
128+dom.x = x; dom.y = y; dom.X = X; dom.Y = Y;
129+dom.inside = inside; dom.cell = cell; dom.m = m;
130+dom.boundary = boundary; dom.interior = interior; dom.full3 = full3; dom.near = near;
131+dom.nunknowns_dirichlet = nnz(interior);
132+dom.nunknowns_free = nnz(inside);
133+dom.kappa = kappa; dom.normal_x = normal_x; dom.normal_y = normal_y;
134+dom.N = N;
135+end
packages/core/src/m/plate_fd.madded+45−0View file
@@ -0,0 +1,45 @@
1+function result = plate_fd(params)
2+% PLATE_FD Explicit finite differences for a Kirchhoff-Love plate in its mount, under gravity.
3+%
4+% result = plate_fd(params) advances the damped plate equation
5+%
6+% mu w_tt + 2 mu sigma0 w_t - 2 mu sigma1 lap(w_t) + D lap(lap(w))
7+% - div(N grad w) = F(t) s(x,y) + mu g_n + support forces
8+%
9+% on a uniform nx x ny grid with a second-order explicit scheme, the spatial
10+% operators being the sparse energy-method matrices of plate_bending (the
11+% 13-point biharmonic stencil inside, the free-edge conditions as natural
12+% conditions on the edges) and plate_prestress (the in-plane stress of the
13+% plate's own weight). The mount (plate_mount) supplies the support forces:
14+% a thread is a transverse spring at its hole, a pad or a felt a one-sided
15+% spring and dashpot, a vise holds a band of nodes still, fingers are a
16+% two-sided spring over a patch. The plate starts from its static
17+% equilibrium (plate_static) and the departure from it is carried as
18+% rigid-body coordinates plus a flexural field (plate_rigid_basis), so that
19+% the pendulum swing of a hanging plate and its flexural vibration come out
20+% of one time loop.
21+%
22+% The step is dt = cfl * dt_max with dt_max from plate_model; the scheme is
23+% unstable for cfl > 1 and the run stops as soon as the field diverges,
24+% reporting the step at which it did (info.diverged_step).
25+%
26+% The work is in three stages, each its own file, so that a host can run the
27+% time loop elsewhere (the lab has a WebGPU port of plate_fd_loop) while the
28+% setup and the output stay this code:
29+% S = plate_fd_setup(params) grid, step, statics, load, probe, listener, operators
30+% Rec = plate_fd_loop(S) the time stepping; records probe, rigid coordinates,
31+% support forces, frames, audio bins
32+% result = plate_fd_output(S, Rec) stresses, audio, spectra, the result struct
33+%
34+% Approximations. Second-order truncation error in dx, dy, dt; free edges
35+% and curved edges first order at the boundary; small-angle theory for the
36+% threads and the pendulum motion; the probe is snapped to the nearest grid
37+% node; stress fields are evaluated only at the saved frames; the audio is
38+% an estimate of the waveform, not a calibrated level.
39+%
40+% See plate_result_schema for the fields of result.
41+
42+S = plate_fd_setup(params);
43+Rec = plate_fd_loop(S);
44+result = plate_fd_output(S, Rec);
45+end
packages/core/src/m/plate_fd_loop.madded+191−0View file
@@ -0,0 +1,191 @@
1+function Rec = plate_fd_loop(S)
2+% PLATE_FD_LOOP The explicit time stepping of plate_fd.
3+%
4+% Rec = plate_fd_loop(S), with S from plate_fd_setup, advances the departure
5+% from the static equilibrium, w = R c + phi, for S.nsteps steps: the rigid-
6+% body coordinates c as nr scalar equations and the flexural field phi on
7+% the grid,
8+%
9+% cacc = Ginv (R' f - Krr c - AR' phi)
10+% cn = c_new (2 c - c_old cm + dt^2 cacc)
11+% acc = (f - KB phi - KG phi - AR c) / M - R cacc
12+% phin = c_new (2 phi - c_old phim + dt^2 acc)
13+%
14+% where f is the nodal force at this step: the strike F(n) sa, the contact
15+% and dashpot forces about rest, and the sigma1 loss 2 sigma1 M Lm (w - wm) / dt
16+% on the backward-difference velocity of the total field w = R c + phi.
17+% c_old = 1 - sigma0 dt and c_new = 1 / (1 + sigma0 dt) carry the
18+% frequency-independent loss. Every force enters both parts through f, so
19+% the sum R cn + phin is exactly the leapfrog step of the whole system and
20+% the losses stay dissipative; the term - R cacc keeps phi free of
21+% rigid-body content, so the two parts stay what they are called. (A
22+% sigma1 loss applied to phi alone is not symmetric on the whole system and
23+% was found to pump the swing of a hanging plate slowly.)
24+% The scheme is second order in space and time (Bilbao, Numerical Sound
25+% Synthesis, 2009, ch. 12).
26+%
27+% Contacts. A bottom contact with compression gb at rest pushes up by
28+% kb (gb - w) while gb - w > 0, a top contact with compression gt pushes
29+% down by kt (gt + w) while gt + w > 0; the static parts are subtracted so
30+% f is zero at rest, and the dashpots act on the backward-difference
31+% velocity while the contact is engaged.
32+%
33+% What the loop records, and returns in Rec. The series are averaged onto
34+% the output samples (S.na of them at S.fs): each step adds its value,
35+% weighted by S.wsamp, to the sample that contains its time, so a sample is
36+% the mean over its steps.
37+% w_p, wxx_p, wyy_p, wxy_p displacement (total departure) and curvatures
38+% (flexural) at the probe node, 1 x na
39+% C rigid-body coordinates, nr x na
40+% Fsup force of each support, nsup x na [N]: the
41+% sum over its nodes of the force of its kind
42+% (spring, bottom contact or top contact),
43+% static part included
44+% Pbins radiated pressure per listener delay bin and
45+% sample, nbins x na
46+% Wf, Cf phi at the frame steps, ny x nx x nf, and c there, nr x nf
47+% diverged_step 0, or the step at which the field passed
48+% S.blowup (the run stops there)
49+% notes, backend, loop_s
50+%
51+% The lab has a WebGPU port of this function (packages/core/src/gpu/fdLoop.ts)
52+% that computes the same record from the same S, in single precision. It
53+% carries phi as (phi, phi - phim) and c likewise, the same arithmetic
54+% rearranged so that the rounding of the field does not feed back into the
55+% velocity; keep the two implementations in step.
56+
57+t_loop = tic;
58+nx = S.nx; ny = S.ny; dx = S.dx; dy = S.dy; dt = S.dt; nsteps = S.nsteps; fs = S.fs; na = S.na;
59+N = S.N; nr = S.nr;
60+KB = S.KB; KG = S.KG; Lm = S.Lm; Mvec = S.Mvec; keep = S.keep;
61+R = S.R; AR = S.AR; Krr = S.Krr; Ginv = S.Ginv;
62+kb = S.kb; cb = S.cb; gb = S.gb; kt = S.kt; ct = S.ct; gt = S.gt; cs = S.cs; ks = S.ks;
63+fstat_s = S.fstat_s; fstat_b = S.fstat_b; fstat_t = S.fstat_t;
64+sa = S.sa; F = S.F; ip = S.ip; jp = S.jp; kp = ip + (jp - 1) * ny;
65+frame_steps = S.frame_steps; nf = S.nf;
66+ln = S.lis_node; lb = S.lis_bin; lw = S.lis_wgt; nbins = S.nbins;
67+sup_ptr = S.sup_ptr; sup_nodes = S.sup_nodes; nsup = S.nsup; sup_kind = S.sup_kind;
68+c_old = S.c_old; c_new = S.c_new; s1f = S.s1f; LR = S.LR; dt2 = S.dt2; blowup = S.blowup;
69+wsamp = S.wsamp;
70+gb0 = max(gb, 0); gt0 = max(gt, 0);
71+hasb = any(kb > 0); hast = any(kt > 0); hascs = any(cs > 0);
72+rmax = max(abs(R), [], 1);
73+
74+% State
75+phi = S.phi0; phim = phi; c = S.c0; cm = c;
76+Lphi_m = Lm * phi;
77+w_p = zeros(1, na); wxx_p = zeros(1, na); wyy_p = zeros(1, na); wxy_p = zeros(1, na);
78+C = zeros(nr, na); Fsup = zeros(nsup, na);
79+Pbins = zeros(nbins, na);
80+Wf = zeros(ny, nx, nf); Cf = zeros(nr, nf);
81+diverged_step = 0;
82+notes = {};
83+kf = 1;
84+
85+for nstep = 1:nsteps
86+ ia = floor((nstep - 1) * dt * fs) + 1;
87+ ws = wsamp(ia);
88+ Lphi = Lm * phi;
89+ if nr > 0
90+ w = R * c + phi;
91+ v = ((phi - phim) + R * (c - cm)) / dt;
92+ else
93+ w = phi;
94+ v = (phi - phim) / dt;
95+ end
96+
97+ % The sigma1 loss on the total velocity, as a nodal force: 2 sigma1 M Lm (w - wm) / dt
98+ dLw = Lphi - Lphi_m;
99+ if nr > 0
100+ dLw = dLw + LR * (c - cm);
101+ end
102+ f = F(nstep) * sa + s1f .* dLw;
103+
104+ % Contact and dashpot forces about rest
105+ fsS = fstat_s - ks .* w; % springs: their static force plus the departure
106+ fsB = fstat_b; fsT = fstat_t;
107+ if hasb
108+ compb = gb - w;
109+ actb = kb > 0 & compb > 0;
110+ fb = kb .* max(compb, 0) - kb .* gb0 - (cb .* v) .* actb;
111+ f = f + fb; fsB = fsB + fb;
112+ end
113+ if hast
114+ compt = gt + w;
115+ actt = kt > 0 & compt > 0;
116+ ft = -kt .* max(compt, 0) + kt .* gt0 - (ct .* v) .* actt;
117+ f = f + ft; fsT = fsT + ft;
118+ end
119+ if hascs
120+ fl = -cs .* v;
121+ f = f + fl; fsS = fsS + fl;
122+ end
123+
124+ % Series of the current state, box-averaged onto the output samples
125+ w_p(ia) = w_p(ia) + w(kp) * ws;
126+ wxx_p(ia) = wxx_p(ia) + (phi(kp + ny) - 2 * phi(kp) + phi(kp - ny)) / dx^2 * ws;
127+ wyy_p(ia) = wyy_p(ia) + (phi(kp + 1) - 2 * phi(kp) + phi(kp - 1)) / dy^2 * ws;
128+ wxy_p(ia) = wxy_p(ia) + (phi(kp + ny + 1) - phi(kp + ny - 1) - phi(kp - ny + 1) + phi(kp - ny - 1)) / (4 * dx * dy) * ws;
129+ if nr > 0
130+ C(:, ia) = C(:, ia) + c * ws;
131+ end
132+ for q = 1:nsup
133+ nd = sup_nodes(sup_ptr(q):sup_ptr(q + 1) - 1);
134+ if sup_kind(q) == 0
135+ fq = fsS(nd);
136+ elseif sup_kind(q) == 1
137+ fq = fsB(nd);
138+ else
139+ fq = fsT(nd);
140+ end
141+ Fsup(q, ia) = Fsup(q, ia) + sum(fq) * ws;
142+ end
143+
144+ % Rigid-body coordinates
145+ if nr > 0
146+ cacc = Ginv * (R' * f - Krr * c - AR' * phi);
147+ cn = c_new * (2 * c - c_old * cm + dt2 * cacc);
148+ acc = (f - KB * phi - KG * phi - AR * c) ./ Mvec - R * cacc;
149+ else
150+ cn = c;
151+ acc = (f - KB * phi - KG * phi) ./ Mvec;
152+ end
153+ phin = c_new * (2 * phi - c_old * phim + dt2 * acc);
154+ phin = phin .* keep;
155+
156+ % Radiated pressure: acceleration of the total field at this step, weighted and delayed per entry
157+ accw = ((phin - phi) - (phi - phim)) / dt2;
158+ if nr > 0
159+ accw = accw + R * (((cn - c) - (c - cm)) / dt2);
160+ end
161+ Pbins(:, ia) = Pbins(:, ia) + accumarray(lb, lw .* accw(ln), [nbins 1]) * ws;
162+
163+ if kf <= nf && nstep == frame_steps(kf)
164+ Wf(:, :, kf) = reshape(phin, ny, nx);
165+ if nr > 0
166+ Cf(:, kf) = cn;
167+ end
168+ kf = kf + 1;
169+ end
170+
171+ peak = max(abs(phin));
172+ if nr > 0
173+ peak = peak + sum(abs(cn(:)') .* rmax);
174+ end
175+ if ~(peak < blowup) % also catches NaN and Inf
176+ diverged_step = nstep;
177+ notes{end + 1} = sprintf('solution diverged at step %d (t = %.3g s); results are truncated there', nstep, nstep * dt);
178+ break
179+ end
180+ phim = phi; phi = phin; cm = c; c = cn; Lphi_m = Lphi;
181+end
182+
183+Rec.w_p = w_p; Rec.wxx_p = wxx_p; Rec.wyy_p = wyy_p; Rec.wxy_p = wxy_p;
184+Rec.C = C; Rec.Fsup = Fsup;
185+Rec.Wf = Wf; Rec.Cf = Cf;
186+Rec.Pbins = Pbins;
187+Rec.diverged_step = diverged_step;
188+Rec.notes = notes;
189+Rec.backend = 'numbl';
190+Rec.loop_s = toc(t_loop);
191+end
packages/core/src/m/plate_fd_output.madded+121−0View file
@@ -0,0 +1,121 @@
1+function result = plate_fd_output(S, Rec)
2+% PLATE_FD_OUTPUT The result struct of plate_fd from the record of its time loop.
3+%
4+% result = plate_fd_output(S, Rec), with S from plate_fd_setup and Rec from
5+% plate_fd_loop (or its WebGPU port), truncates the record after a blow-up,
6+% evaluates the surface stresses at the saved frames and at the probe, sums
7+% the listener's delay bins into the radiated pressure, takes the probe
8+% spectra and assembles the fields listed in plate_result_schema. The
9+% stresses are computed here, in double precision, from the flexural frames
10+% the loop kept, whichever precision the loop ran in; the rigid-body part
11+% has no curvature. Nodes outside the plate are NaN in every field, so
12+% they are drawn blank.
13+
14+P = S.P;
15+dom = S.dom; mnt = S.mnt;
16+nx = S.nx; ny = S.ny; dx = S.dx; dy = S.dy; dt = S.dt; fs = S.fs;
17+nr = S.nr; N = S.N;
18+frame_steps = S.frame_steps;
19+notes = [S.notes, Rec.notes];
20+w_p = Rec.w_p; wxx_p = Rec.wxx_p; wyy_p = Rec.wyy_p; wxy_p = Rec.wxy_p;
21+C = Rec.C; Fsup = Rec.Fsup; Wf = Rec.Wf; Cf = Rec.Cf; Pbins = Rec.Pbins;
22+
23+% Complete output samples only: the last one is partly filled, and after a
24+% blow-up everything past the last finite step is dropped
25+nsteps_done = S.nsteps;
26+if Rec.diverged_step > 0
27+ nsteps_done = Rec.diverged_step - 1;
28+ keepf = frame_steps < Rec.diverged_step;
29+ Wf = Wf(:, :, keepf); Cf = Cf(:, keepf); frame_steps = frame_steps(keepf);
30+end
31+n = floor(nsteps_done * dt * fs);
32+n = max(1, min(n, S.na));
33+t = ((0:n - 1) + 0.5) / fs;
34+w_p = w_p(1:n); wxx_p = wxx_p(1:n); wyy_p = wyy_p(1:n); wxy_p = wxy_p(1:n);
35+C = C(:, 1:n); Fsup = Fsup(:, 1:n);
36+nf = numel(frame_steps);
37+tf = frame_steps * dt;
38+
39+% Total departure at the frames, and the stresses from the flexural frames
40+Wtot = zeros(ny, nx, nf);
41+VMf = zeros(ny, nx, nf); SPf = zeros(ny, nx, nf);
42+rect = strcmp(dom.shape, 'rectangle');
43+for kf = 1:nf
44+ phi = Wf(:, :, kf);
45+ if nr > 0
46+ Wtot(:, :, kf) = phi + reshape(S.R * Cf(:, kf), ny, nx);
47+ else
48+ Wtot(:, :, kf) = phi;
49+ end
50+ if rect
51+ [wxx, wyy, wxy] = plate_curvature_free(phi, dx, dy, P.material.nu);
52+ else
53+ [wxx, wyy, wxy] = plate_curvature_domain(phi, dom);
54+ end
55+ Sf = plate_stress(P, wxx, wyy, wxy);
56+ VMf(:, :, kf) = Sf.vm; SPf(:, :, kf) = Sf.sp;
57+end
58+if isempty(tf)
59+ env_vm = zeros(ny, nx); env_sp = zeros(ny, nx);
60+else
61+ env_vm = max(VMf, [], 3); env_sp = max(abs(SPf), [], 3);
62+end
63+out = ~dom.inside;
64+w0 = reshape(S.w0, ny, nx);
65+for kf = 1:nf
66+ tmp = Wf(:, :, kf); tmp(out) = NaN; Wf(:, :, kf) = tmp;
67+ tmp = Wtot(:, :, kf); tmp(out) = NaN; Wtot(:, :, kf) = tmp;
68+ tmp = VMf(:, :, kf); tmp(out) = NaN; VMf(:, :, kf) = tmp;
69+ tmp = SPf(:, :, kf); tmp(out) = NaN; SPf(:, :, kf) = tmp;
70+end
71+env_vm(out) = NaN; env_sp(out) = NaN; w0(out) = NaN;
72+
73+% Audio: sum the delay bins, then trim to the complete samples
74+na = S.na;
75+p_audio = zeros(1, na);
76+for d = 1:S.nbins
77+ p_audio(d:end) = p_audio(d:end) + Pbins(d, 1:na - d + 1);
78+end
79+p_audio = p_audio(1:n);
80+
81+Sp = plate_stress(P, wxx_p, wyy_p, wxy_p);
82+[fw, Aw] = plate_spectrum(t, w_p);
83+[~, Avm] = plate_spectrum(t, Sp.vm);
84+
85+% Static support forces per support
86+fstat_sup = zeros(1, S.nsup);
87+for q = 1:S.nsup
88+ nd = S.sup_nodes(S.sup_ptr(q):S.sup_ptr(q + 1) - 1);
89+ if S.sup_kind(q) == 0
90+ fstat_sup(q) = sum(S.fstat_s(nd));
91+ elseif S.sup_kind(q) == 1
92+ fstat_sup(q) = sum(S.fstat_b(nd));
93+ else
94+ fstat_sup(q) = sum(S.fstat_t(nd));
95+ end
96+end
97+
98+result.method = 'fd';
99+result.grid = struct('x', S.x, 'y', S.y);
100+result.t = t;
101+result.probe = struct('x', S.x(S.jp), 'y', S.y(S.ip), 'w', w_p, 'sxx', Sp.sxx, 'syy', Sp.syy, 'txy', Sp.txy, 'vm', Sp.vm, 'sp', Sp.sp);
102+result.frames = struct('t', tf, 'w', Wf, 'w_total', Wtot, 'rigid', Cf, 'vm', VMf, 'sp', SPf);
103+result.envelope = struct('vm', env_vm, 'sp', env_sp);
104+result.spectrum = struct('f', fw, 'w', Aw, 'vm', Avm);
105+result.audio = struct('fs', fs, 'p', p_audio, 't0', S.Lis.t0, 'model', S.Lis.model);
106+result.rigid = struct('nr', nr, 'names', {mnt.rigid_names}, 'series', C, ...
107+ 'pivot_x', mnt.pivot(1), 'pivot_y', mnt.pivot(2), 'ed', mnt.ed, 'eh', mnt.eh, ...
108+ 'hz', S.mdl.rigid_hz(:)', 'mode_names', {S.mdl.rigid_mode_names});
109+result.supports = struct('n', S.nsup, 'names', {S.sup_names}, 'units', {S.sup_units}, 'kinds', {S.sup_kinds}, ...
110+ 'series', Fsup, 'static', fstat_sup);
111+result.static = struct('w0', w0, 'sag', S.mdl.st.sag, 'gx', mnt.gx, 'gy', mnt.gy, 'gz', mnt.gz, 'g', P.g, ...
112+ 'mass', mnt.mass, 'xc', mnt.xc, 'yc', mnt.yc, 'held', double(mnt.held));
113+if S.mdl.ps.solved
114+ result.static.Nxx = S.mdl.ps.Nxx; result.static.Nyy = S.mdl.ps.Nyy; result.static.Nxy = S.mdl.ps.Nxy;
115+end
116+result.info = struct('nx', nx, 'ny', ny, 'dx', dx, 'dy', dy, 'dt', dt, 'dt_max', S.mdl.dt_max, 'cfl', P.fd.cfl, ...
117+ 'nsteps', S.nsteps, 'ncells', N, 'nunknowns', S.mdl.nunknowns, 'shape', dom.shape, 'mount', mnt.type, ...
118+ 'cell_steps', N * S.nsteps, 'fs', fs, ...
119+ 'stable', Rec.diverged_step == 0, 'diverged_step', Rec.diverged_step, ...
120+ 'loop', Rec.backend, 'loop_s', Rec.loop_s, 'wall_s', toc(S.t_start), 'notes', {notes});
121+end
packages/core/src/m/plate_fd_plan.madded+46−0View file
@@ -0,0 +1,46 @@
1+function plan = plate_fd_plan(params)
2+% PLATE_FD_PLAN Grid, time step, cost and statics of the explicit scheme, before a run.
3+%
4+% plan = plate_fd_plan(params) assembles the model (plate_model) and reports
5+% plan.nx, plan.ny, plan.dx, plan.dy grid (nodes include the edges of the box)
6+% plan.dt_max largest stable time step [s]
7+% plan.dt step actually used, params.fd.cfl * dt_max
8+% plan.nsteps, plan.ncells, plan.cell_steps problem size (ncells counts every grid node)
9+% plan.nunknowns nodes the scheme actually updates
10+% plan.rigid_hz frequencies of the rigid-body modes the
11+% mount leaves (sway, swing, spin or heave and
12+% tilts), NaN for an unstable one
13+% plan.rigid_names the rigid-body coordinates of the mount
14+% plan.rigid_mode_names the coordinate that dominates each mode
15+% plan.sag largest static deflection under gravity [m]
16+% plan.gx, plan.gy, plan.gz direction of gravity in plate coordinates
17+% plan.mass, plan.xc, plan.yc plate mass and centre of mass
18+% plan.tension thread tensions [N] (empty otherwise)
19+% plan.notes remarks about the mount and the statics
20+%
21+% Because the step scales with dx^2, halving the grid spacing costs eight
22+% times as many cell updates for the same simulated time; and because the
23+% pendulum motion of a hanging plate takes a second or so to show while the
24+% step is a few microseconds, a run is hundreds of thousands of steps.
25+
26+mdl = plate_model(params);
27+plan.nx = mdl.nx; plan.ny = mdl.ny; plan.dx = mdl.dx; plan.dy = mdl.dy;
28+plan.dt_max = mdl.dt_max;
29+plan.dt = mdl.dt;
30+plan.nsteps = mdl.nsteps;
31+plan.ncells = mdl.ncells;
32+plan.nunknowns = mdl.nunknowns;
33+plan.cell_steps = plan.ncells * plan.nsteps;
34+plan.rigid_hz = mdl.rigid_hz(:)';
35+plan.rigid_names = mdl.mnt.rigid_names;
36+plan.rigid_mode_names = mdl.rigid_mode_names;
37+plan.sag = mdl.st.sag;
38+plan.gx = mdl.mnt.gx; plan.gy = mdl.mnt.gy; plan.gz = mdl.mnt.gz;
39+plan.mass = mdl.mnt.mass; plan.xc = mdl.mnt.xc; plan.yc = mdl.mnt.yc;
40+plan.tension = zeros(1, 0);
41+if mdl.ps.solved && any(strcmp(mdl.mnt.type, {'thread', 'threads'}))
42+ plan.tension = mdl.ps.up(mdl.ps.fixed_nodes)';
43+end
44+plan.Om2 = mdl.Om2;
45+plan.notes = mdl.notes;
46+end
packages/core/src/m/plate_fd_setup.madded+236−0View file
@@ -0,0 +1,236 @@
1+function S = plate_fd_setup(params)
2+% PLATE_FD_SETUP Everything the explicit time loop needs, computed once.
3+%
4+% S = plate_fd_setup(params) takes the model (plate_model: operators,
5+% statics, rigid-body basis, time step) and adds what a run needs: the load
6+% footprint and force history, the probe node, the frame schedule, the
7+% listener geometry, the initial state and the constants of the update.
8+% plate_fd_loop advances the plate with S, and plate_fd_output turns what
9+% the loop recorded into the result struct; plate_fd calls the three in
10+% turn. The split lets a host run the loop somewhere else while the setup
11+% and the output stay this code: the lab has a WebGPU port of plate_fd_loop
12+% that reads exactly the fields below and hands its record to plate_fd_output.
13+%
14+% State. The loop carries the departure from the static equilibrium w0 as
15+% rigid-body coordinates c (nr of them, plate_rigid_basis) and a flexural
16+% field phi on the grid; the displacement is w = w0 + R c + phi. A release
17+% (excitation.type = 'release') starts from the static deflection under a
18+% force at the pull point, scaled to the asked displacement there and split
19+% into c0 and phi0; a strike starts from rest.
20+%
21+% Fields the loop reads (indices are 1-based, arrays over all N = nx ny nodes
22+% in column-major order unless said otherwise).
23+% nx, ny, dx, dy, dt, nsteps, fs, na grid, step, count, sample rate and
24+% number of output samples
25+% keep N x 1: 1 at the unknowns, 0 elsewhere
26+% Mvec N x 1 node masses (1 where keep is 0)
27+% KB, KG, Lm sparse N x N: bending, prestress plus
28+% linear springs, Laplacian per node area
29+% Gs, Gscale, WB, WG, WL, ng, inva the same operators in factored form
30+% (see below), for the port
31+% nr, R, Q, AR, Krr, Ginv rigid-body basis (N x nr), projector,
32+% coupling forces, reduced stiffness and
33+% inverse mass (nr x nr)
34+% kb, cb, gb, kt, ct, gt bottom and top contacts: stiffness
35+% [N/m], dashpot [N s/m], compression at
36+% rest [m], per node
37+% cs, ks linear dashpots and springs (the springs
38+% for the force report; they are in KG)
39+% fstat_s, fstat_b, fstat_t static force of the springs, the bottom
40+% and the top contacts per node
41+% sup_ptr, sup_nodes, nsup, sup_kind supports as a CSR list of node indices,
42+% each reporting one kind of force
43+% (0 spring, 1 bottom, 2 top)
44+% sa force per node per unit F(t) [N/N]
45+% F force history at the nsteps + 1 levels
46+% ip, jp probe row and column
47+% frame_steps, nf steps at which the field is kept
48+% lis_node, lis_bin, lis_wgt, nbins listener entries (plate_listener)
49+% c0, phi0 initial state
50+% wsamp na x 1 weight of a step in its sample
51+% c_old, c_new, s1f, LR, dt2 constants of the update: the sigma0 loss,
52+% the sigma1 loss as a nodal force on the
53+% total velocity (with Lm R for the rigid part)
54+% blowup deflection that declares the run unstable
55+% notes, t_start
56+
57+S.t_start = tic;
58+mdl = plate_model(params);
59+P = mdl.P; dom = mdl.dom; mnt = mdl.mnt;
60+nx = mdl.nx; ny = mdl.ny; dx = mdl.dx; dy = mdl.dy; N = mdl.N;
61+dt = mdl.dt; nsteps = mdl.nsteps;
62+X = dom.X; Y = dom.Y; x = dom.x; y = dom.y;
63+unk = mdl.unk;
64+fs = P.output.fs;
65+notes = mdl.notes;
66+
67+% Load on the unknowns: force per node per unit of F(t)
68+s = plate_force_footprint(P.excitation, X, Y, dx * dy, unk);
69+sa = s(:) .* mdl.a(:);
70+tt = (0:nsteps) * dt;
71+F = plate_force_time(P.excitation, tt);
72+
73+% Probe: the nearest node whose eight neighbours belong to the plate
74+d2 = (X - P.probe.x).^2 + (Y - P.probe.y).^2;
75+d2(~dom.full3 | mnt.held) = Inf;
76+[~, kp] = min(d2(:));
77+ip = mod(kp - 1, ny) + 1;
78+jp = floor((kp - 1) / ny) + 1;
79+if abs(x(jp) - P.probe.x) > 0.5 * dx || abs(y(ip) - P.probe.y) > 0.5 * dy
80+ notes{end + 1} = sprintf('probe moved to the nearest interior node (%.4g, %.4g) m', x(jp), y(ip));
81+end
82+
83+% Frames at the steps nearest to nframes equally spaced times in (0, duration]
84+frame_steps = unique(max(1, min(nsteps, round(linspace(0, P.fd.duration, P.output.nframes) / dt))));
85+
86+% Listener, and the output samples: each series value is the mean over the
87+% steps whose time falls in the sample, so the weight of a step is one over
88+% the number of steps its sample contains
89+Lis = plate_listener(P.listener, dom, dx * dy, fs);
90+na = floor(nsteps * dt * fs) + 1;
91+ia_of = floor((0:nsteps - 1) * dt * fs) + 1;
92+cnt = accumarray(ia_of', 1, [na 1]);
93+wsamp = 1 ./ max(cnt, 1);
94+
95+% Initial state
96+nr = mdl.nr;
97+c0 = zeros(nr, 1);
98+phi0 = zeros(N, 1);
99+if strcmp(P.excitation.type, 'release')
100+ e = P.excitation;
101+ sp = plate_force_footprint(e, X, Y, dx * dy, unk);
102+ f = sp(:) .* mdl.a(:);
103+ A = mdl.st.Alin;
104+ if mnt.free_rotation && nr > 0
105+ % the spin about the thread has no stiffness to speak of: take the
106+ % spin moment out of the load and pin the spin at one node
107+ rh = mdl.R(:, 3);
108+ Mr = mdl.Mvec .* rh;
109+ f = f - (rh' * f) / (rh' * Mr) * Mr;
110+ [~, kf] = max(abs(rh));
111+ A = A + sparse(kf, kf, mdl.Mvec(kf) * 1e6, N, N);
112+ notes{end + 1} = 'the pull is applied without twisting the plate about its thread';
113+ end
114+ iu = find(unk(:));
115+ wr = zeros(N, 1);
116+ [Rc, p] = chol(A(iu, iu));
117+ if p > 0
118+ error('plate_fd_setup: no static deflection to release from; the plate is not held stably');
119+ end
120+ wr(iu) = Rc \ (Rc' \ f(iu));
121+ wP = (sp(:) .* mdl.a(:))' * wr; % displacement at the pull point
122+ if ~(abs(wP) > 0)
123+ error('plate_fd_setup: the pull point does not move; is it on a held part of the plate?');
124+ end
125+ wr = wr * (e.displacement / wP);
126+ if nr > 0
127+ c0 = mdl.Q' * wr;
128+ if mnt.free_rotation
129+ c0(3) = 0;
130+ end
131+ phi0 = wr - mdl.R * c0;
132+ else
133+ phi0 = wr;
134+ end
135+ phi0 = phi0 .* double(unk(:));
136+ notes{end + 1} = sprintf('released from a static pull of %.3g mm at (%.4g, %.4g) m, which needed %.3g N', e.displacement * 1e3, e.x, e.y, e.displacement / wP);
137+end
138+
139+% The operators in factored form, for the WebGPU port. Every operator of the
140+% loop is a sum of terms G_q' W G_q with G_q a difference stencil, and the
141+% port applies them that way: strains e = G phi first (each an exact integer
142+% stencil times a scale), then the weights, then G' back to the nodes. The
143+% assembled matrices lose their smooth modes in single precision (the 13-point
144+% biharmonic of a smooth field is the small residual of large cancelling
145+% terms, and the rounding of the field alone is a few percent of it at low
146+% wavenumbers); the factored form keeps them to about 1e-5. Gs stacks
147+% Dxx, Dyy, Dxc, Dyc, Dxy, Bx, By, Bxc, Byc; Gscale is the scale of each row
148+% (the stencil divided by it is integer); WB, WG, WL are the symmetric weight
149+% matrices with KB = Gs' WB Gs, KG - Ks = Gs' WG Gs and Lm = diag(1/a) Gs' WL Gs
150+% on the unknowns.
151+ops = mdl.ops;
152+ni = ops.n; idx = ops.idx;
153+Gu = [ops.Dxx; ops.Dyy; ops.Dxc; ops.Dyc; ops.Dxy; ops.Bx; ops.By; ops.Bxc; ops.Byc];
154+nrow = [size(ops.Dxx, 1), size(ops.Dyy, 1), size(ops.Dxc, 1), size(ops.Dyc, 1), size(ops.Dxy, 1), ...
155+ size(ops.Bx, 1), size(ops.By, 1), size(ops.Bxc, 1), size(ops.Byc, 1)];
156+scl = [1 / dx^2, 1 / dy^2, 1 / dx^2, 1 / dy^2, 1 / (dx * dy), 1 / dx, 1 / dy, 1 / (2 * dx), 1 / (2 * dy)];
157+Gscale = zeros(sum(nrow), 1);
158+off = cumsum([0 nrow]);
159+for q = 1:9
160+ Gscale(off(q) + 1:off(q + 1)) = scl(q);
161+end
162+ng = sum(nrow);
163+[gi, gj, gv] = find(Gu);
164+Gs = sparse(gi, idx(gj), gv, ng, N);
165+D = ops.D; nu = ops.nu; area = ops.area;
166+o = off;
167+WB = sparse(ng, ng);
168+WB = WB + sparse(o(1) + (1:nrow(1)), o(1) + (1:nrow(1)), D * ops.mkx, ng, ng);
169+WB = WB + sparse(o(2) + (1:nrow(2)), o(2) + (1:nrow(2)), D * ops.mky, ng, ng);
170+WB = WB + sparse(o(3) + (1:nrow(3)), o(4) + (1:nrow(4)), D * nu * ops.mkc, ng, ng);
171+WB = WB + sparse(o(4) + (1:nrow(4)), o(3) + (1:nrow(3)), D * nu * ops.mkc, ng, ng);
172+WB = WB + sparse(o(5) + (1:nrow(5)), o(5) + (1:nrow(5)), D * 2 * (1 - nu) * area * ones(nrow(5), 1), ng, ng);
173+WG = sparse(ng, ng);
174+if mdl.ps.solved
175+ WG = WG + sparse(o(6) + (1:nrow(6)), o(6) + (1:nrow(6)), mdl.ps.nxx, ng, ng);
176+ WG = WG + sparse(o(7) + (1:nrow(7)), o(7) + (1:nrow(7)), mdl.ps.nyy, ng, ng);
177+ WG = WG + sparse(o(8) + (1:nrow(8)), o(9) + (1:nrow(9)), mdl.ps.nxy, ng, ng);
178+ WG = WG + sparse(o(9) + (1:nrow(9)), o(8) + (1:nrow(8)), mdl.ps.nxy, ng, ng);
179+end
180+WL = sparse(o(6) + (1:nrow(6)), o(6) + (1:nrow(6)), -ops.wx, ng, ng) + sparse(o(7) + (1:nrow(7)), o(7) + (1:nrow(7)), -ops.wy, ng, ng);
181+
182+% Supports as a CSR list
183+sup = mnt.supports;
184+nsup = numel(sup);
185+sup_ptr = ones(nsup + 1, 1);
186+sup_nodes = zeros(0, 1);
187+for k = 1:nsup
188+ nd = sup(k).nodes(:);
189+ sup_nodes = [sup_nodes; nd];
190+ sup_ptr(k + 1) = sup_ptr(k) + numel(nd);
191+end
192+sup_names = cell(1, nsup); sup_units = cell(1, nsup); sup_kinds = cell(1, nsup);
193+sup_kind = zeros(nsup, 1); % 0 spring, 1 bottom contact, 2 top contact
194+for k = 1:nsup
195+ sup_names{k} = sup(k).name; sup_units{k} = sup(k).unit; sup_kinds{k} = sup(k).kind;
196+ switch sup(k).kind
197+ case 'spring'
198+ sup_kind(k) = 0;
199+ case 'bottom'
200+ sup_kind(k) = 1;
201+ case 'top'
202+ sup_kind(k) = 2;
203+ otherwise
204+ error('plate_fd_setup: unknown support kind "%s"', sup(k).kind);
205+ end
206+end
207+
208+S.mdl = mdl; S.P = P; S.dom = dom; S.mnt = mnt;
209+S.nx = nx; S.ny = ny; S.dx = dx; S.dy = dy; S.x = x; S.y = y; S.X = X; S.Y = Y; S.N = N;
210+S.dt = dt; S.nsteps = nsteps; S.tt = tt; S.F = F; S.fs = fs; S.na = na; S.wsamp = wsamp;
211+S.keep = double(unk(:));
212+S.Mvec = mdl.Mvec; S.a = mdl.a(:);
213+S.KB = mdl.Kb; S.KG = mdl.Kg + mdl.Ks; S.Lm = mdl.Lm;
214+S.Gs = Gs; S.Gscale = Gscale; S.WB = WB; S.WG = WG; S.WL = WL; S.ng = ng;
215+S.inva = double(unk(:)) ./ max(mdl.a(:), eps);
216+S.nr = nr; S.R = mdl.R; S.Q = mdl.Q; S.AR = mdl.AR; S.Krr = mdl.Krr; S.Ginv = mdl.Ginv;
217+S.kb = mnt.kb(:); S.cb = mnt.cb(:); S.gb = mdl.st.gb;
218+S.kt = mnt.kt(:); S.ct = mnt.ct(:); S.gt = mdl.st.gt;
219+S.cs = mnt.cs(:); S.ks = mnt.ks(:);
220+S.fstat_s = mdl.st.fstat_s; S.fstat_b = mdl.st.fstat_b; S.fstat_t = mdl.st.fstat_t;
221+S.w0 = mdl.st.w0;
222+S.sup_ptr = sup_ptr; S.sup_nodes = sup_nodes; S.nsup = nsup; S.sup_kind = sup_kind;
223+S.sup_names = sup_names; S.sup_units = sup_units; S.sup_kinds = sup_kinds;
224+S.sa = sa; S.s = s;
225+S.ip = ip; S.jp = jp;
226+S.frame_steps = frame_steps; S.nf = numel(frame_steps);
227+S.Lis = Lis; S.lis_node = Lis.node; S.lis_bin = Lis.delay + 1; S.lis_wgt = Lis.weight; S.nbins = Lis.nbins;
228+S.c0 = c0; S.phi0 = phi0;
229+S.c_old = 1 - P.damping.sigma0 * dt;
230+S.c_new = 1 / (1 + P.damping.sigma0 * dt);
231+S.s1f = 2 * P.damping.sigma1 * mdl.Mvec / dt; % the sigma1 loss as a nodal force per unit Lm(w - wm)
232+S.LR = mdl.Lm * mdl.R; % Laplacian per node of the rigid basis, N x nr
233+S.dt2 = dt^2;
234+S.blowup = 1e3 * P.plate.h;
235+S.notes = notes;
236+end
packages/core/src/m/plate_force_footprint.madded+31−0View file
@@ -0,0 +1,31 @@
1+function s = plate_force_footprint(exc, X, Y, dA, mask)
2+% PLATE_FORCE_FOOTPRINT Spatial distribution s(x,y) [1/m^2] of the applied force.
3+%
4+% s = plate_force_footprint(exc, X, Y, dA) evaluates a raised-cosine bump of
5+% radius exc.radius centred on the strike point (exc.x, exc.y) at the grid
6+% points X, Y (each with area dA) and scales it so that sum(s(:)) * dA = 1.
7+% The pressure is then f = F(t) s(x,y) and F(t) is the total force.
8+%
9+% s = plate_force_footprint(exc, X, Y, dA, mask) keeps only the points where
10+% the logical mask is true (the nodes of the plate that carry the load) and
11+% normalizes over those, so a strike near a curved edge or a supported edge
12+% still delivers the whole force.
13+%
14+% A finite footprint stands in for the contact patch of a hammer. It also
15+% keeps the stress finite: a true point load gives a logarithmic stress
16+% singularity in thin-plate theory, so the stress at the strike point would
17+% depend only on how finely the load is resolved.
18+
19+r = sqrt((X - exc.x).^2 + (Y - exc.y).^2);
20+s = zeros(size(X));
21+in = r < exc.radius;
22+if nargin >= 5
23+ in = in & mask;
24+end
25+s(in) = 0.5 * (1 + cos(pi * r(in) / exc.radius));
26+total = sum(s(:)) * dA;
27+if total <= 0
28+ error('plate_force_footprint: no grid point of the plate lies inside the footprint (radius %g m); enlarge the radius, refine the grid or move the strike inside the plate', exc.radius);
29+end
30+s = s / total;
31+end
packages/core/src/m/plate_force_time.madded+23−0View file
@@ -0,0 +1,23 @@
1+function F = plate_force_time(exc, t)
2+% PLATE_FORCE_TIME Total applied force F(t) [N] sampled at the times t [s].
3+%
4+% The applied pressure is f(x,y,t) = F(t) s(x,y), where the footprint s
5+% integrates to one over the plate (see plate_force_footprint), so F(t) is
6+% the total force on the plate at time t.
7+%
8+% exc.type = 'impulse' half-sine hammer pulse: peak force exc.force [N]
9+% over the contact time exc.duration [s]
10+% exc.type = 'release' no force: the plate starts from a static deflection
11+% (plate_static) and is let go
12+
13+switch exc.type
14+ case 'impulse'
15+ F = zeros(size(t));
16+ in = (t >= 0) & (t < exc.duration);
17+ F(in) = exc.force * sin(pi * t(in) / exc.duration);
18+ case 'release'
19+ F = zeros(size(t));
20+ otherwise
21+ error('plate_force_time: unknown excitation type "%s" (expected impulse or release)', exc.type);
22+end
23+end
packages/core/src/m/plate_listener.madded+86−0View file
@@ -0,0 +1,86 @@
1+function L = plate_listener(lis, dom, dA, fs)
2+% PLATE_LISTENER Geometry of the sound radiated from the plate to a listener.
3+%
4+% L = plate_listener(lis, dom, dA, fs) prepares the radiation calculation
5+% for surface patches centred at the grid nodes of dom with area dA and a
6+% listener at (lis.x, lis.y, lis.z) in the plate's coordinates, z along the
7+% normal, with sound speed lis.c [m/s] and air density lis.rho_air [kg/m^3].
8+% The pressure is a sum over contributions, each the acceleration of one
9+% node scaled by a weight and delayed by a travel time:
10+%
11+% p(t) = sum_k L.weight(k) * w_tt( node L.node(k), t - delay_k )
12+%
13+% Delays are rounded to whole samples at the audio rate fs and measured
14+% from the shortest path; the common travel time L.t0 is dropped.
15+%
16+% lis.model = 'baffled'. Rayleigh's first integral for a flat radiator set
17+% in an infinite rigid baffle,
18+%
19+% p(r, t) = rho_air / (2 pi) * integral w_tt(rs, t - |r - rs| / c) / |r - rs| dA(rs),
20+%
21+% one contribution per node. Right when the plate sits in a large table or
22+% wall; for a plate on a bench it is an upper estimate.
23+%
24+% lis.model = 'unbaffled'. A plate hanging in free air radiates from both
25+% faces with opposite signs, and at wavelengths longer than the plate the
26+% two cancel: the low end of the baffled estimate is not there. The lab
27+% estimates this with two contributions per node: the front face as above,
28+% and the back face with the opposite sign and its path taken around the
29+% plate, from the node to the nearest point of the edge and from there to
30+% the listener (L.node repeats each node; the second set carries negative
31+% weights). The cancellation is complete only in the limit of zero
32+% frequency; at high frequency, where the true diffracted field is weak,
33+% the two add incoherently and the estimate is a little loud. It is a
34+% rough model of the front-back cancellation, not a solution of the
35+% diffraction problem.
36+%
37+% Validity: neither model accounts for the room or for anything near the
38+% plate. The result is an estimate of the radiated waveform, not a
39+% calibrated level.
40+
41+X = dom.X; Y = dom.Y;
42+N = numel(X);
43+inside = dom.inside(:);
44+Rf = sqrt((X(:) - lis.x).^2 + (Y(:) - lis.y).^2 + lis.z^2);
45+if ~isfield(lis, 'model')
46+ lis.model = 'baffled';
47+end
48+switch lis.model
49+ case 'baffled'
50+ node = (1:N)';
51+ path = Rf;
52+ weight = lis.rho_air / (2 * pi) * dA ./ Rf;
53+ case 'unbaffled'
54+ % nearest point of the edge to each node
55+ if strcmp(dom.shape, 'ellipse')
56+ a = dom.x(end) / 2; b = dom.y(end) / 2;
57+ px = X(:) - a; py = Y(:) - b;
58+ t = sqrt((px / a).^2 + (py / b).^2);
59+ t(t == 0) = eps;
60+ Ex = a + px ./ t; Ey = b + py ./ t; % radial projection onto the ellipse
61+ else
62+ Lx = dom.x(end); Ly = dom.y(end);
63+ dl = X(:); dr = Lx - X(:); dd = Y(:); du = Ly - Y(:);
64+ [~, side] = min([dl dr dd du], [], 2);
65+ Ex = X(:); Ey = Y(:);
66+ Ex(side == 1) = 0; Ex(side == 2) = Lx;
67+ Ey(side == 3) = 0; Ey(side == 4) = Ly;
68+ end
69+ dedge = sqrt((X(:) - Ex).^2 + (Y(:) - Ey).^2);
70+ Rb = dedge + sqrt((Ex - lis.x).^2 + (Ey - lis.y).^2 + lis.z^2);
71+ node = [(1:N)'; (1:N)'];
72+ path = [Rf; Rb];
73+ weight = lis.rho_air / (2 * pi) * dA * [1 ./ Rf; -1 ./ Rb];
74+ otherwise
75+ error('plate_listener: unknown model "%s" (expected baffled or unbaffled)', lis.model);
76+end
77+keep = inside(node);
78+node = node(keep); path = path(keep); weight = weight(keep);
79+L.model = lis.model;
80+L.node = node;
81+L.weight = weight; % Pa per (m/s^2) of node acceleration
82+L.t0 = min(path) / lis.c; % common travel time removed
83+L.delay = round((path - min(path)) * fs / lis.c); % whole samples
84+L.nbins = max(L.delay) + 1;
85+L.nentries = numel(node);
86+end
packages/core/src/m/plate_model.madded+151−0View file
@@ -0,0 +1,151 @@
1+function mdl = plate_model(params)
2+% PLATE_MODEL The discrete plate in its mount: operators, statics and time step.
3+%
4+% mdl = plate_model(params) assembles everything about the problem that
5+% does not depend on the excitation: the grid (plate_domain), the mount
6+% (plate_mount), the bending and Laplacian operators (plate_bending), the
7+% self-weight prestress (plate_prestress), the rigid-body basis
8+% (plate_rigid_basis), the static equilibrium (plate_static), the reduced
9+% rigid-body system and the stable time step. plate_fd_plan reports its
10+% numbers before a run and plate_fd_setup builds the run on it.
11+%
12+% The semi-discrete equation on the unknown nodes is
13+%
14+% M w_tt + 2 sigma0 M w_t - 2 sigma1 mu Le w_t + A0 w + f_contact(w, w_t) = F(t) s + M g_n
15+% A0 = Kb + Kg + Ks
16+%
17+% with M the node masses, Kb the bending stiffness, Kg the prestress
18+% operator, Ks the linear springs of the mount (threads, fingers), Le the
19+% Laplacian energy operator, and f_contact the one-sided pads and felts
20+% with their dashpots. Writing w = w0 + R c + phi, with w0 the static
21+% deflection, R the rigid-body basis and c its coordinates, and phi the
22+% flexural remainder (mass-orthogonal to R), the equation splits into
23+%
24+% G (c_tt + 2 sigma0 c_t) = R' f - Krr c - AR' phi, G = R' M R,
25+% phi_tt + 2 sigma0 phi_t = M^-1 (f - A0 phi - AR c) - R (c_tt + 2 sigma0 c_t),
26+%
27+% where f collects the applied and contact forces about rest and the sigma1
28+% loss 2 sigma1 mu Le w_t of the total velocity (so that it enters both
29+% parts and stays dissipative), AR = A0 R is the force of a unit rigid
30+% displacement (with the thread's torsion added as a force on the spin) and
31+% Krr = R' AR the reduced stiffness of the rigid motions (the pendulum
32+% stiffness, the thread springs, the torsion). Since Kb R = 0, AR is
33+% computed from Kg + Ks alone; both are exact to rounding. The sum
34+% R c + phi then obeys exactly the leapfrog of the whole system, and the
35+% rigid content of phi, Q' phi, stays zero: every force acts on the whole
36+% plate, and the rigid equations are its projection. (Leaving AR c out of
37+% the flexural equation, an early version of this code, made the two parts
38+% pump each other and the swing of a hung plate grow without bound.)
39+%
40+% Stable time step. For the bending operator alone the von Neumann bound of
41+% the explicit scheme is dt <= 2 / (lambda (sigma1 + sqrt(kappa^2 + sigma1^2)))
42+% with lambda = 4/dx^2 + 4/dy^2 the largest eigenvalue of the five-point
43+% Laplacian. The extra stiffness of the prestress, the springs and the
44+% engaged contacts adds a rate Omega^2 bounded by Gershgorin's theorem on
45+% M^-1 (Kg + Ks + K_contact), and the bound becomes
46+%
47+% dt <= ( -2 sigma1 lambda + sqrt(4 sigma1^2 lambda^2 + 4 (kappa^2 lambda^2 + Omega^2)) )
48+% / (kappa^2 lambda^2 + Omega^2),
49+%
50+% which reduces to the first form when Omega = 0. The dashpots are treated
51+% explicitly and need dt < 2 M / c; the plan keeps dt below half of that.
52+% The largest eigenvalue of the energy-method bending operator lies just
53+% below the interior bound (about 0.97 to 0.99 of it), so the step is safe
54+% and slightly conservative.
55+%
56+% Fields: P, dom, mnt, unk, Mvec, Kb, Le, Lm, Kg, Ks, A0, R, Q, G, Ginv,
57+% Krr, AR, ps (prestress solution), st (statics), rigid_hz (frequencies of
58+% the rigid-body modes, ascending, NaN for an unstable one) and
59+% rigid_mode_names (the coordinate that dominates each), lam_max, Om2, dt_max, dt,
60+% nsteps, ncells, nunknowns, notes.
61+
62+P = plate_setup(params);
63+nx = P.fd.nx; ny = P.fd.ny;
64+if nx < 5 || ny < 5
65+ error('plate_model: the grid needs at least 5 nodes in each direction');
66+end
67+dom = plate_domain(P.plate, nx, ny);
68+dx = dom.dx; dy = dom.dy;
69+N = nx * ny;
70+mnt = plate_mount(P, dom);
71+unk = dom.inside & ~mnt.held;
72+a = dom.m * dx * dy;
73+Mvec = P.mu * a(:);
74+Mvec(~unk(:)) = 1; % dummy mass at nodes outside the system
75+[Kb, Le, ops] = plate_bending(dom, mnt.held, P.material.nu, P.D);
76+[Kg, ps] = plate_prestress(P, dom, mnt, ops);
77+ksv = mnt.ks(:) .* double(unk(:));
78+Ks = spdiags(ksv, 0, N, N);
79+A0 = Kb + Kg + Ks;
80+Lm = spdiags(1 ./ max(a(:), eps) .* double(unk(:)), 0, N, N) * Le;
81+[R, Q, G] = plate_rigid_basis(dom, mnt, Mvec, unk);
82+st = plate_static(P, dom, mnt, A0, Mvec, unk);
83+nr = mnt.nr;
84+notes = [mnt.notes, st.notes];
85+
86+% Reduced rigid-body system: bending annihilates R, so only Kg and Ks act on it
87+if nr > 0
88+ AR = (Kg + Ks) * R;
89+ if mnt.ktor > 0
90+ % the thread's torsion as a force on the plate: -ktor c3 M r_h / (r_h' M r_h),
91+ % which changes only the spin coordinate, so that Krr = R' AR stays exact
92+ AR(:, 3) = AR(:, 3) + mnt.ktor * (Mvec .* R(:, 3)) / G(3, 3);
93+ end
94+ Krr = R' * AR;
95+ Krr = (Krr + Krr') / 2;
96+ Ginv = inv(G);
97+ % modes of the reduced system in the mass-normalized coordinates, so that
98+ % the dominant coordinate of each is meaningful across metres and radians
99+ Rg = chol(G); % G = Rg' Rg
100+ Bm = (Rg' \ Krr) / Rg;
101+ [V, Dm] = eig((Bm + Bm') / 2);
102+ lam = real(diag(Dm));
103+ [lam, order] = sort(lam);
104+ V = real(V(:, order));
105+ rigid_hz = sqrt(max(lam, 0)) / (2 * pi);
106+ rigid_hz(lam < -1e-9 * max(abs(lam))) = NaN;
107+ rigid_mode_names = cell(1, nr);
108+ for k = 1:nr
109+ [~, kd] = max(abs(V(:, k)));
110+ rigid_mode_names{k} = mnt.rigid_names{kd};
111+ end
112+ if any(isnan(rigid_hz))
113+ notes{end + 1} = 'a rigid-body motion has negative stiffness: the plate is in an unstable position and the run will diverge';
114+ end
115+else
116+ AR = zeros(N, 0); Krr = zeros(0, 0); Ginv = zeros(0, 0); rigid_hz = zeros(0, 1); rigid_mode_names = {};
117+end
118+
119+% Stable time step
120+lam_max = 4 / dx^2 + 4 / dy^2;
121+kc = mnt.kb(:) .* double(st.actb) + mnt.kt(:) .* double(st.actt);
122+Kx = abs(Kg) + spdiags(abs(ksv) + kc, 0, N, N);
123+row = sum(Kx, 2);
124+Om2 = max(row(unk(:)) ./ Mvec(unk(:)));
125+if isempty(Om2)
126+ Om2 = 0;
127+end
128+s1 = P.damping.sigma1;
129+om2b = P.kappa^2 * lam_max^2 + Om2;
130+dt_max = (-2 * s1 * lam_max + sqrt(4 * s1^2 * lam_max^2 + 4 * om2b)) / om2b;
131+cv = (mnt.cb(:) + mnt.ct(:) + mnt.cs(:)) ./ Mvec;
132+cmax = max(cv(unk(:)));
133+if cmax > 0 && dt_max > 0.5 / cmax
134+ dt_max = 0.5 / cmax;
135+ notes{end + 1} = 'the support damping, not the bending, limits the time step';
136+end
137+dt = P.fd.cfl * dt_max;
138+nsteps = ceil(P.fd.duration / dt);
139+
140+mdl.P = P; mdl.dom = dom; mdl.mnt = mnt; mdl.unk = unk;
141+mdl.nx = nx; mdl.ny = ny; mdl.dx = dx; mdl.dy = dy; mdl.N = N;
142+mdl.a = a; mdl.Mvec = Mvec;
143+mdl.Kb = Kb; mdl.Le = Le; mdl.Lm = Lm; mdl.Kg = Kg; mdl.Ks = Ks; mdl.A0 = A0;
144+mdl.R = R; mdl.Q = Q; mdl.G = G; mdl.Ginv = Ginv; mdl.Krr = Krr; mdl.AR = AR; mdl.nr = nr;
145+mdl.ps = ps; mdl.st = st; mdl.ops = ops;
146+mdl.rigid_hz = rigid_hz; mdl.rigid_mode_names = rigid_mode_names;
147+mdl.lam_max = lam_max; mdl.Om2 = Om2;
148+mdl.dt_max = dt_max; mdl.dt = dt; mdl.nsteps = nsteps;
149+mdl.ncells = N; mdl.nunknowns = nnz(unk);
150+mdl.notes = notes;
151+end
packages/core/src/m/plate_mount.madded+294−0View file
@@ -0,0 +1,294 @@
1+function mnt = plate_mount(P, dom)
2+% PLATE_MOUNT How the plate is held, as data on the grid.
3+%
4+% mnt = plate_mount(P, dom), with P from plate_setup and dom from
5+% plate_domain, turns params.mount into what the solvers need: the
6+% direction of gravity in the plate's coordinates, the nodes the mount holds
7+% still, the springs, dashpots and one-sided contacts it puts under the
8+% plate, the in-plane constraints for the self-weight stress solve
9+% (plate_prestress), and the rigid-body basis the results are decomposed in
10+% (plate_rigid_basis). Every mount is something that can be built on a
11+% bench, and each carries an idealization that is stated in mnt.notes.
12+%
13+% Gravity. The plate lies in the (x, y) plane and w is along its normal z.
14+% mnt.gx, mnt.gy, mnt.gz is the unit vector along which gravity pulls, in
15+% these coordinates, and P.g its magnitude. The in-plane part (gx, gy) puts
16+% the plate under its own weight in tension or compression (plate_prestress);
17+% the normal part gz loads it transversely (plate_static).
18+%
19+% Mounts.
20+%
21+% 'thread' One thread through a hole at (x, y), of length L, from a fixed
22+% hook. A plate hung from one point turns in its own plane until
23+% its centre of mass is straight below the hole, which fixes
24+% (gx, gy). For small out-of-plane motion the thread is a
25+% transverse spring of stiffness T / L at the hole, T = m g its
26+% tension (the linearized pendulum), and a torsional spring
27+% G J / L about the vertical axis with J = pi d^4 / 32 from the
28+% thread's diameter d and shear modulus G (mount.thread).
29+% 'threads' Two threads of the same length L from hooks at the same
30+% height, through holes 1 and 2. The line of the holes is then
31+% horizontal, and the tensions come from statics: the plate is
32+% refused when its centre of mass is not between the holes,
33+% because one thread would go slack.
34+% 'pads' A horizontal plate resting on pads (foam, felt or a knife
35+% edge): each pad is a rectangle of half-widths (rx, ry) about
36+% (x, y), with a stiffness k [N/m^3] and damping c [N s/m^3] per
37+% unit area. A pad pushes only while compressed, so the plate
38+% can lift off and land again.
39+% 'cymbal' A horizontal plate on a stand: a rod of radius r_rod through
40+% the centre (no transverse effect) between two felt washers of
41+% outer radius r_felt, each of stiffness k and damping c per
42+% unit area and both compressed by the wing nut's preload
43+% [m]. Each felt is a one-sided contact.
44+% 'vise' One edge gripped in a vise to a depth [m]: the nodes within
45+% the jaws are held at w = 0 and the clamped condition follows
46+% from the bending energy (plate_bending). The angle tilt [deg]
47+% sets the orientation: 0 hangs straight down from the jaw, 90 is
48+% a horizontal cantilever, 180 stands up on the jaw with the
49+% self-weight in compression.
50+% 'pinched' Held between finger and thumb over a patch of radius r about
51+% (x, y): a two-sided spring k [N/m] and dashpot c [N s/m] spread
52+% over the patch, the patch also fixed in the plane. tilt [deg]
53+% runs from 0 (hanging below the hand) to 90 (held flat).
54+%
55+% Fields.
56+% type the mount
57+% gx, gy, gz unit direction of gravity in plate coordinates
58+% held ny x nx logical: nodes held at w = 0 (vise)
59+% ks, cs ny x nx: linear transverse spring [N/m] and
60+% dashpot [N s/m] at each node (threads, pinch)
61+% kb, cb, ob bottom contacts: stiffness [N/m], dashpot
62+% [N s/m] and surface offset [m] per node; the
63+% contact pushes up while ob - w > 0
64+% kt, ct, ot top contacts: push down while ot + w > 0
65+% ktor torsional stiffness about the vertical axis
66+% through the pivot [N m/rad] (one thread)
67+% fix in-plane constraints for plate_prestress: rows
68+% [node, dir_x, dir_y] meaning the displacement
69+% of node along (dir_x, dir_y) is zero
70+% free_rotation the in-plane rotation about the first fixed
71+% node is free (one thread) and must be
72+% regularized, which needs the load to have no
73+% moment about it
74+% pivot [x y] the rigid-body basis is taken about
75+% ed, eh in-plane unit vectors: down and horizontal
76+% nr number of rigid-body coordinates (0 or 3)
77+% rigid_names {1 x nr} names of the coordinates
78+% supports struct array: name, unit, nodes (index list),
79+% kind ('spring', 'bottom' or 'top': which force
80+% the series reports at those nodes)
81+% mass, xc, yc plate mass [kg] and centre of mass, from the
82+% node weights
83+% notes remarks about the idealization
84+
85+mount = P.mount;
86+nx = dom.nx; ny = dom.ny; dx = dom.dx; dy = dom.dy;
87+X = dom.X; Y = dom.Y;
88+inside = dom.inside;
89+a = dom.m * dx * dy; % node areas [m^2]
90+Mi = P.mu * a; % node masses [kg]
91+mass = sum(Mi(:));
92+xc = sum(Mi(:) .* X(:)) / mass;
93+yc = sum(Mi(:) .* Y(:)) / mass;
94+zero = zeros(ny, nx);
95+mnt.type = mount.type;
96+mnt.held = false(ny, nx);
97+mnt.ks = zero; mnt.cs = zero;
98+mnt.kb = zero; mnt.cb = zero; mnt.ob = zero;
99+mnt.kt = zero; mnt.ct = zero; mnt.ot = zero;
100+mnt.ktor = 0;
101+mnt.fix = zeros(0, 3);
102+mnt.free_rotation = false;
103+mnt.mass = mass; mnt.xc = xc; mnt.yc = yc;
104+supports = struct('name', {}, 'unit', {}, 'nodes', {}, 'kind', {});
105+notes = {};
106+
107+switch mount.type
108+ case 'thread'
109+ kA = nearest_node([mount.x mount.y], dom);
110+ A = [X(kA) Y(kA)]; % the plate hangs from the node the hole snaps to
111+ d = [xc yc] - A;
112+ if norm(d) < 1e-3 * max(P.plate.Lx, P.plate.Ly)
113+ error('plate_mount: the hole is at the centre of mass; a plate hung there has no preferred orientation');
114+ end
115+ ed = d / norm(d);
116+ eh = [-ed(2) ed(1)];
117+ mnt.gx = ed(1); mnt.gy = ed(2); mnt.gz = 0;
118+ T = mass * P.g;
119+ mnt.ks(kA) = T / mount.length;
120+ mnt.fix = [kA 1 0; kA 0 1];
121+ mnt.free_rotation = true;
122+ mnt.ktor = torsion(mount);
123+ mnt.pivot = [X(kA) Y(kA)];
124+ mnt.ed = ed; mnt.eh = eh;
125+ mnt.nr = 3;
126+ mnt.rigid_names = {'sway of the hole', 'swing about the hole', 'spin about the thread'};
127+ supports(end + 1) = struct('name', 'thread', 'unit', 'N', 'nodes', kA, 'kind', 'spring');
128+ ang = atan2(ed(1), -ed(2)) * 180 / pi;
129+ notes{end + 1} = sprintf('hung from the node (%.4g, %.4g) m; the plate turns %.3g degrees in its plane so the centre of mass hangs below the hole; tension %.3g N gives a transverse spring of %.3g N/m at the hole', X(kA), Y(kA), ang, T, T / mount.length);
130+ if mnt.ktor > 0
131+ notes{end + 1} = sprintf('spin about the thread is restrained only by its torsion, %.3g N m/rad', mnt.ktor);
132+ else
133+ notes{end + 1} = 'spin about the thread is free: nothing but the sigma0 loss slows it';
134+ end
135+ case 'threads'
136+ A1 = [mount.x1 mount.y1]; A2 = [mount.x2 mount.y2];
137+ k1 = nearest_node(A1, dom); k2 = nearest_node(A2, dom);
138+ if k1 == k2
139+ error('plate_mount: the two holes fall on the same grid node; move them apart or refine the grid');
140+ end
141+ A1 = [X(k1) Y(k1)]; A2 = [X(k2) Y(k2)];
142+ e = (A2 - A1) / norm(A2 - A1);
143+ ed = [-e(2) e(1)]; % perpendicular to the line of the holes
144+ if dot([xc yc] - A1, ed) < 0
145+ ed = -ed; % the plate hangs below its threads
146+ end
147+ eh = [-ed(2) ed(1)];
148+ s2 = norm(A2 - A1);
149+ sc = dot([xc yc] - A1, eh) / dot(A2 - A1, eh); % centre of mass along the line, 0 at hole 1, 1 at hole 2
150+ if ~(sc > 0 && sc < 1)
151+ error('plate_mount: the centre of mass is not between the two holes, so one thread would go slack');
152+ end
153+ T1 = mass * P.g * (1 - sc);
154+ T2 = mass * P.g * sc;
155+ mnt.gx = ed(1); mnt.gy = ed(2); mnt.gz = 0;
156+ mnt.ks(k1) = T1 / mount.length;
157+ mnt.ks(k2) = T2 / mount.length;
158+ mnt.fix = [k1 ed; k2 ed; k1 eh]; % threads inextensible, sway fixed at one hole
159+ mnt.pivot = (A1 + A2) / 2;
160+ mnt.ed = ed; mnt.eh = eh;
161+ mnt.nr = 3;
162+ mnt.rigid_names = {'sway of the threads', 'swing about the holes', 'yaw about the vertical'};
163+ supports(end + 1) = struct('name', 'thread 1', 'unit', 'N', 'nodes', k1, 'kind', 'spring');
164+ supports(end + 1) = struct('name', 'thread 2', 'unit', 'N', 'nodes', k2, 'kind', 'spring');
165+ notes{end + 1} = sprintf('hung from the nodes (%.4g, %.4g) and (%.4g, %.4g) m on threads of %.3g m; tensions %.3g and %.3g N; the line of the holes is horizontal', A1(1), A1(2), A2(1), A2(2), mount.length, T1, T2);
166+ notes{end + 1} = sprintf('span %.3g m between the holes', s2);
167+ case 'pads'
168+ mnt.gx = 0; mnt.gy = 0; mnt.gz = -1;
169+ if ~isfield(mount, 'pads') || isempty(mount.pads)
170+ error('plate_mount: the pads mount needs at least one pad');
171+ end
172+ npads = numel(mount.pads);
173+ for k = 1:npads
174+ pd = mount.pads(k);
175+ in = inside & abs(X - pd.x) <= pd.rx + 1e-12 & abs(Y - pd.y) <= pd.ry + 1e-12;
176+ if ~any(in(:))
177+ kn = nearest_node([pd.x pd.y], dom);
178+ in = false(ny, nx); in(kn) = true;
179+ notes{end + 1} = sprintf('pad %d is smaller than a grid cell, so it holds the one node nearest its centre', k);
180+ end
181+ mnt.kb(in) = mnt.kb(in) + mount.k * a(in);
182+ mnt.cb(in) = mnt.cb(in) + mount.c * a(in);
183+ supports(end + 1) = struct('name', sprintf('pad %d', k), 'unit', 'N', 'nodes', find(in), 'kind', 'bottom');
184+ end
185+ mnt.pivot = [xc yc];
186+ mnt.ed = [1 0]; mnt.eh = [0 1];
187+ mnt.nr = 3;
188+ mnt.rigid_names = {'heave', 'tilt along x', 'tilt along y'};
189+ notes{end + 1} = sprintf('resting on %d pad(s); each pushes only while compressed, so the plate can lift off', npads);
190+ case 'cymbal'
191+ mnt.gx = 0; mnt.gy = 0; mnt.gz = -1;
192+ r = sqrt((X - P.plate.Lx / 2).^2 + (Y - P.plate.Ly / 2).^2);
193+ in = inside & r <= mount.r_felt + 1e-12 & r > mount.r_rod;
194+ if ~any(in(:))
195+ error('plate_mount: the felt washer covers no node; enlarge r_felt or refine the grid');
196+ end
197+ mnt.kb(in) = mount.k * a(in); mnt.cb(in) = mount.c * a(in); mnt.ob(in) = mount.preload;
198+ mnt.kt(in) = mount.k * a(in); mnt.ct(in) = mount.c * a(in); mnt.ot(in) = mount.preload;
199+ supports(end + 1) = struct('name', 'lower felt', 'unit', 'N', 'nodes', find(in), 'kind', 'bottom');
200+ supports(end + 1) = struct('name', 'upper felt', 'unit', 'N', 'nodes', find(in), 'kind', 'top');
201+ mnt.pivot = [P.plate.Lx / 2, P.plate.Ly / 2];
202+ mnt.ed = [1 0]; mnt.eh = [0 1];
203+ mnt.nr = 3;
204+ mnt.rigid_names = {'heave', 'tilt along x', 'tilt along y'};
205+ notes{end + 1} = sprintf('on a stand: felts over %d node(s) between radii %.3g and %.3g m, each preloaded by %.3g mm; the rod and the hole it passes through are not modelled', nnz(in), mount.r_rod, mount.r_felt, mount.preload * 1e3);
206+ case 'vise'
207+ depth = mount.depth;
208+ switch mount.edge
209+ case 'x0'
210+ coord = X; edir = [1 0];
211+ case 'x1'
212+ coord = P.plate.Lx - X; edir = [-1 0];
213+ case 'y0'
214+ coord = Y; edir = [0 1];
215+ case 'y1'
216+ coord = P.plate.Ly - Y; edir = [0 -1];
217+ otherwise
218+ error('plate_mount: unknown vise edge "%s" (expected x0, x1, y0 or y1)', mount.edge);
219+ end
220+ held = inside & coord <= depth + 1e-12;
221+ % the clamped condition needs a held node behind every held edge node
222+ h = min(dx, dy);
223+ if nnz(held) < 2 || max(coord(held)) < h - 1e-12
224+ held = inside & coord <= h + 1e-12;
225+ notes{end + 1} = sprintf('jaw depth widened to %.3g mm, two rows of nodes, which the clamped condition needs', h * 1e3);
226+ end
227+ if ~any(held(:))
228+ error('plate_mount: the vise grips no node of the plate');
229+ end
230+ if nnz(inside & ~held) < 9
231+ error('plate_mount: the vise leaves almost nothing of the plate free');
232+ end
233+ mnt.held = held;
234+ tilt = mount.tilt * pi / 180;
235+ mnt.gx = cos(tilt) * edir(1); mnt.gy = cos(tilt) * edir(2); mnt.gz = -sin(tilt);
236+ kh = find(held);
237+ mnt.fix = [kh ones(numel(kh), 1) zeros(numel(kh), 1); kh zeros(numel(kh), 1) ones(numel(kh), 1)];
238+ mnt.pivot = [xc yc];
239+ mnt.ed = edir; mnt.eh = [-edir(2) edir(1)];
240+ mnt.nr = 0;
241+ mnt.rigid_names = {};
242+ notes{end + 1} = sprintf('%d nodes within %.3g mm of the %s edge are held in the vise; tilt %g degrees', nnz(held), depth * 1e3, mount.edge, mount.tilt);
243+ if mount.tilt > 90
244+ notes{end + 1} = 'standing on the jaw: the self-weight is compressive, which softens the lowest mode and can buckle a long thin plate';
245+ end
246+ case 'pinched'
247+ A = [mount.x mount.y];
248+ r = sqrt((X - A(1)).^2 + (Y - A(2)).^2);
249+ in = inside & r <= mount.radius + 1e-12;
250+ if ~any(in(:))
251+ kA = nearest_node(A, dom);
252+ in = false(ny, nx); in(kA) = true;
253+ notes{end + 1} = 'the pinch patch is smaller than a grid cell, so it holds one node';
254+ end
255+ atot = sum(a(in));
256+ mnt.ks(in) = mount.k * a(in) / atot;
257+ mnt.cs(in) = mount.c * a(in) / atot;
258+ kin = find(in);
259+ mnt.fix = [kin ones(numel(kin), 1) zeros(numel(kin), 1); kin zeros(numel(kin), 1) ones(numel(kin), 1)];
260+ d = [xc yc] - A;
261+ if norm(d) < 1e-9
262+ d = [0 -1];
263+ end
264+ ed = d / norm(d);
265+ tilt = mount.tilt * pi / 180;
266+ mnt.gx = cos(tilt) * ed(1); mnt.gy = cos(tilt) * ed(2); mnt.gz = -sin(tilt);
267+ mnt.pivot = A;
268+ mnt.ed = ed; mnt.eh = [-ed(2) ed(1)];
269+ mnt.nr = 3;
270+ mnt.rigid_names = {'heave at the hand', 'swing about the hand', 'turn about the hand'};
271+ supports(end + 1) = struct('name', 'fingers', 'unit', 'N', 'nodes', kin, 'kind', 'spring');
272+ notes{end + 1} = sprintf('pinched over %d node(s) about (%.4g, %.4g) m: a two-sided spring of %.3g N/m with %.3g N s/m of damping, tilt %g degrees', nnz(in), A(1), A(2), mount.k, mount.c, mount.tilt);
273+end
274+mnt.supports = supports;
275+mnt.notes = notes;
276+end
277+
278+function k = nearest_node(A, dom)
279+d2 = (dom.X - A(1)).^2 + (dom.Y - A(2)).^2;
280+d2(~dom.inside) = Inf;
281+[dmin, k] = min(d2(:));
282+if ~isfinite(dmin)
283+ error('plate_mount: no node of the plate near (%.4g, %.4g) m', A(1), A(2));
284+end
285+end
286+
287+function kt = torsion(mount)
288+% Torsional stiffness G J / L of a round thread; zero without thread data.
289+kt = 0;
290+if isfield(mount, 'thread') && isfield(mount.thread, 'diameter') && isfield(mount.thread, 'G')
291+ J = pi * mount.thread.diameter^4 / 32;
292+ kt = mount.thread.G * J / mount.length;
293+end
294+end
packages/core/src/m/plate_prestress.madded+169−0View file
@@ -0,0 +1,169 @@
1+function [Kg, ps] = plate_prestress(P, dom, mnt, ops)
2+% PLATE_PRESTRESS In-plane stress of the plate's own weight, and its effect on bending.
3+%
4+% [Kg, ps] = plate_prestress(P, dom, mnt, ops) solves the plane-stress
5+% problem of the plate loaded by the in-plane part of its weight,
6+% rho h g (gx, gy) per unit area, with the in-plane constraints of the mount
7+% (the holes a thread passes through, the jaws of a vise, the pinched patch),
8+% and returns the sparse N x N operator Kg of the membrane term
9+%
10+% U_N = 1/2 integral( N_xx w_x^2 + N_yy w_y^2 + 2 N_xy w_x w_y ) dA
11+%
12+% that the in-plane stress resultants N add to the bending energy. This
13+% is how gravity acts on the vibration of a hanging plate: a tilted element
14+% under tension is pulled back, and for a rigid rotation of the plate about
15+% its hole the term integrates to the compound-pendulum stiffness m g d
16+% exactly (d the distance from the hole to the centre of mass), so the
17+% swing of a hung plate and the small stiffening of its lowest flexural
18+% modes come out of the same operator. Parts of the plate above the hole
19+% are in compression and the term softens them slightly. Without an
20+% in-plane gravity component (a horizontal plate) Kg is empty.
21+%
22+% Discretization. The in-plane displacement (u, v) lives on the nodes.
23+% The direct strains are first differences along the grid edges,
24+% e_xx = (u_R - u_L) / dx on the x-edges and e_yy on the y-edges, the shear
25+% strain gamma = u_y + v_x and the coupling e_xx e_yy are taken at the cell
26+% centres from the averaged corner differences (ops from plate_bending).
27+% The strain energy
28+%
29+% U = h/2 sum_edges C11 w_e e_xx^2 + h/2 sum_edges C11 w_e e_yy^2
30+% + h sum_cells A_c ( C12 e_xx e_yy + G/2 gamma^2 ),
31+% C11 = E / (1 - nu^2), C12 = nu C11, G = E / (2 (1 + nu)),
32+%
33+% with the edge weights w_e their share of cell area, has no spurious zero
34+% modes: only the two translations and the rotation are strain-free. The
35+% stress resultants are the derivatives of U with respect to the strains,
36+% n_xx = dU/de_xx on each x-edge, n_yy on each y-edge, n_xy = dU/dgamma on
37+% each cell (each N times its area), and Kg is assembled from the same
38+% difference operators applied to w:
39+%
40+% Kg = Bx' diag(n_xx) Bx + By' diag(n_yy) By + Bxc' diag(n_xy) Byc + Byc' diag(n_xy) Bxc.
41+%
42+% Because both U and U_N use the same strain operators, the identity
43+% r' Kg r = m g d for the rigid tilt r holds to rounding, not just to
44+% truncation error.
45+%
46+% Constraints (mnt.fix) are imposed by a change of variables: a node with
47+% one fixed direction keeps its displacement along the other, a node with
48+% two keeps none. A plate on one thread can still rotate about the hole;
49+% the load has no moment about it (the plate hangs with its centre of mass
50+% below the hole, plate_mount), so fixing the tangential displacement of
51+% the node farthest from the hole removes the rotation at zero reaction.
52+%
53+% ps returns the solution for inspection: u, v (ny x nx), the reactions at
54+% the fixed nodes and the tension of each thread, and the node-averaged
55+% stress resultants Nxx, Nyy, Nxy [N/m].
56+
57+nx = dom.nx; ny = dom.ny;
58+N = nx * ny;
59+ps = struct('solved', false);
60+if P.g == 0 || (mnt.gx == 0 && mnt.gy == 0)
61+ Kg = sparse(N, N);
62+ return
63+end
64+idx = ops.idx; n = ops.n;
65+Bx = ops.Bx; By = ops.By; Bxc = ops.Bxc; Byc = ops.Byc; Cx = ops.Cx; Cy = ops.Cy;
66+wx = ops.wx; wy = ops.wy; area = ops.area; ncell = ops.ncell;
67+nex = numel(wx); ney = numel(wy);
68+E = P.material.E; nu = P.material.nu; h = P.plate.h;
69+C11 = E / (1 - nu^2); C12 = nu * C11; G = E / (2 * (1 + nu));
70+
71+% Stiffness on [u; v]
72+Kuu = h * C11 * (Bx' * spdiags(wx, 0, nex, nex) * Bx);
73+Kvv = h * C11 * (By' * spdiags(wy, 0, ney, ney) * By);
74+Kuv = h * C12 * area * (Bxc' * Byc);
75+Gam = [Byc, Bxc]; % gamma = u_y + v_x at the cells
76+Kps = [Kuu, Kuv; Kuv', Kvv] + h * G * area * (Gam' * Gam);
77+
78+% Load: the in-plane weight of each node
79+Mi = P.mu * ops.mu;
80+f = [Mi * P.g * mnt.gx; Mi * P.g * mnt.gy];
81+
82+% Constraints, in the plate's node numbering
83+fix = mnt.fix;
84+loc = zeros(N, 1); loc(idx) = (1:n)';
85+fixn = loc(fix(:, 1));
86+if any(fixn == 0)
87+ error('plate_prestress: a constraint sits on a node off the plate');
88+end
89+fix_mount = unique(fixn); % the mount's own constraints, for the reactions
90+if mnt.free_rotation
91+ % the tangential displacement of the node farthest from the pivot is fixed
92+ pk = fixn(1);
93+ X = dom.X(idx); Y = dom.Y(idx);
94+ d2 = (X - X(pk)).^2 + (Y - Y(pk)).^2;
95+ d2(ops.unk == 0) = -1;
96+ [~, far] = max(d2);
97+ t = [-(Y(far) - Y(pk)), X(far) - X(pk)];
98+ t = t / norm(t);
99+ fixn = [fixn; far];
100+ fix = [fix; idx(far) t];
101+end
102+% Rotation matrix T from the reduced unknowns to [u; v]
103+one = zeros(n, 2); cnt = zeros(n, 1);
104+for q = 1:numel(fixn)
105+ k = fixn(q);
106+ cnt(k) = cnt(k) + 1;
107+ one(k, :) = fix(q, 2:3) / norm(fix(q, 2:3));
108+end
109+if any(cnt > 2)
110+ error('plate_prestress: more than two constraints on one node');
111+end
112+both = cnt == 2;
113+single = cnt == 1;
114+none = cnt == 0;
115+kn = find(none); ks = find(single);
116+nq = 2 * numel(kn) + numel(ks);
117+ti = [kn; n + kn; ks; n + ks];
118+tj = [(1:numel(kn))'; numel(kn) + (1:numel(kn))'; 2 * numel(kn) + (1:numel(ks))'; 2 * numel(kn) + (1:numel(ks))'];
119+tv = [ones(2 * numel(kn), 1); -one(ks, 2); one(ks, 1)]; % the direction perpendicular to the fixed one
120+T = sparse(ti, tj, tv, 2 * n, nq);
121+Kred = T' * Kps * T;
122+fred = T' * f;
123+q = Kred \ fred;
124+U = T * q;
125+u = U(1:n); v = U(n + 1:end);
126+if any(~isfinite(U))
127+ error('plate_prestress: the in-plane solve failed; the mount does not hold the plate in its plane');
128+end
129+react = Kps * U - f; % force the constraints apply, [x; y] per node
130+
131+% Stress resultants times area at the edges and cells
132+exx = Bx * u; eyy = By * v;
133+exxc = Bxc * u; eyyc = Byc * v; gam = Gam * U;
134+nxx = h * (C11 * (wx .* exx) + C12 * area * (Cx' * eyyc));
135+nyy = h * (C11 * (wy .* eyy) + C12 * area * (Cy' * exxc));
136+nxy = h * G * area * gam;
137+Kgu = Bx' * spdiags(nxx, 0, nex, nex) * Bx + By' * spdiags(nyy, 0, ney, ney) * By ...
138+ + Bxc' * spdiags(nxy, 0, ncell, ncell) * Byc + Byc' * spdiags(nxy, 0, ncell, ncell) * Bxc;
139+Pz = spdiags(ops.unk, 0, n, n);
140+Kgu = Pz * Kgu * Pz;
141+[ki, kj, kv] = find(Kgu);
142+Kg = sparse(idx(ki), idx(kj), kv, N, N);
143+
144+% Node-averaged resultants for display: each edge's value shared by its two
145+% nodes, each cell's by its four corners, divided by the areas they cover
146+ps.solved = true;
147+uf = zeros(ny, nx); vf = zeros(ny, nx);
148+uf(idx) = u; vf(idx) = v;
149+ps.u = uf; ps.v = vf;
150+Ex = abs(Bx) * (dom.dx / 2); % 1/2 at each node of an x-edge
151+Ey = abs(By) * (dom.dy / 2);
152+sx = Ex' * nxx; ax = Ex' * wx;
153+sy = Ey' * nyy; ay = Ey' * wy;
154+ic = (1:ncell)';
155+Qc = sparse([ic; ic; ic; ic], ops.corners(:), 0.25 * ones(4 * ncell, 1), ncell, n);
156+sxy = Qc' * nxy; ac = Qc' * (area * ones(ncell, 1));
157+Nxx = zeros(ny, nx); Nyy = zeros(ny, nx); Nxy = zeros(ny, nx);
158+Nxx(idx) = sx ./ max(ax, eps); Nyy(idx) = sy ./ max(ay, eps); Nxy(idx) = sxy ./ max(ac, eps);
159+Nxx(~dom.inside) = NaN; Nyy(~dom.inside) = NaN; Nxy(~dom.inside) = NaN;
160+ps.Nxx = Nxx; ps.Nyy = Nyy; ps.Nxy = Nxy;
161+ps.nxx = nxx; ps.nyy = nyy; ps.nxy = nxy; % the resultants times area, per edge and cell: the factors of Kg
162+% Reactions and thread tensions: the force along -ed (up) at each fixed node
163+rx = react(1:n); ry = react(n + 1:end);
164+ps.react_x = rx; ps.react_y = ry;
165+ps.up = -(rx * mnt.ed(1) + ry * mnt.ed(2)); % upward force each constrained node carries
166+ps.fixed_nodes = idx(fix_mount);
167+ps.mass_check = sum(ps.up(fix_mount)) / (mnt.mass * P.g); % 1 when the constraints carry the weight
168+ps.energy = 0.5 * (U' * Kps * U);
169+end
packages/core/src/m/plate_result_schema.madded+37−0View file
@@ -0,0 +1,37 @@
1+% PLATE_RESULT_SCHEMA Fields of the result struct plate_fd returns.
2+%
3+% All displacements are the departure from the static equilibrium under
4+% gravity, w - w0; result.static carries w0 itself.
5+%
6+% result.method 'fd'
7+% result.grid x (1 x nx), y (1 x ny): the grid [m], spanning the box of the plate
8+% result.t time axis of the series [s], one sample per 1/fs, each the
9+% average over the steps it contains
10+% result.probe x, y (actual probe node), and the series w [m] (total
11+% departure), sxx, syy, txy, vm, sp [Pa] (from the flexural part)
12+% result.frames t (1 x nf); w the flexural field, w_total the flexural field
13+% plus the rigid-body motion, vm and sp the surface stresses,
14+% each ny x nx x nf (rows index y, columns index x), NaN off the
15+% plate; rigid (nr x nf) the rigid-body coordinates at the frames
16+% result.envelope vm, sp: peak over the saved frames, ny x nx [Pa]
17+% result.spectrum f [Hz], w [m], vm [Pa]: amplitude spectra of the probe series
18+% result.audio fs [Hz], p (pressure at the listener) [Pa], t0 (dropped travel
19+% time) [s], model ('baffled' or 'unbaffled')
20+% result.rigid nr; names {1 x nr}; series (nr x nt) the rigid-body coordinates:
21+% a translation [m] along the normal at the pivot and two rotations
22+% [rad] about the in-plane axes eh and ed through the pivot
23+% (pivot_x, pivot_y, ed, eh); hz the frequencies of the rigid-body
24+% modes of the mount, ascending, and mode_names the coordinate
25+% that dominates each
26+% result.supports n; names, units, kinds {1 x n}; series (n x nt) the force each
27+% support applies [N]; static (1 x n) the same at rest
28+% result.static w0 (ny x nx) the deflection at rest, sag = max |w0|; gx, gy, gz
29+% the direction of gravity in plate coordinates and g; mass, xc, yc;
30+% held (ny x nx) the nodes the mount holds; Nxx, Nyy, Nxy [N/m]
31+% the self-weight stress resultants when the plate hangs
32+% result.info nx, ny, dx, dy, dt, dt_max, cfl, nsteps, ncells, nunknowns, shape,
33+% mount, cell_steps, fs, stable, diverged_step, loop ('numbl' or
34+% 'webgpu'), loop_s, wall_s, notes (cell array of strings about
35+% the idealizations that applied to this run)
36+%
37+% This file documents the convention and has no executable content.
packages/core/src/m/plate_rigid_basis.madded+45−0View file
@@ -0,0 +1,45 @@
1+function [R, Q, G] = plate_rigid_basis(dom, mnt, Mvec, unk)
2+% PLATE_RIGID_BASIS Rigid-body coordinates of the plate about the mount's pivot.
3+%
4+% [R, Q, G] = plate_rigid_basis(dom, mnt, Mvec, unk) returns the N x nr
5+% basis R of the rigid-body motions the mount leaves free, evaluated at the
6+% grid nodes and zero at the nodes that are not unknowns (unk false), the
7+% projector Q = M R (R' M R)^-1 with M = diag(Mvec) the node masses, so that
8+%
9+% c = Q' w are the rigid-body coordinates of a field w and
10+% w - R c its flexural remainder, mass-orthogonal to R,
11+%
12+% and the nr x nr rigid mass matrix G = R' M R. For nr = 3 the columns are
13+%
14+% 1 translation along the normal (heave, or the
15+% sway of a hanging plate's hole, in metres)
16+% (P - pivot) . ed rotation about the horizontal in-plane axis eh
17+% through the pivot, in radians (the swing of a
18+% hung plate, the tilt of one on pads)
19+% (P - pivot) . eh rotation about the in-plane axis ed through the
20+% pivot (the spin of a hung plate about its thread)
21+%
22+% with mnt.pivot, mnt.ed (in-plane down) and mnt.eh (in-plane horizontal)
23+% from plate_mount. A mount that holds nodes still (the vise) has nr = 0.
24+%
25+% The time stepping carries the two parts separately (plate_fd_loop): the
26+% rigid coordinates as three scalar equations with the exact reduced
27+% stiffness R' A R, the remainder on the grid. Bending annihilates R, so
28+% the split costs nothing in accuracy and it is what keeps the metre-scale
29+% sway and the micrometre-scale flexure apart in single precision.
30+
31+N = dom.nx * dom.ny;
32+nr = mnt.nr;
33+if nr == 0
34+ R = zeros(N, 0); Q = zeros(N, 0); G = zeros(0, 0);
35+ return
36+end
37+X = dom.X(:); Y = dom.Y(:);
38+rd = (X - mnt.pivot(1)) * mnt.ed(1) + (Y - mnt.pivot(2)) * mnt.ed(2);
39+rh = (X - mnt.pivot(1)) * mnt.eh(1) + (Y - mnt.pivot(2)) * mnt.eh(2);
40+R = [ones(N, 1), rd, rh];
41+R(~unk(:), :) = 0;
42+m = Mvec(:) .* double(unk(:));
43+G = R' * (m .* R);
44+Q = (m .* R) / G;
45+end
packages/core/src/m/plate_setup.madded+68−0View file
@@ -0,0 +1,68 @@
1+function P = plate_setup(params)
2+% PLATE_SETUP Derived quantities for a Kirchhoff-Love plate problem.
3+%
4+% P = plate_setup(params) returns a copy of params with the derived fields
5+% P.D bending stiffness D = E h^3 / (12 (1 - nu^2)) [N m]
6+% P.mu mass per unit area mu = rho h [kg/m^2]
7+% P.kappa stiffness parameter kappa = sqrt(D / mu) [m^2/s]
8+% P.Kb bending-stress factor at the surface z = h/2 [N/m]
9+% P.Ks shear-stress factor at the surface z = h/2 [N/m]
10+% P.g gravitational acceleration [m/s^2], params.gravity.g (0 switches
11+% gravity off)
12+%
13+% The plate is params.plate.shape, 'rectangle' or 'ellipse', filling the
14+% box [0, Lx] x [0, Ly]: for the ellipse Lx and Ly are the diameters along
15+% x and y, and Lx = Ly is a disk. A missing shape means a rectangle.
16+%
17+% The transverse displacement w(x,y,t) of the plate obeys
18+%
19+% mu w_tt + 2 mu sigma0 w_t - 2 mu sigma1 lap(w_t) + D lap(lap(w))
20+% - div(N grad w) = f(x,y,t) + mu g_n + support forces
21+%
22+% where f is the applied pressure [N/m^2], sigma0 [1/s] is a frequency-
23+% independent loss rate, sigma1 [m^2/s] adds losses that grow with spatial
24+% frequency (Bilbao, Numerical Sound Synthesis, 2009, ch. 12), N is the
25+% in-plane stress resultant of the plate's own weight (plate_prestress) and
26+% g_n the component of gravity along the plate normal. How the plate is
27+% held (params.mount) decides the support forces and the direction of
28+% gravity in the plate's own coordinates: see plate_mount. The surface
29+% stresses follow from the curvatures (plate_stress).
30+
31+P = params;
32+pl = params.plate;
33+mat = params.material;
34+
35+if ~isfield(pl, 'shape')
36+ pl.shape = 'rectangle';
37+ P.plate.shape = 'rectangle';
38+end
39+if ~any(strcmp(pl.shape, {'rectangle', 'ellipse'}))
40+ error('plate_setup: unknown plate shape "%s" (expected rectangle or ellipse)', pl.shape);
41+end
42+if pl.Lx <= 0 || pl.Ly <= 0 || pl.h <= 0
43+ error('plate_setup: plate dimensions must be positive');
44+end
45+if pl.h > 0.1 * min(pl.Lx, pl.Ly)
46+ warning('plate_setup: thickness is more than a tenth of the shorter side; Kirchhoff-Love thin-plate theory is not reliable here');
47+end
48+if ~isfield(params, 'mount') || ~isfield(params.mount, 'type')
49+ error('plate_setup: params.mount.type is required (thread, threads, pads, cymbal, vise or pinched)');
50+end
51+if ~any(strcmp(params.mount.type, {'thread', 'threads', 'pads', 'cymbal', 'vise', 'pinched'}))
52+ error('plate_setup: unknown mount type "%s"', params.mount.type);
53+end
54+if isfield(params, 'gravity') && isfield(params.gravity, 'g')
55+ P.g = params.gravity.g;
56+else
57+ P.g = 9.81;
58+end
59+if P.g < 0
60+ error('plate_setup: g cannot be negative; turn the plate over instead');
61+end
62+
63+P.D = mat.E * pl.h^3 / (12 * (1 - mat.nu^2));
64+P.mu = mat.rho * pl.h;
65+P.kappa = sqrt(P.D / P.mu);
66+P.Kb = mat.E * pl.h / (2 * (1 - mat.nu^2));
67+P.Ks = mat.E * pl.h / (2 * (1 + mat.nu));
68+end
packages/core/src/m/plate_spectrum.madded+19−0View file
@@ -0,0 +1,19 @@
1+function [f, A] = plate_spectrum(t, x)
2+% PLATE_SPECTRUM One-sided amplitude spectrum of a real, uniformly sampled signal.
3+%
4+% [f, A] = plate_spectrum(t, x) applies a Hann window and returns the
5+% amplitude A at frequencies f [Hz], scaled so that a steady sinusoid of
6+% amplitude a shows a peak of height about a. For a decaying signal the
7+% peak heights depend on the record length, so compare shapes and
8+% frequencies rather than absolute heights.
9+
10+x = x(:).';
11+N = numel(x);
12+dt = t(2) - t(1);
13+win = 0.5 * (1 - cos(2 * pi * (0:N - 1) / (N - 1)));
14+X = fft(x .* win);
15+nh = floor(N / 2) + 1;
16+X = X(1:nh);
17+A = abs(X) * 2 / sum(win);
18+f = (0:nh - 1) / (N * dt);
19+end
packages/core/src/m/plate_static.madded+87−0View file
@@ -0,0 +1,87 @@
1+function st = plate_static(P, dom, mnt, A0, Mvec, unk)
2+% PLATE_STATIC Static equilibrium of the plate in its mount under gravity.
3+%
4+% st = plate_static(P, dom, mnt, A0, Mvec, unk) solves
5+%
6+% (A0 + K_contact) w0 = M g_n + contact offsets
7+%
8+% for the deflection w0 of the plate at rest, with A0 the linear operator
9+% (bending, self-weight prestress and the linear springs of the mount,
10+% plate_model), M the node masses, g_n the normal component of gravity, and
11+% K_contact the springs of the one-sided contacts that are compressed at
12+% rest. Which contacts those are is found by an active-set iteration: all
13+% contacts start engaged, any found in tension is released, and the solve is
14+% repeated until the set no longer changes. A plate that nothing holds in
15+% equilibrium (too few pads to rest on, or one standing on its edge that
16+% buckles under its own weight) fails the Cholesky factorization, and the
17+% error says so.
18+%
19+% The dynamics are then computed for the departure from w0 (plate_fd_loop):
20+% the static forces cancel exactly, the contact forces are linear in the
21+% departure until a contact opens, and single precision has only the motion
22+% to represent.
23+%
24+% Fields.
25+% w0 N x 1 static deflection (zero at nodes that are not unknowns)
26+% actb, actt N x 1 logical: bottom and top contacts engaged at rest
27+% gb, gt N x 1 compression of each contact at rest [m]: the bottom
28+% contact pushes while gb - w > 0, the top while gt + w > 0
29+% fstat_s, fstat_b, fstat_t N x 1 force at rest of the linear springs, the
30+% bottom contacts and the top contacts [N]
31+% Alin the operator linearized about rest, for the release solve
32+% sag largest |w0| [m]
33+% notes
34+
35+N = dom.nx * dom.ny;
36+u = unk(:);
37+kb = mnt.kb(:); kt = mnt.kt(:); ob = mnt.ob(:); ot = mnt.ot(:);
38+ks = mnt.ks(:);
39+fg = Mvec(:) .* u * P.g * mnt.gz;
40+notes = {};
41+actb = kb > 0; actt = kt > 0;
42+w0 = zeros(N, 1);
43+iu = find(u);
44+if ~any(fg) && ~any(actb) && ~any(actt)
45+ Alin = A0;
46+else
47+ for iter = 1:20
48+ kc = kb .* actb + kt .* actt;
49+ Alin = A0 + spdiags(kc, 0, N, N);
50+ rhs = fg + kb .* actb .* ob - kt .* actt .* ot;
51+ w0 = zeros(N, 1);
52+ w0(iu) = plate_static_solve(Alin(iu, iu), rhs(iu));
53+ nb = (kb > 0) & (ob - w0 > 0);
54+ nt = (kt > 0) & (ot + w0 > 0);
55+ if isequal(nb, actb) && isequal(nt, actt)
56+ break
57+ end
58+ actb = nb; actt = nt;
59+ if iter == 20
60+ notes{end + 1} = 'the set of engaged contacts did not settle in 20 iterations; the last one is used';
61+ end
62+ end
63+ if any(kb > 0) && ~all(actb(kb > 0))
64+ notes{end + 1} = sprintf('%d support node(s) carry no load at rest: the plate does not touch them', nnz(kb > 0 & ~actb));
65+ end
66+ if any(kt > 0) && ~all(actt(kt > 0))
67+ notes{end + 1} = sprintf('%d upper-felt node(s) are clear of the plate at rest', nnz(kt > 0 & ~actt));
68+ end
69+end
70+st.w0 = w0;
71+st.actb = actb; st.actt = actt;
72+st.gb = ob - w0; st.gt = ot + w0;
73+st.fstat_s = -ks .* w0; % linear springs
74+st.fstat_b = kb .* max(st.gb, 0); % bottom contacts, pushing up
75+st.fstat_t = -kt .* max(st.gt, 0); % top contacts, pushing down
76+st.Alin = Alin;
77+st.sag = max(abs(w0));
78+st.notes = notes;
79+end
80+
81+function x = plate_static_solve(A, b)
82+[Rc, p] = chol(A);
83+if p > 0
84+ error('plate_static: the plate has no stable equilibrium in this mount (it tips, swings over or buckles under its own weight); check the pads, the holes or the tilt');
85+end
86+x = Rc \ (Rc' \ b);
87+end
packages/core/src/m/plate_stress.madded+38−0View file
@@ -0,0 +1,38 @@
1+function S = plate_stress(P, wxx, wyy, wxy)
2+% PLATE_STRESS Surface stresses of a Kirchhoff-Love plate from its curvatures.
3+%
4+% S = plate_stress(P, wxx, wyy, wxy) takes the second derivatives of the
5+% displacement (arrays of any common size) and returns, at the surface z = h/2,
6+% S.sxx, S.syy, S.txy in-plane stress components [Pa]
7+% S.vm von Mises equivalent stress [Pa]
8+% S.sp principal stress of largest magnitude, signed
9+% (tension positive) [Pa]
10+%
11+% In thin-plate theory the in-plane strains vary linearly through the
12+% thickness, eps_xx = -z w_xx, eps_yy = -z w_yy, gamma_xy = -2 z w_xy, and
13+% plane stress gives
14+% sigma_xx = -E z / (1 - nu^2) (w_xx + nu w_yy)
15+% sigma_yy = -E z / (1 - nu^2) (w_yy + nu w_xx)
16+% tau_xy = -E z / (1 + nu) w_xy
17+% Stresses are largest at the surfaces z = +-h/2 and vanish at the mid-plane;
18+% the two surfaces carry equal and opposite stress. P.Kb and P.Ks are the
19+% factors E h / (2 (1 - nu^2)) and E h / (2 (1 + nu)) (see plate_setup).
20+%
21+% Because stress is a second derivative of the primary unknown, any error in
22+% the computed displacement is amplified here.
23+
24+nu = P.material.nu;
25+S.sxx = -P.Kb * (wxx + nu * wyy);
26+S.syy = -P.Kb * (wyy + nu * wxx);
27+S.txy = -P.Ks * wxy;
28+
29+S.vm = sqrt(S.sxx.^2 - S.sxx .* S.syy + S.syy.^2 + 3 * S.txy.^2);
30+
31+c = (S.sxx + S.syy) / 2;
32+r = sqrt(((S.sxx - S.syy) / 2).^2 + S.txy.^2);
33+s1 = c + r;
34+s2 = c - r;
35+S.sp = s1;
36+pick = abs(s2) > abs(s1);
37+S.sp(pick) = s2(pick);
38+end
packages/core/src/methods/common.tsadded+182−0View file
@@ -0,0 +1,182 @@
1+import type { EquationGroup } from "./types";
2+
3+/**
4+ * The theory, as the equations tab shows it: the model, then how each mount
5+ * enters, then the discretization and its limits. Every group names what it
6+ * idealizes. Groups with a `mounts` list are shown only for those mounts.
7+ */
8+
9+export const GOVERNING: EquationGroup = {
10+ title: "The plate under gravity",
11+ equations: [
12+ {
13+ tex: String.raw`\mu\,\ddot w + 2\mu\sigma_0\,\dot w - 2\mu\sigma_1\,\nabla^2\dot w + D\,\nabla^4 w - \nabla\!\cdot\!\left(\mathbf N\,\nabla w\right) = f(x,y,t) + \mu\, g_n + f_{\mathrm{mount}}`,
14+ caption:
15+ "Damped Kirchhoff-Love plate in its own coordinates: w the transverse displacement, mu = rho h the mass per area, D the bending stiffness. Gravity enters twice: its normal component g_n loads the plate transversely, and its in-plane component puts the plate under its own weight, whose stress resultants N (tension positive) act on the tilted plate through the membrane term. The mount supplies f_mount: springs, contacts, or nothing where it holds the plate still. The two loss terms are a model choice, not a measurement.",
16+ },
17+ {
18+ tex: String.raw`D = \frac{E h^3}{12(1-\nu^2)}, \qquad \kappa = \sqrt{D/\mu}, \qquad \mathbf g = g\,(g_x, g_y, g_z), \quad g_x^2 + g_y^2 + g_z^2 = 1`,
19+ caption:
20+ "Bending stiffness, the stiffness parameter kappa that sets the dispersion of bending waves, and the direction of gravity in plate coordinates, which the mount decides: a hung plate turns in its plane until its centre of mass is below the hole, a plate on pads is horizontal, a vise or a hand holds it at a chosen tilt.",
21+ },
22+ {
23+ tex: String.raw`\nabla\!\cdot\!\left(\mathbf C\, \boldsymbol\varepsilon(\mathbf u)\right) h + \rho h\, g\,(g_x, g_y) = 0, \qquad \mathbf N = h\,\mathbf C\,\boldsymbol\varepsilon(\mathbf u), \qquad \oint_{\text{edge}} \mathbf N\mathbf n\, ds = 0 \text{ except at the mount}`,
24+ caption:
25+ "Plane stress of the self-weight: the in-plane displacement u under the in-plane body force, free edges, and the mount's constraints taking the reactions (the thread tension, the jaws, the fingers). Solved once at setup by a sparse direct solve; it is the only place a linear system is solved, everything else is explicit.",
26+ },
27+ {
28+ tex: String.raw`U_N = \frac12\iint \left( N_{xx} w_x^2 + N_{yy} w_y^2 + 2 N_{xy} w_x w_y \right) dA, \qquad \mathbf r^{\!\top}\mathbf K_N\,\mathbf r = m\,g\,d`,
29+ caption:
30+ "The membrane energy of the prestress. For a rigid rotation r of a hung plate about the horizontal axis through its hole, it equals the compound-pendulum stiffness m g d, d the distance from the hole to the centre of mass; the lab assembles U_N with the same difference operators as the plane-stress energy, and then this identity holds to rounding. So the swing of a hanging plate and the small stiffening of its lowest flexural modes come from one operator, and a thin plate hung from a low point (its centre of mass above the hole) is correctly unstable.",
31+ },
32+ {
33+ tex: String.raw`f(x,y,t) = F(t)\,s(x,y), \qquad \iint s\,dA = 1`,
34+ caption:
35+ "A strike is a force history F(t), a half-sine pulse, spread over a raised-cosine footprint s so that F is the total force. A release has no force: the plate starts from the static deflection under a pull at a point, scaled to the asked displacement there, and is let go.",
36+ },
37+ ],
38+};
39+
40+export const THREAD_GROUP: EquationGroup = {
41+ title: "Threads",
42+ mounts: ["thread", "threads"],
43+ equations: [
44+ {
45+ tex: String.raw`T = m g \ (\text{one thread}), \qquad T_1 = m g\,(1 - s_c),\ T_2 = m g\, s_c \ (\text{two}), \qquad k_T = \frac{T}{L}`,
46+ caption:
47+ "A taut thread of length L and tension T pulls its hole back with force T w/L when the hole moves w out of plane: the linearized pendulum, a transverse spring of stiffness T/L at the hole. With two threads of one length from hooks at one height the line of the holes is horizontal and statics shares the weight by the position s_c of the centre of mass between them; a centre of mass outside the holes would slacken a thread, and the lab refuses it.",
48+ },
49+ {
50+ tex: String.raw`k_\theta = \frac{G J}{L}, \qquad J = \frac{\pi d^4}{32}`,
51+ caption:
52+ "One thread leaves the plate free to spin about the vertical axis through its hole, since the self-weight tension has no moment about it (the identity above gives zero for that rotation). The only restoring torque is the thread's torsion, which for any thread is so small that the spin has a period of minutes: after an off-centre strike the plate turns slowly, and only the sigma0 loss stops it. Two threads restrain the spin as a yaw on their two springs.",
53+ },
54+ ],
55+};
56+
57+export const CONTACT_GROUP: EquationGroup = {
58+ title: "Pads and felts",
59+ mounts: ["pads", "cymbal"],
60+ equations: [
61+ {
62+ tex: String.raw`f_c = k\,\max(0,\, g - w) - c\,\dot w\,[g - w > 0], \qquad g = \text{compression at rest}`,
63+ caption:
64+ "A pad is a one-sided spring and dashpot per unit area: it pushes while compressed and does nothing once the plate has lifted off it. The static solve finds which pads carry the plate at rest by an active-set iteration (a pad found in tension is released and the solve repeated); the dynamics then track the departure from that state, so a hard strike can lift the plate off a pad and let it land again. A cymbal stand has a felt below and one above the plate, each preloaded by the wing nut, so the centre is held two-sided until a strike separates one of them.",
65+ },
66+ {
67+ tex: String.raw`\Delta t \le \frac{2 M_i}{c_i}`,
68+ caption:
69+ "The dashpots are treated explicitly, on the backward-difference velocity, which is stable while the step stays below this bound; the plan keeps it under half of it. Stiff pads (a bare bench) also raise the stiffness bound below, so the hardest supports cost a shorter step.",
70+ },
71+ ],
72+};
73+
74+export const VISE_GROUP: EquationGroup = {
75+ title: "The vise",
76+ mounts: ["vise"],
77+ equations: [
78+ {
79+ tex: String.raw`w = 0 \ \text{in the jaws}, \qquad U = \frac{D}{2}\sum_q \omega_q\left(\frac{w_{q+1} - 2 w_q + w_{q-1}}{\Delta x^2}\right)^2 + \dots \quad\Rightarrow\quad w_n = 0 \ \text{at the clamp}`,
80+ caption:
81+ "The nodes within the jaw depth are held at zero and dropped from the unknowns after the bending energy is assembled. A second difference centred on a held node that reaches the first free node then penalizes the slope at the clamp, which is the clamped condition w = w_n = 0 (the 7/dx^4 coefficient of the ghost-node scheme); it needs a held node behind every held edge node, so the jaw depth is at least two rows. The self-weight then hangs from, sags from or presses on the jaws according to the tilt.",
82+ },
83+ ],
84+};
85+
86+export const PINCH_GROUP: EquationGroup = {
87+ title: "Fingers",
88+ mounts: ["pinched"],
89+ equations: [
90+ {
91+ tex: String.raw`f_{\mathrm{hand}} = -\frac{k\, a_i}{\sum a}\, w_i - \frac{c\, a_i}{\sum a}\, \dot w_i \quad \text{over the patch}, \qquad \mathbf u = 0 \ \text{on the patch}`,
92+ caption:
93+ "A grip between finger and thumb is a two-sided spring and dashpot, spread over the patch by node area, with the patch also fixed in the plane so it takes the weight. The stiffness and the damping of a hand are guesses of the right order (a few kN/m, tens of N s/m); the loss is what makes a hand-held plate die away faster than one on a thread.",
94+ },
95+ ],
96+};
97+
98+export const SCHEME_GROUP: EquationGroup = {
99+ title: "The explicit scheme",
100+ equations: [
101+ {
102+ tex: String.raw`\mathbf M\ddot{\mathbf w} + 2\sigma_0 \mathbf M\dot{\mathbf w} - 2\sigma_1\mu\,\mathbf L\dot{\mathbf w} + \left(\mathbf K_b + \mathbf K_N + \mathbf K_s\right)\mathbf w + \mathbf f_c(\mathbf w, \dot{\mathbf w}) = F(t)\,\mathbf s + \mathbf M g_n`,
103+ caption:
104+ "The semi-discrete system on the grid. K_b is the bending stiffness assembled from the discrete bending energy (finite-difference energy method): the 13-point biharmonic stencil inside, the free-edge conditions as natural conditions on the edges; K_N the prestress operator; K_s the linear springs of the mount; L the Laplacian energy operator with its natural zero-slope condition; M the trapezoidal node masses.",
105+ },
106+ {
107+ tex: String.raw`\mathbf w = \mathbf w_0 + \mathbf R\,\mathbf c + \boldsymbol\varphi, \qquad \mathbf R^{\!\top}\mathbf M\,\boldsymbol\varphi = 0, \qquad \mathbf R = \left[\,1,\ (\mathbf P - \mathbf A)\cdot\mathbf e_d,\ (\mathbf P - \mathbf A)\cdot\mathbf e_h\,\right]`,
108+ caption:
109+ "The state is the departure from the static equilibrium w0, split into three rigid-body coordinates c (a translation along the normal at the pivot A, and the rotations about the horizontal and the vertical in-plane axes through it) and a flexural remainder mass-orthogonal to them. The split is exact, and it is what keeps a millimetre swing and a micrometre vibration apart in single precision: bending annihilates R, so the bending operator is never applied to the swing.",
110+ },
111+ {
112+ tex: String.raw`\mathbf G\,(\ddot{\mathbf c} + 2\sigma_0\dot{\mathbf c}) = \mathbf R^{\!\top}\mathbf f - \mathbf K_{rr}\,\mathbf c - (\mathbf A\mathbf R)^{\!\top}\boldsymbol\varphi, \qquad \ddot{\boldsymbol\varphi} + 2\sigma_0\dot{\boldsymbol\varphi} - 2\sigma_1\mathbf L_m\dot{\boldsymbol\varphi} = \mathbf M^{-1}\left(\mathbf f - \mathbf A\boldsymbol\varphi - \mathbf A\mathbf R\,\mathbf c\right) - \mathbf R\,(\ddot{\mathbf c} + 2\sigma_0\dot{\mathbf c})`,
113+ caption:
114+ "The two parts, with A = K_b + K_N + K_s, G = R'MR the rigid mass matrix, K_rr = R'AR the reduced stiffness (pendulum, thread springs, thread torsion) and f the applied and contact forces about rest. The last term of the flexural equation removes from it exactly the rigid acceleration, so phi stays flexural. The sigma1 loss, a model of losses that grow with spatial frequency, acts on the flexural part only.",
115+ },
116+ {
117+ tex: String.raw`(1+\sigma_0\Delta t)\,\boldsymbol\varphi^{n+1} = 2\boldsymbol\varphi^n - (1-\sigma_0\Delta t)\,\boldsymbol\varphi^{n-1} + \Delta t^2\,\mathbf a^n + 2\sigma_1\Delta t\,\mathbf L_m\!\left(\boldsymbol\varphi^n - \boldsymbol\varphi^{n-1}\right)`,
118+ caption:
119+ "Second-order explicit stepping of both parts, the same form for c; a^n is the flexural acceleration above at step n, and the contact forces and dashpots are evaluated at step n. The GPU port carries (phi, phi - phi_prev) instead of (phi, phi_prev), the same arithmetic rearranged so that the rounding of the field does not feed back into the velocity (Bilbao, Numerical Sound Synthesis, 2009, ch. 12, for the scheme).",
120+ },
121+ {
122+ tex: String.raw`\Delta t \le \frac{-2\sigma_1\lambda + \sqrt{4\sigma_1^2\lambda^2 + 4\left(\kappa^2\lambda^2 + \Omega^2\right)}}{\kappa^2\lambda^2 + \Omega^2}, \qquad \lambda = \frac{4}{\Delta x^2} + \frac{4}{\Delta y^2}, \qquad \Omega^2 = \max_i \frac{1}{M_i}\sum_j \left|\left(\mathbf K_N + \mathbf K_s + \mathbf K_c\right)_{ij}\right|`,
123+ caption:
124+ "Stability bound from von Neumann analysis of the bending scheme, lambda the largest eigenvalue of the five-point Laplacian, with the extra stiffness of the prestress, the springs and the engaged contacts bounded by Gershgorin's theorem and added as a rate Omega. Without the extras it is the familiar dt <= dx^2 / (4 kappa) of a square grid. Because dt scales with dx^2, halving the spacing costs eight times the work; and because a swing takes a second to show while the step is microseconds, a run is hundreds of thousands of steps, which is why the loop lives on the GPU.",
125+ },
126+ ],
127+};
128+
129+export const CURVED_EDGE_GROUP: EquationGroup = {
130+ title: "Elliptical plates",
131+ equations: [
132+ {
133+ tex: String.raw`\Omega_h = \left\{ (x_j, y_i) : \frac{(x_j - L_x/2)^2}{(L_x/2)^2} + \frac{(y_i - L_y/2)^2}{(L_y/2)^2} \le 1 + O(\Delta x) \right\}`,
134+ caption:
135+ "An ellipse is the set of grid nodes within half a spacing of the inscribed ellipse, so its edge is a staircase that straddles the true curve. The energy method assembles the free-edge conditions on that footprint as it does on a rectangle, and the geometry and the stress along the edge are first order in the spacing (second order inside); refining the grid is the check. Every edge in this lab is free unless a mount holds it, so the plate paradox of simply supported curved edges does not arise.",
136+ },
137+ ],
138+};
139+
140+export const STRESS_GROUP: EquationGroup = {
141+ title: "Stress",
142+ equations: [
143+ {
144+ tex: String.raw`\sigma_{xx} = -\frac{E z}{1-\nu^2}\left(w_{xx} + \nu w_{yy}\right),\quad \sigma_{yy} = -\frac{E z}{1-\nu^2}\left(w_{yy} + \nu w_{xx}\right),\quad \tau_{xy} = -\frac{E z}{1+\nu}\,w_{xy}`,
145+ caption:
146+ "Surface stresses at z = h/2 from the curvatures of the flexural part (the rigid-body motion has none). Stress is a second derivative of the primary unknown, so it inherits and amplifies the solution error. The in-plane self-weight stress N/h is a separate, static field, shown with the statics; for a hanging steel sheet it is tens of kilopascals against the megapascals of a strike.",
147+ },
148+ {
149+ tex: String.raw`\sigma_{vM} = \sqrt{\sigma_{xx}^2 - \sigma_{xx}\sigma_{yy} + \sigma_{yy}^2 + 3\tau_{xy}^2}, \qquad \sigma_{1,2} = \frac{\sigma_{xx}+\sigma_{yy}}{2} \pm \sqrt{\left(\frac{\sigma_{xx}-\sigma_{yy}}{2}\right)^2 + \tau_{xy}^2}`,
150+ caption:
151+ "Von Mises equivalent stress and principal stresses; the lab reports the principal stress of largest magnitude with its sign.",
152+ },
153+ ],
154+};
155+
156+export const RADIATION_GROUP: EquationGroup = {
157+ title: "Sound",
158+ equations: [
159+ {
160+ tex: String.raw`p(\mathbf r, t) = \frac{\rho_{\mathrm{air}}}{2\pi} \iint \frac{\ddot w\!\left(\mathbf r_s,\, t - |\mathbf r - \mathbf r_s|/c\right)}{|\mathbf r - \mathbf r_s|}\, dA`,
161+ caption:
162+ "Baffled: Rayleigh's first integral for a flat radiator in an infinite rigid baffle. Right for a plate set in a large table or wall, an upper estimate for one on a bench.",
163+ },
164+ {
165+ tex: String.raw`p \approx \frac{\rho_{\mathrm{air}}}{2\pi} \iint \left[ \frac{\ddot w(\mathbf r_s, t - R_f/c)}{R_f} - \frac{\ddot w(\mathbf r_s, t - R_b/c)}{R_b} \right] dA, \qquad R_b = |\mathbf r_s - \mathbf r_e| + |\mathbf r - \mathbf r_e|`,
166+ caption:
167+ "Unbaffled: a plate hanging in free air radiates from both faces with opposite signs, and at wavelengths longer than the plate the two cancel. The lab estimates this with the back face's contribution taken around the nearest point of the edge r_e. The cancellation is complete only towards zero frequency, and at high frequency, where the real diffracted field is weak, the two add incoherently and the estimate is a little loud: a rough model of the front-back cancellation, not a solution of the diffraction problem. Neither model knows about the room.",
168+ },
169+ ],
170+};
171+
172+export const ALL_GROUPS: EquationGroup[] = [
173+ GOVERNING,
174+ THREAD_GROUP,
175+ CONTACT_GROUP,
176+ VISE_GROUP,
177+ PINCH_GROUP,
178+ SCHEME_GROUP,
179+ CURVED_EDGE_GROUP,
180+ STRESS_GROUP,
181+ RADIATION_GROUP,
182+];
packages/core/src/methods/fd.tsadded+62−0View file
@@ -0,0 +1,62 @@
1+import type { MethodDefinition } from "./types";
2+import { ALL_GROUPS } from "./common";
3+
4+export const FD_METHOD: MethodDefinition = {
5+ id: "fd",
6+ name: "Explicit finite differences",
7+ shortName: "Explicit FD",
8+ summary:
9+ "Explicit second-order time stepping on the energy-method operators, rigid-body coordinates plus flexural field, gravity through the self-weight prestress.",
10+ solverFunction: "plate_fd",
11+ planFunction: "plate_fd_plan",
12+ supports: {
13+ shape: ["rectangle", "ellipse"],
14+ mount: ["thread", "threads", "pads", "cymbal", "vise", "pinched"],
15+ excitation: ["impulse", "release"],
16+ },
17+ readingOrder: [
18+ "plate_fd.m",
19+ "plate_fd_setup.m",
20+ "plate_fd_loop.m",
21+ "plate_fd_output.m",
22+ "plate_fd_plan.m",
23+ "plate_model.m",
24+ "plate_setup.m",
25+ "plate_domain.m",
26+ "plate_mount.m",
27+ "plate_bending.m",
28+ "plate_prestress.m",
29+ "plate_static.m",
30+ "plate_rigid_basis.m",
31+ "plate_force_time.m",
32+ "plate_force_footprint.m",
33+ "plate_stress.m",
34+ "plate_listener.m",
35+ "plate_spectrum.m",
36+ "plate_curvature_free.m",
37+ "plate_curvature_domain.m",
38+ "plate_result_schema.m",
39+ ],
40+ equationGroups: ALL_GROUPS,
41+ validity: [
42+ "Kirchhoff-Love thin-plate theory: no shear deformation or rotary inertia, so thick plates and very high modes are wrong.",
43+ "Small-angle theory throughout: the threads are linear springs, the pendulum motion is linearized, and the plate's in-plane motion (a swing in its own plane, a rotation about the normal) is not part of the model. A pull beyond a few degrees of swing or a hard strike on a light plate leaves this regime.",
44+ "Second-order truncation error in dx, dy and dt; the top modes on the grid are dispersive and their frequencies are too low. Free edges and curved edges are first order at the boundary.",
45+ "Mounts are idealizations: a thread has one tension, a pad or felt is a spring and dashpot per unit area with order-of-magnitude constants, a vise is perfectly rigid, a hand is a guess. The rod and the hole of a cymbal stand are not modelled, nor is the cymbal's curvature.",
46+ "The self-weight prestress is exact for the discrete model, but the stress at a hole or a jaw is the stress of a point or a line constraint on the grid, and depends on the spacing.",
47+ "Damping is a two-parameter model plus the mount's dashpots, chosen, not measured. The sigma1 loss acts on the flexural part only.",
48+ "The probe is snapped to the nearest grid node with a full stencil, and the series are averaged over each output sample.",
49+ "Stress fields are evaluated only at the saved frames, so the envelope samples the true peak; near a strike the stress depends on the load footprint.",
50+ "Radiated sound is an estimate of the waveform, not a calibrated level: Rayleigh's integral in a baffle, or a rough front-back cancellation for an unbaffled plate.",
51+ "With the GPU option the time loop runs in single precision (a WebGPU port of plate_fd_loop.m); setup, statics, stresses, sound and spectra stay in double precision in numbl. Rigid-body coordinates and flexural field are carried separately, which is what keeps the swing and the vibration both accurate in single precision.",
52+ ],
53+ durationOf: (spec) => spec.fd.duration,
54+ gridOf: (spec) => [spec.fd.nx, spec.fd.ny],
55+ usPerCellStep: 0.8,
56+ gpu: {
57+ loopFile: "plate_fd_loop.m",
58+ setupFunction: "plate_fd_setup",
59+ outputFunction: "plate_fd_output",
60+ usPerCellStep: 0.03,
61+ },
62+};
packages/core/src/methods/registry.tsadded+23−0View file
@@ -0,0 +1,23 @@
1+import type { ProblemSpec } from "../spec/types";
2+import type { MethodDefinition } from "./types";
3+import { FD_METHOD } from "./fd";
4+
5+export const METHODS: MethodDefinition[] = [FD_METHOD];
6+
7+export function getMethod(id: string): MethodDefinition {
8+ const m = METHODS.find((x) => x.id === id);
9+ if (!m) throw new Error(`unknown method ${id}`);
10+ return m;
11+}
12+
13+/** Reasons the method cannot run this spec, in plain words; empty when it can. */
14+export function methodBlockers(method: MethodDefinition, spec: ProblemSpec): string[] {
15+ const out: string[] = [];
16+ if (!method.supports.shape.includes(spec.plate.shape))
17+ out.push(`${method.name} has no ${spec.plate.shape} solution`);
18+ if (!method.supports.mount.includes(spec.mount.type))
19+ out.push(`${method.name} cannot handle the ${spec.mount.type} mount`);
20+ if (!method.supports.excitation.includes(spec.excitation.type))
21+ out.push(`${method.name} cannot handle the ${spec.excitation.type} excitation`);
22+ return out;
23+}
packages/core/src/methods/types.tsadded+59−0View file
@@ -0,0 +1,59 @@
1+import type { ExcitationType, MountType, PlateShape, ProblemSpec } from "../spec/types";
2+
3+export interface Equation {
4+ /** LaTeX, display mode */
5+ tex: string;
6+ caption: string;
7+}
8+
9+/** A titled group of equations, for the equations tab. */
10+export interface EquationGroup {
11+ title: string;
12+ /** Which mounts the group applies to; absent means all */
13+ mounts?: MountType[];
14+ equations: Equation[];
15+}
16+
17+/**
18+ * A solution method is data: which `.m` function to call, what it can handle,
19+ * the equations it discretizes, and what it approximates.
20+ */
21+export interface MethodDefinition {
22+ id: string;
23+ name: string;
24+ /** Two or three words, for tabs and other tight places */
25+ shortName: string;
26+ /** One sentence for tabs and tooltips */
27+ summary: string;
28+ /** Name of the `.m` function taking `params` and returning `result` */
29+ solverFunction: string;
30+ /** Optional `.m` function taking `params` and returning a cost plan */
31+ planFunction?: string;
32+ supports: {
33+ shape: PlateShape[];
34+ mount: MountType[];
35+ excitation: ExcitationType[];
36+ };
37+ /** Library files in reading order for this method (main.m is prepended by the code view) */
38+ readingOrder: string[];
39+ equationGroups: EquationGroup[];
40+ /** Plain statements of what is idealized or approximate, shown next to results */
41+ validity: string[];
42+ /** Duration key of the spec that this method simulates */
43+ durationOf: (spec: ProblemSpec) => number;
44+ gridOf: (spec: ProblemSpec) => [number, number];
45+ /** Starting guess for wall time per cell-step in microseconds, refined by measurements */
46+ usPerCellStep: number;
47+ /**
48+ * The method's time loop has a WebGPU port: the loop file it mirrors (an edit
49+ * there sends the run back to numbl) and the setup and output functions the
50+ * host runs in numbl around it.
51+ */
52+ gpu?: {
53+ loopFile: string;
54+ setupFunction: string;
55+ outputFunction: string;
56+ /** Starting guess for the GPU path, per cell-step, refined by measurements */
57+ usPerCellStep: number;
58+ };
59+}
packages/core/src/node.tsadded+5−0View file
@@ -0,0 +1,5 @@
1+/** Node-only entry: the in-process runner used by tests and scripts. */
2+export { NodeRunner } from "./runner/nodeRunner";
3+export { executeBundle, numblVersion } from "./runner/execute";
4+export { executeRequest, type GpuProvider } from "./runner/executeGpu";
5+export { requestGpuDevice, runFdLoopGpu, type FdLoopSetup, type FdLoopRecord } from "./gpu/fdLoop";
packages/core/src/results/parse.tsadded+259−0View file
@@ -0,0 +1,259 @@
1+import {
2+ isPlainCell,
3+ isPlainStruct,
4+ isPlainTensor,
5+ type PlainStruct,
6+ type PlainValue,
7+} from "../runner/plain";
8+import type { Field2D, FieldFrames } from "../fields/field";
9+import type {
10+ MethodPlan,
11+ PlateResult,
12+ ResultInfo,
13+ RigidResult,
14+ StaticResult,
15+ SupportsResult,
16+} from "./types";
17+
18+export class ResultShapeError extends Error {}
19+
20+function field(s: PlainStruct, name: string): PlainValue {
21+ const v = s.fields[name];
22+ if (v === undefined) throw new ResultShapeError(`result is missing field "${name}"`);
23+ return v;
24+}
25+
26+function struct(s: PlainStruct, name: string): PlainStruct {
27+ const v = field(s, name);
28+ if (!isPlainStruct(v)) throw new ResultShapeError(`field "${name}" should be a struct`);
29+ return v;
30+}
31+
32+function num(s: PlainStruct, name: string): number {
33+ const v = field(s, name);
34+ if (typeof v === "number") return v;
35+ if (typeof v === "boolean") return v ? 1 : 0;
36+ if (isPlainTensor(v) && v.data.length === 1) return v.data[0];
37+ throw new ResultShapeError(`field "${name}" should be a scalar`);
38+}
39+
40+function bool(s: PlainStruct, name: string): boolean {
41+ return num(s, name) !== 0;
42+}
43+
44+function str(s: PlainStruct, name: string): string {
45+ const v = field(s, name);
46+ if (typeof v === "string") return v;
47+ throw new ResultShapeError(`field "${name}" should be a string`);
48+}
49+
50+function vec(s: PlainStruct, name: string): Float64Array {
51+ const v = field(s, name);
52+ if (typeof v === "number") return Float64Array.of(v);
53+ if (isPlainTensor(v)) return v.data;
54+ throw new ResultShapeError(`field "${name}" should be a numeric vector`);
55+}
56+
57+/** A numeric vector that may be absent or empty. */
58+function vecOpt(s: PlainStruct, name: string): Float64Array {
59+ const v = s.fields[name];
60+ if (v === undefined) return new Float64Array(0);
61+ if (typeof v === "number") return Float64Array.of(v);
62+ if (isPlainTensor(v)) return v.data;
63+ return new Float64Array(0);
64+}
65+
66+function field2d(s: PlainStruct, name: string): Field2D {
67+ const v = field(s, name);
68+ if (!isPlainTensor(v) || v.shape.length !== 2)
69+ throw new ResultShapeError(`field "${name}" should be a 2-D array`);
70+ return { ny: v.shape[0], nx: v.shape[1], data: v.data };
71+}
72+
73+function field2dOpt(s: PlainStruct, name: string): Field2D | undefined {
74+ const v = s.fields[name];
75+ if (!isPlainTensor(v) || v.shape.length !== 2) return undefined;
76+ return { ny: v.shape[0], nx: v.shape[1], data: v.data };
77+}
78+
79+function frames3d(s: PlainStruct, name: string): FieldFrames {
80+ const v = field(s, name);
81+ if (!isPlainTensor(v)) throw new ResultShapeError(`field "${name}" should be a 3-D array`);
82+ const [ny, nx, nf] = [v.shape[0] ?? 0, v.shape[1] ?? 0, v.shape[2] ?? 1];
83+ if (v.shape.length < 2 || v.shape.length > 3)
84+ throw new ResultShapeError(`field "${name}" should be a 3-D array`);
85+ return { ny, nx, nf, data: v.data };
86+}
87+
88+/** Rows of an m x n column-major matrix as m separate arrays. */
89+function rows(s: PlainStruct, name: string, m: number): Float64Array[] {
90+ const v = s.fields[name];
91+ if (m === 0 || v === undefined || !isPlainTensor(v)) return [];
92+ const [mm, n] = [v.shape[0] ?? 0, v.shape[1] ?? 0];
93+ if (mm !== m) throw new ResultShapeError(`field "${name}" should have ${m} rows`);
94+ const out: Float64Array[] = [];
95+ for (let k = 0; k < m; k++) {
96+ const r = new Float64Array(n);
97+ for (let j = 0; j < n; j++) r[j] = v.data[k + j * m];
98+ out.push(r);
99+ }
100+ return out;
101+}
102+
103+function strings(s: PlainStruct, name: string): string[] {
104+ const v = s.fields[name];
105+ if (v === undefined) return [];
106+ if (typeof v === "string") return [v];
107+ if (isPlainCell(v)) return v.items.map((x) => (typeof x === "string" ? x : JSON.stringify(x)));
108+ return [];
109+}
110+
111+const INFO_STANDARD = new Set([
112+ "nsteps",
113+ "ncells",
114+ "dt",
115+ "fs",
116+ "wall_s",
117+ "stable",
118+ "diverged_step",
119+ "notes",
120+]);
121+
122+function parseInfo(s: PlainStruct): ResultInfo {
123+ const extra: Record<string, number | string | boolean> = {};
124+ for (const [k, v] of Object.entries(s.fields)) {
125+ if (INFO_STANDARD.has(k)) continue;
126+ if (typeof v === "number" || typeof v === "string" || typeof v === "boolean") extra[k] = v;
127+ else if (isPlainTensor(v) && v.data.length === 1) extra[k] = v.data[0];
128+ }
129+ return {
130+ nsteps: num(s, "nsteps"),
131+ ncells: num(s, "ncells"),
132+ dt: num(s, "dt"),
133+ fs: num(s, "fs"),
134+ wall_s: num(s, "wall_s"),
135+ stable: bool(s, "stable"),
136+ diverged_step: num(s, "diverged_step"),
137+ notes: strings(s, "notes"),
138+ extra,
139+ };
140+}
141+
142+function parseRigid(s: PlainStruct, framesStruct: PlainStruct): RigidResult {
143+ const nr = num(s, "nr");
144+ const ed = vec(s, "ed");
145+ const eh = vec(s, "eh");
146+ return {
147+ nr,
148+ names: strings(s, "names"),
149+ series: rows(s, "series", nr),
150+ frames: rows(framesStruct, "rigid", nr),
151+ pivot: { x: num(s, "pivot_x"), y: num(s, "pivot_y") },
152+ ed: [ed[0] ?? 1, ed[1] ?? 0],
153+ eh: [eh[0] ?? 0, eh[1] ?? 1],
154+ hz: Array.from(vecOpt(s, "hz")),
155+ modeNames: strings(s, "mode_names"),
156+ };
157+}
158+
159+function parseSupports(s: PlainStruct): SupportsResult {
160+ const n = num(s, "n");
161+ return {
162+ names: strings(s, "names"),
163+ units: strings(s, "units"),
164+ kinds: strings(s, "kinds"),
165+ series: rows(s, "series", n),
166+ static: Array.from(vecOpt(s, "static")).slice(0, n),
167+ };
168+}
169+
170+function parseStatic(s: PlainStruct): StaticResult {
171+ return {
172+ w0: field2d(s, "w0"),
173+ sag: num(s, "sag"),
174+ gx: num(s, "gx"),
175+ gy: num(s, "gy"),
176+ gz: num(s, "gz"),
177+ g: num(s, "g"),
178+ mass: num(s, "mass"),
179+ xc: num(s, "xc"),
180+ yc: num(s, "yc"),
181+ held: field2d(s, "held"),
182+ Nxx: field2dOpt(s, "Nxx"),
183+ Nyy: field2dOpt(s, "Nyy"),
184+ Nxy: field2dOpt(s, "Nxy"),
185+ };
186+}
187+
188+/** Validate and type the solver's result struct. Throws ResultShapeError with a specific message. */
189+export function parsePlateResult(v: PlainValue): PlateResult {
190+ if (!isPlainStruct(v)) throw new ResultShapeError("the result variable is not a struct");
191+ const grid = struct(v, "grid");
192+ const probe = struct(v, "probe");
193+ const frames = struct(v, "frames");
194+ const env = struct(v, "envelope");
195+ const spec = struct(v, "spectrum");
196+ const audio = struct(v, "audio");
197+ return {
198+ method: str(v, "method"),
199+ grid: { x: vec(grid, "x"), y: vec(grid, "y") },
200+ t: vec(v, "t"),
201+ probe: {
202+ x: num(probe, "x"),
203+ y: num(probe, "y"),
204+ w: vec(probe, "w"),
205+ sxx: vec(probe, "sxx"),
206+ syy: vec(probe, "syy"),
207+ txy: vec(probe, "txy"),
208+ vm: vec(probe, "vm"),
209+ sp: vec(probe, "sp"),
210+ },
211+ frames: {
212+ t: vec(frames, "t"),
213+ w: frames3d(frames, "w"),
214+ w_total: frames3d(frames, "w_total"),
215+ vm: frames3d(frames, "vm"),
216+ sp: frames3d(frames, "sp"),
217+ },
218+ envelope: { vm: field2d(env, "vm"), sp: field2d(env, "sp") },
219+ spectrum: { f: vec(spec, "f"), w: vec(spec, "w"), vm: vec(spec, "vm") },
220+ audio: {
221+ fs: num(audio, "fs"),
222+ p: vec(audio, "p"),
223+ t0: num(audio, "t0"),
224+ model: str(audio, "model"),
225+ },
226+ rigid: parseRigid(struct(v, "rigid"), frames),
227+ supports: parseSupports(struct(v, "supports")),
228+ static: parseStatic(struct(v, "static")),
229+ info: parseInfo(struct(v, "info")),
230+ };
231+}
232+
233+export function parseMethodPlan(v: PlainValue): MethodPlan {
234+ if (!isPlainStruct(v)) throw new ResultShapeError("the plan variable is not a struct");
235+ return {
236+ nx: num(v, "nx"),
237+ ny: num(v, "ny"),
238+ dx: num(v, "dx"),
239+ dy: num(v, "dy"),
240+ dt_max: num(v, "dt_max"),
241+ dt: num(v, "dt"),
242+ nsteps: num(v, "nsteps"),
243+ ncells: num(v, "ncells"),
244+ nunknowns: num(v, "nunknowns"),
245+ cell_steps: num(v, "cell_steps"),
246+ rigid_hz: Array.from(vecOpt(v, "rigid_hz")),
247+ rigid_names: strings(v, "rigid_names"),
248+ rigid_mode_names: strings(v, "rigid_mode_names"),
249+ sag: num(v, "sag"),
250+ gx: num(v, "gx"),
251+ gy: num(v, "gy"),
252+ gz: num(v, "gz"),
253+ mass: num(v, "mass"),
254+ xc: num(v, "xc"),
255+ yc: num(v, "yc"),
256+ tension: Array.from(vecOpt(v, "tension")),
257+ notes: strings(v, "notes"),
258+ };
259+}
packages/core/src/results/types.tsadded+128−0View file
@@ -0,0 +1,128 @@
1+import type { Field2D, FieldFrames } from "../fields/field";
2+
3+export interface ProbeSeries {
4+ x: number;
5+ y: number;
6+ /** Total departure from the static equilibrium at the probe [m] */
7+ w: Float64Array;
8+ sxx: Float64Array;
9+ syy: Float64Array;
10+ txy: Float64Array;
11+ vm: Float64Array;
12+ sp: Float64Array;
13+}
14+
15+export interface ResultInfo {
16+ nsteps: number;
17+ ncells: number;
18+ dt: number;
19+ fs: number;
20+ /** Wall time measured inside the .m with tic/toc [s] */
21+ wall_s: number;
22+ stable: boolean;
23+ diverged_step: number;
24+ notes: string[];
25+ /** Method-specific extras (nx, ny, dt_max, cfl, cell_steps, loop, mount, ...) */
26+ extra: Record<string, number | string | boolean>;
27+}
28+
29+/** The rigid-body motion the mount leaves the plate: coordinates in time and at the frames. */
30+export interface RigidResult {
31+ nr: number;
32+ names: string[];
33+ /** nr x nt, row-major by coordinate: series[k] is coordinate k over time */
34+ series: Float64Array[];
35+ /** Coordinates at the saved frames, frames[k][f] */
36+ frames: Float64Array[];
37+ pivot: { x: number; y: number };
38+ /** In-plane unit vectors: down and horizontal, in plate coordinates */
39+ ed: [number, number];
40+ eh: [number, number];
41+ /** Frequencies of the rigid-body modes [Hz], ascending; NaN for an unstable one */
42+ hz: number[];
43+ /** The coordinate that dominates each mode */
44+ modeNames: string[];
45+}
46+
47+export interface SupportsResult {
48+ names: string[];
49+ units: string[];
50+ kinds: string[];
51+ /** Force of each support over time, series[k] */
52+ series: Float64Array[];
53+ /** The same at rest */
54+ static: number[];
55+}
56+
57+export interface StaticResult {
58+ /** Deflection at rest under gravity [m], NaN off the plate */
59+ w0: Field2D;
60+ sag: number;
61+ /** Direction of gravity in plate coordinates */
62+ gx: number;
63+ gy: number;
64+ gz: number;
65+ g: number;
66+ mass: number;
67+ xc: number;
68+ yc: number;
69+ /** Nodes the mount holds still (1) */
70+ held: Field2D;
71+ /** Self-weight stress resultants [N/m], when the plate hangs */
72+ Nxx?: Field2D;
73+ Nyy?: Field2D;
74+ Nxy?: Field2D;
75+}
76+
77+/** Typed view of the `result` struct the solver returns (see plate_result_schema.m). */
78+export interface PlateResult {
79+ method: string;
80+ grid: { x: Float64Array; y: Float64Array };
81+ t: Float64Array;
82+ probe: ProbeSeries;
83+ frames: {
84+ t: Float64Array;
85+ /** Flexural field */
86+ w: FieldFrames;
87+ /** Flexural field plus rigid-body motion */
88+ w_total: FieldFrames;
89+ vm: FieldFrames;
90+ sp: FieldFrames;
91+ };
92+ envelope: { vm: Field2D; sp: Field2D };
93+ spectrum: { f: Float64Array; w: Float64Array; vm: Float64Array };
94+ audio: { fs: number; p: Float64Array; t0: number; model: string };
95+ rigid: RigidResult;
96+ supports: SupportsResult;
97+ static: StaticResult;
98+ info: ResultInfo;
99+}
100+
101+/** Cost plan and statics of the scheme before a run (plate_fd_plan). */
102+export interface MethodPlan {
103+ nx: number;
104+ ny: number;
105+ dx: number;
106+ dy: number;
107+ dt_max: number;
108+ dt: number;
109+ nsteps: number;
110+ ncells: number;
111+ nunknowns: number;
112+ cell_steps: number;
113+ /** Frequencies of the rigid-body modes the mount leaves [Hz], NaN if unstable */
114+ rigid_hz: number[];
115+ rigid_names: string[];
116+ rigid_mode_names: string[];
117+ /** Largest static deflection [m] */
118+ sag: number;
119+ gx: number;
120+ gy: number;
121+ gz: number;
122+ mass: number;
123+ xc: number;
124+ yc: number;
125+ /** Thread tensions [N], when there are threads */
126+ tension: number[];
127+ notes: string[];
128+}
packages/core/src/runner/convert.tsadded+69−0View file
@@ -0,0 +1,69 @@
1+import { RTV } from "numbl";
2+import type { RuntimeValue } from "numbl";
3+import type { PlainValue } from "./plain";
4+
5+type Kinded = { kind?: string };
6+
7+/** numbl RuntimeValue -> PlainValue. Tensor data is copied so the result owns its memory. */
8+export function toPlain(v: RuntimeValue | undefined): PlainValue {
9+ if (v === undefined) return { kind: "unsupported", description: "undefined" };
10+ if (typeof v === "number" || typeof v === "boolean" || typeof v === "string") return v;
11+ const k = (v as Kinded).kind;
12+ switch (k) {
13+ case "tensor": {
14+ const t = v as { data: Float64Array; imag?: Float64Array; shape: number[] };
15+ const out: PlainValue = {
16+ kind: "tensor",
17+ shape: [...t.shape],
18+ data: Float64Array.from(t.data),
19+ };
20+ if (t.imag) out.imag = Float64Array.from(t.imag);
21+ return out;
22+ }
23+ case "char":
24+ return (v as { value: string }).value;
25+ case "struct": {
26+ const fields: Record<string, PlainValue> = {};
27+ for (const [name, val] of (v as { fields: Map<string, RuntimeValue> }).fields)
28+ fields[name] = toPlain(val);
29+ return { kind: "struct", fields };
30+ }
31+ case "cell": {
32+ const c = v as { data: RuntimeValue[]; shape: number[] };
33+ return { kind: "cell", shape: [...c.shape], items: c.data.map(toPlain) };
34+ }
35+ case "struct_array": {
36+ // struct(...) with a cell-valued field builds a struct array in MATLAB; a single
37+ // element is the common case (e.g. 'notes', {notes}) and reads as a plain struct.
38+ const sa = v as { elements: RuntimeValue[] };
39+ if (sa.elements.length === 1) return toPlain(sa.elements[0]);
40+ return { kind: "cell", shape: [1, sa.elements.length], items: sa.elements.map(toPlain) };
41+ }
42+ case "complex":
43+ return { kind: "unsupported", description: "complex scalar" };
44+ default:
45+ return { kind: "unsupported", description: k ?? typeof v };
46+ }
47+}
48+
49+/** PlainValue -> numbl RuntimeValue, for passing results back into a script (e.g. plate_compare). */
50+export function toRuntime(v: PlainValue): RuntimeValue {
51+ if (typeof v === "number" || typeof v === "boolean" || typeof v === "string") return v;
52+ switch (v.kind) {
53+ case "tensor":
54+ return RTV.tensor(
55+ Float64Array.from(v.data),
56+ [...v.shape],
57+ v.imag ? Float64Array.from(v.imag) : undefined,
58+ );
59+ case "struct": {
60+ const fields: Record<string, RuntimeValue> = {};
61+ for (const [name, val] of Object.entries(v.fields)) fields[name] = toRuntime(val);
62+ return RTV.struct(fields);
63+ }
64+ case "cell":
65+ return RTV.cell(v.items.map(toRuntime), [...v.shape]);
66+ case "unsupported":
67+ throw new Error(`cannot convert unsupported value (${v.description}) to numbl`);
68+ }
69+}
packages/core/src/runner/execute.tsadded+89−0View file
@@ -0,0 +1,89 @@
1+import { executeCode, RuntimeError } from "numbl";
2+import type { RuntimeValue } from "numbl";
3+import type { RunRequest, RunResult, BackendInfo } from "./types";
4+import { toPlain, toRuntime } from "./convert";
5+
6+const OPTIMIZATION = "1" as const;
7+
8+declare const __NUMBL_VERSION__: string | undefined;
9+
10+/**
11+ * numbl's package version, injected at build time by the host's Vite config
12+ * (see vite.shared.ts); numbl's own `version` builtin reports the MATLAB
13+ * release it emulates, which is not what "which backend ran it" means.
14+ */
15+export function numblVersion(): string {
16+ return typeof __NUMBL_VERSION__ === "string" ? __NUMBL_VERSION__ : "unknown";
17+}
18+
19+/**
20+ * Run a bundle synchronously in the current thread. Both the Node runner and
21+ * the worker call this, so the two hosts execute identically.
22+ */
23+export function executeBundle(
24+ req: RunRequest,
25+ backend: BackendInfo,
26+ onOutput?: (text: string) => void,
27+): RunResult {
28+ const { bundle } = req;
29+ const entry = bundle.files.find((f) => f.name === bundle.entry);
30+ const output: string[] = [];
31+ const t0 = performance.now();
32+ if (!entry) {
33+ return {
34+ ok: false,
35+ error: `entry file ${bundle.entry} is not in the bundle`,
36+ output,
37+ wallMs: 0,
38+ backend,
39+ };
40+ }
41+ const workspaceFiles = bundle.files
42+ .filter((f) => f.name !== bundle.entry)
43+ .map((f) => ({ name: f.name, source: f.source }));
44+ const initialVariableValues: Record<string, RuntimeValue> = {};
45+ for (const [name, v] of Object.entries(req.inputs ?? {}))
46+ initialVariableValues[name] = toRuntime(v);
47+ try {
48+ const result = executeCode(
49+ entry.source,
50+ {
51+ optimization: OPTIMIZATION,
52+ displayResults: false,
53+ initialVariableValues,
54+ onOutput: (text: string) => {
55+ output.push(text);
56+ onOutput?.(text);
57+ },
58+ },
59+ workspaceFiles,
60+ bundle.entry,
61+ );
62+ const wallMs = performance.now() - t0;
63+ const value = result.variableValues[bundle.resultVariable];
64+ if (value === undefined) {
65+ return {
66+ ok: false,
67+ error: `the script finished but did not define the variable "${bundle.resultVariable}"`,
68+ output,
69+ wallMs,
70+ backend,
71+ };
72+ }
73+ return { ok: true, value: toPlain(value), output, wallMs, backend };
74+ } catch (e) {
75+ const wallMs = performance.now() - t0;
76+ return { ok: false, error: formatError(e), output, wallMs, backend };
77+ }
78+}
79+
80+export function formatError(e: unknown): string {
81+ if (e instanceof RuntimeError) {
82+ const where = [e.file, e.line]
83+ .filter((x) => x !== undefined && x !== null && x !== "")
84+ .join(":");
85+ return where ? `${where}: ${e.message}` : e.message;
86+ }
87+ if (e instanceof Error) return e.message;
88+ return String(e);
89+}
packages/core/src/runner/executeGpu.tsadded+121−0View file
@@ -0,0 +1,121 @@
1+import type { BackendInfo, ComputeInfo, RunRequest, RunResult } from "./types";
2+import { executeBundle, formatError } from "./execute";
3+import { LIBRARY_FILES } from "../generate/library";
4+import { runFdLoopGpu, type GpuHandle } from "../gpu/fdLoop";
5+import { fdLoopRecordToPlain, fdLoopSetupFromPlain } from "../gpu/fdStage";
6+
7+/** Supplies the (cached) WebGPU device of the host, or rejects when there is none. */
8+export type GpuProvider = () => Promise<GpuHandle>;
9+
10+/**
11+ * Run a request, on the GPU when it asks for that and the host has one. The
12+ * plain path is executeBundle. The staged path runs the setup script in numbl,
13+ * the loop on the GPU and the output script in numbl, and reports where the
14+ * loop ran in `compute`; when the GPU was asked for but cannot be used, the
15+ * whole bundle runs in numbl and `compute.note` says why.
16+ */
17+export async function executeRequest(
18+ req: RunRequest,
19+ backend: BackendInfo,
20+ onOutput?: (text: string) => void,
21+ gpu?: GpuProvider,
22+): Promise<RunResult> {
23+ const stages = req.gpu;
24+ if (!stages) return executeBundle(req, backend, onOutput);
25+ const fallback = (note: string): RunResult => ({
26+ ...executeBundle(req, backend, onOutput),
27+ compute: { loop: "numbl", note },
28+ });
29+
30+ if (!gpu) return fallback("WebGPU is not available here, so the time loop ran in numbl");
31+ const shipped = LIBRARY_FILES.find((f) => f.name === stages.loopFile)?.source;
32+ const current = req.bundle.files.find((f) => f.name === stages.loopFile)?.source;
33+ if (shipped !== undefined && current !== shipped)
34+ return fallback(
35+ `${stages.loopFile} was edited, so the time loop ran in numbl, where the edit is live`,
36+ );
37+ let handle: GpuHandle;
38+ try {
39+ handle = await gpu();
40+ } catch (e) {
41+ return fallback(`no WebGPU device (${formatError(e)}), so the time loop ran in numbl`);
42+ }
43+
44+ const t0 = performance.now();
45+ const output: string[] = [];
46+ const say = (text: string) => {
47+ output.push(text);
48+ onOutput?.(text);
49+ };
50+ const libs = req.bundle.files.filter((f) => f.name !== req.bundle.entry);
51+ const fail = (error: string, compute?: ComputeInfo): RunResult => ({
52+ ok: false,
53+ error,
54+ output,
55+ wallMs: performance.now() - t0,
56+ backend,
57+ compute,
58+ });
59+
60+ const setupRun = executeBundle(
61+ {
62+ bundle: {
63+ files: [{ name: "gpu_setup.m", source: stages.setup }, ...libs],
64+ entry: "gpu_setup.m",
65+ resultVariable: "S",
66+ },
67+ },
68+ backend,
69+ say,
70+ );
71+ if (!setupRun.ok) return fail(setupRun.error);
72+ let setup;
73+ try {
74+ setup = fdLoopSetupFromPlain(setupRun.value);
75+ } catch (e) {
76+ return fail(`the setup stage returned an unexpected shape: ${formatError(e)}`);
77+ }
78+
79+ let lastPct = 0;
80+ let rec;
81+ try {
82+ rec = await runFdLoopGpu(handle.device, setup, (done, total) => {
83+ const pct = Math.floor((10 * done) / total);
84+ if (pct > lastPct && done < total) {
85+ lastPct = pct;
86+ say(`gpu: ${done.toLocaleString()} of ${total.toLocaleString()} steps\n`);
87+ }
88+ });
89+ } catch (e) {
90+ return fail(`WebGPU loop: ${formatError(e)}`, { loop: "webgpu", adapter: handle.adapter });
91+ }
92+
93+ const Rec = fdLoopRecordToPlain(rec, setup, handle.adapter);
94+ const outRun = executeBundle(
95+ {
96+ bundle: {
97+ files: [{ name: "gpu_output.m", source: stages.output }, ...libs],
98+ entry: "gpu_output.m",
99+ resultVariable: req.bundle.resultVariable,
100+ },
101+ inputs: { ...(req.inputs ?? {}), Rec },
102+ },
103+ backend,
104+ say,
105+ );
106+ const compute: ComputeInfo = {
107+ loop: "webgpu",
108+ adapter: handle.adapter,
109+ gpuMs: rec.gpuMs,
110+ numblMs: setupRun.wallMs + outRun.wallMs,
111+ };
112+ if (!outRun.ok) return fail(outRun.error, compute);
113+ return {
114+ ok: true,
115+ value: outRun.value,
116+ output,
117+ wallMs: performance.now() - t0,
118+ backend,
119+ compute,
120+ };
121+}
packages/core/src/runner/nodeRunner.tsadded+33−0View file
@@ -0,0 +1,33 @@
1+import type { BackendInfo, RunRequest, RunResult, Runner } from "./types";
2+import { numblVersion } from "./execute";
3+import { executeRequest, type GpuProvider } from "./executeGpu";
4+
5+/**
6+ * Runs numbl in the current Node process. Used by tests and scripts; cannot
7+ * cancel. Pass `gpu` to run the ported time loop on a WebGPU device (the
8+ * optional `webgpu` package, Google Dawn, provides one under Node).
9+ */
10+export class NodeRunner implements Runner {
11+ private readonly info: BackendInfo = {
12+ engine: "numbl",
13+ version: numblVersion(),
14+ optimization: "1",
15+ host: "node",
16+ };
17+ readonly backend = Promise.resolve(this.info);
18+ private readonly gpu?: GpuProvider;
19+
20+ constructor(opts: { gpu?: GpuProvider } = {}) {
21+ this.gpu = opts.gpu;
22+ }
23+
24+ run(req: RunRequest, onOutput?: (text: string) => void): Promise<RunResult> {
25+ return executeRequest(req, this.info, onOutput, this.gpu);
26+ }
27+ cancel(): void {
28+ /* synchronous host: nothing in flight between awaits */
29+ }
30+ dispose(): void {
31+ /* nothing to release */
32+ }
33+}
packages/core/src/runner/plain.tsadded+49−0View file
@@ -0,0 +1,49 @@
1+/**
2+ * Plain, structured-clone-friendly mirror of the numbl values the lab uses.
3+ * Tensors keep numbl's column-major layout: element (i, j) of an [m, n] array is
4+ * data[i + j*m], and (i, j, k) of an [m, n, p] array is data[i + j*m + k*m*n].
5+ */
6+export interface PlainTensor {
7+ kind: "tensor";
8+ shape: number[];
9+ data: Float64Array;
10+ imag?: Float64Array;
11+}
12+export interface PlainStruct {
13+ kind: "struct";
14+ fields: Record<string, PlainValue>;
15+}
16+export interface PlainCell {
17+ kind: "cell";
18+ shape: number[];
19+ items: PlainValue[];
20+}
21+export interface PlainUnsupported {
22+ kind: "unsupported";
23+ description: string;
24+}
25+export type PlainValue =
26+ number | boolean | string | PlainTensor | PlainStruct | PlainCell | PlainUnsupported;
27+
28+export function isPlainTensor(v: PlainValue | undefined): v is PlainTensor {
29+ return typeof v === "object" && v !== null && v.kind === "tensor";
30+}
31+export function isPlainStruct(v: PlainValue | undefined): v is PlainStruct {
32+ return typeof v === "object" && v !== null && v.kind === "struct";
33+}
34+export function isPlainCell(v: PlainValue | undefined): v is PlainCell {
35+ return typeof v === "object" && v !== null && v.kind === "cell";
36+}
37+
38+/** All ArrayBuffers inside a value, for zero-copy transfer out of a worker. */
39+export function collectBuffers(v: PlainValue, out: ArrayBuffer[] = []): ArrayBuffer[] {
40+ if (isPlainTensor(v)) {
41+ out.push(v.data.buffer as ArrayBuffer);
42+ if (v.imag) out.push(v.imag.buffer as ArrayBuffer);
43+ } else if (isPlainStruct(v)) {
44+ for (const f of Object.values(v.fields)) collectBuffers(f, out);
45+ } else if (isPlainCell(v)) {
46+ for (const item of v.items) collectBuffers(item, out);
47+ }
48+ return out;
49+}
packages/core/src/runner/protocol.tsadded+18−0View file
@@ -0,0 +1,18 @@
1+import type { SourceBundle } from "../generate/bundle";
2+import type { PlainValue } from "./plain";
3+import type { BackendInfo, GpuStages, RunResult } from "./types";
4+
5+export type ToWorker =
6+ | {
7+ type: "run";
8+ id: number;
9+ bundle: SourceBundle;
10+ inputs?: Record<string, PlainValue>;
11+ gpu?: GpuStages;
12+ }
13+ | { type: "info"; id: number };
14+
15+export type FromWorker =
16+ | { type: "output"; id: number; text: string }
17+ | { type: "done"; id: number; result: RunResult }
18+ | { type: "info"; id: number; backend: BackendInfo };
packages/core/src/runner/types.tsadded+76−0View file
@@ -0,0 +1,76 @@
1+import type { SourceBundle } from "../generate/bundle";
2+import type { PlainValue } from "./plain";
3+
4+export interface BackendInfo {
5+ engine: "numbl";
6+ version: string;
7+ /** numbl optimization level: "1" is the JS JIT, "0" the interpreter */
8+ optimization: "0" | "1";
9+ /** Where the code ran */
10+ host: "web-worker" | "node";
11+}
12+
13+/**
14+ * A run whose time loop may leave numbl: the host runs `setup` in numbl, hands
15+ * the struct S to its WebGPU port of `loopFile`, and runs `output` in numbl
16+ * with the loop's record as the workspace variable R. Both scripts carry the
17+ * same params block as the bundle's entry.
18+ */
19+export interface GpuStages {
20+ setup: string;
21+ output: string;
22+ /** The library file whose port runs on the GPU; an edit to it falls back to numbl */
23+ loopFile: string;
24+}
25+
26+export interface RunRequest {
27+ bundle: SourceBundle;
28+ /** Workspace variables defined before the script runs */
29+ inputs?: Record<string, PlainValue>;
30+ /** Ask for the time loop on the GPU; ignored by a runner without one */
31+ gpu?: GpuStages;
32+}
33+
34+/** Where the time loop of a staged run actually ran, and why, when it was asked to leave numbl. */
35+export interface ComputeInfo {
36+ loop: "webgpu" | "numbl";
37+ adapter?: string;
38+ /** Why the loop stayed in numbl although the GPU was asked for */
39+ note?: string;
40+ /** Time in the GPU loop and in the numbl setup and output stages [ms] */
41+ gpuMs?: number;
42+ numblMs?: number;
43+}
44+
45+export interface RunSuccess {
46+ ok: true;
47+ value: PlainValue;
48+ /** Everything the script printed */
49+ output: string[];
50+ /** Wall time of executeCode, including JIT compilation [ms] */
51+ wallMs: number;
52+ backend: BackendInfo;
53+ compute?: ComputeInfo;
54+}
55+
56+export interface RunFailure {
57+ ok: false;
58+ /** Formatted numbl error, with file and line when known */
59+ error: string;
60+ output: string[];
61+ wallMs: number;
62+ backend: BackendInfo;
63+ compute?: ComputeInfo;
64+ /** True when the run was cancelled by the host rather than failing */
65+ cancelled?: boolean;
66+}
67+
68+export type RunResult = RunSuccess | RunFailure;
69+
70+export interface Runner {
71+ run(req: RunRequest, onOutput?: (text: string) => void): Promise<RunResult>;
72+ /** Abandon the run in flight, if any. Its promise resolves with cancelled: true. */
73+ cancel(): void;
74+ dispose(): void;
75+ readonly backend: Promise<BackendInfo>;
76+}
packages/core/src/runner/worker.tsadded+57−0View file
@@ -0,0 +1,57 @@
1+/// <reference lib="webworker" />
2+/**
3+ * Web Worker that owns a numbl instance and, when the browser exposes WebGPU
4+ * to workers, a GPU device for the ported time loop. One run at a time; the
5+ * host cancels a run by terminating the worker, which is why nothing here
6+ * needs to be interruptible.
7+ */
8+import { numblVersion } from "./execute";
9+import { executeRequest, type GpuProvider } from "./executeGpu";
10+import { requestGpuDevice, type GpuHandle } from "../gpu/fdLoop";
11+import { collectBuffers } from "./plain";
12+import type { FromWorker, ToWorker } from "./protocol";
13+import type { BackendInfo } from "./types";
14+
15+const backend: BackendInfo = {
16+ engine: "numbl",
17+ version: numblVersion(),
18+ optimization: "1",
19+ host: "web-worker",
20+};
21+
22+const post = (msg: FromWorker, transfer: ArrayBuffer[] = []) => self.postMessage(msg, transfer);
23+
24+// The device is requested once and kept; a lost device is requested again next time.
25+let gpuHandle: Promise<GpuHandle> | null = null;
26+const gpuProvider: GpuProvider = () => {
27+ if (!gpuHandle) {
28+ gpuHandle = requestGpuDevice().then((h) => {
29+ void h.device.lost.then(() => {
30+ gpuHandle = null;
31+ });
32+ return h;
33+ });
34+ gpuHandle.catch(() => {
35+ gpuHandle = null;
36+ });
37+ }
38+ return gpuHandle;
39+};
40+const hasWebGpu =
41+ typeof navigator !== "undefined" && "gpu" in navigator && !!(navigator as { gpu?: unknown }).gpu;
42+
43+self.onmessage = async (e: MessageEvent<ToWorker>) => {
44+ const msg = e.data;
45+ if (msg.type === "info") {
46+ post({ type: "info", id: msg.id, backend });
47+ return;
48+ }
49+ const result = await executeRequest(
50+ { bundle: msg.bundle, inputs: msg.inputs, gpu: msg.gpu },
51+ backend,
52+ (text) => post({ type: "output", id: msg.id, text }),
53+ hasWebGpu ? gpuProvider : undefined,
54+ );
55+ const transfer = result.ok ? collectBuffers(result.value) : [];
56+ post({ type: "done", id: msg.id, result }, transfer);
57+};
packages/core/src/runner/workerRunner.tsadded+112−0View file
@@ -0,0 +1,112 @@
1+import type { BackendInfo, RunRequest, RunResult, Runner } from "./types";
2+import type { FromWorker, ToWorker } from "./protocol";
3+
4+interface Pending {
5+ resolve: (r: RunResult) => void;
6+ onOutput?: (text: string) => void;
7+ output: string[];
8+ t0: number;
9+}
10+
11+/**
12+ * Runs numbl in a Web Worker the runner owns. Cancelling terminates the worker
13+ * and a fresh one is created for the next run, so a runaway time-stepping loop
14+ * can always be stopped, with or without cross-origin isolation.
15+ */
16+export class WorkerRunner implements Runner {
17+ private worker: Worker | null = null;
18+ private nextId = 1;
19+ private pending = new Map<number, Pending>();
20+ private infoResolvers = new Map<number, (b: BackendInfo) => void>();
21+ private cachedBackend: BackendInfo | null = null;
22+ private readonly createWorker: () => Worker;
23+
24+ constructor(createWorker?: () => Worker) {
25+ this.createWorker =
26+ createWorker ??
27+ (() => new Worker(new URL("./worker.ts", import.meta.url), { type: "module" }));
28+ }
29+
30+ get backend(): Promise<BackendInfo> {
31+ if (this.cachedBackend) return Promise.resolve(this.cachedBackend);
32+ return new Promise((resolve) => {
33+ const id = this.nextId++;
34+ this.infoResolvers.set(id, (b) => {
35+ this.cachedBackend = b;
36+ resolve(b);
37+ });
38+ this.ensureWorker().postMessage({ type: "info", id } satisfies ToWorker);
39+ });
40+ }
41+
42+ private ensureWorker(): Worker {
43+ if (this.worker) return this.worker;
44+ const w = this.createWorker();
45+ w.onmessage = (e: MessageEvent<FromWorker>) => this.onMessage(e.data);
46+ w.onerror = (e) => this.failAll(`numbl worker crashed: ${e.message || "unknown error"}`);
47+ this.worker = w;
48+ return w;
49+ }
50+
51+ private onMessage(msg: FromWorker) {
52+ if (msg.type === "info") {
53+ this.infoResolvers.get(msg.id)?.(msg.backend);
54+ this.infoResolvers.delete(msg.id);
55+ return;
56+ }
57+ const p = this.pending.get(msg.id);
58+ if (!p) return;
59+ if (msg.type === "output") {
60+ p.output.push(msg.text);
61+ p.onOutput?.(msg.text);
62+ } else if (msg.type === "done") {
63+ this.pending.delete(msg.id);
64+ p.resolve(msg.result);
65+ }
66+ }
67+
68+ private failAll(error: string, cancelled = false) {
69+ const backend = this.cachedBackend ?? {
70+ engine: "numbl",
71+ version: "unknown",
72+ optimization: "1",
73+ host: "web-worker",
74+ };
75+ for (const p of this.pending.values()) {
76+ p.resolve({
77+ ok: false,
78+ error,
79+ output: p.output,
80+ wallMs: performance.now() - p.t0,
81+ backend,
82+ cancelled,
83+ });
84+ }
85+ this.pending.clear();
86+ }
87+
88+ run(req: RunRequest, onOutput?: (text: string) => void): Promise<RunResult> {
89+ return new Promise((resolve) => {
90+ const id = this.nextId++;
91+ this.pending.set(id, { resolve, onOutput, output: [], t0: performance.now() });
92+ this.ensureWorker().postMessage({
93+ type: "run",
94+ id,
95+ bundle: req.bundle,
96+ inputs: req.inputs,
97+ gpu: req.gpu,
98+ } satisfies ToWorker);
99+ });
100+ }
101+
102+ cancel(): void {
103+ if (!this.worker) return;
104+ this.worker.terminate();
105+ this.worker = null;
106+ this.failAll("run cancelled", true);
107+ }
108+
109+ dispose(): void {
110+ this.cancel();
111+ }
112+}
packages/core/src/spec/defaults.tsadded+88−0View file
@@ -0,0 +1,88 @@
1+import { SCENARIO_PRESETS } from "./presets";
2+import type { Mount, MountType, PlateGeometry, ProblemSpec } from "./types";
3+
4+/** The default problem: the steel sheet on a thread. */
5+export function defaultSpec(): ProblemSpec {
6+ return cloneSpec(SCENARIO_PRESETS[0].spec);
7+}
8+
9+/** Deep-copy a spec (specs are plain data). */
10+export function cloneSpec(spec: ProblemSpec): ProblemSpec {
11+ return JSON.parse(JSON.stringify(spec)) as ProblemSpec;
12+}
13+
14+/**
15+ * A reasonable mount of the given type for this plate, used when the mount
16+ * type is switched in the controls: holes near the top edge, pads under the
17+ * corners, felts at the centre, the vise on the left edge, fingers at the top.
18+ */
19+export function defaultMount(type: MountType, plate: PlateGeometry, previous?: Mount): Mount {
20+ const { Lx, Ly } = plate;
21+ const ellipse = plate.shape === "ellipse";
22+ // a hole 10 mm in from the top of the plate's outline, on its centreline
23+ const top = ellipse ? Ly - 0.01 : Ly - 0.01;
24+ switch (type) {
25+ case "thread":
26+ return {
27+ type,
28+ x: Lx / 2,
29+ y: top,
30+ length: 0.3,
31+ thread: { name: "nylon monofilament, 0.5 mm", diameter: 0.5e-3, G: 1.0e9 },
32+ };
33+ case "threads": {
34+ if (ellipse) {
35+ const a = Lx / 2;
36+ const b = Ly / 2;
37+ const ang = (25 * Math.PI) / 180;
38+ return {
39+ type,
40+ x1: a - (a - 0.012) * Math.sin(ang),
41+ y1: b + (b - 0.012) * Math.cos(ang),
42+ x2: a + (a - 0.012) * Math.sin(ang),
43+ y2: b + (b - 0.012) * Math.cos(ang),
44+ length: 0.4,
45+ };
46+ }
47+ return { type, x1: 0.2 * Lx, y1: top, x2: 0.8 * Lx, y2: top, length: 0.3 };
48+ }
49+ case "pads": {
50+ const r = Math.min(0.02, 0.15 * Math.min(Lx, Ly));
51+ const inset = ellipse ? 0.3 : 0.1;
52+ const pads = ellipse
53+ ? [
54+ { x: Lx / 2, y: inset * Ly, rx: r, ry: r },
55+ { x: inset * Lx, y: Ly * 0.7, rx: r, ry: r },
56+ { x: (1 - inset) * Lx, y: Ly * 0.7, rx: r, ry: r },
57+ ]
58+ : [
59+ { x: inset * Lx, y: inset * Ly, rx: r, ry: r },
60+ { x: (1 - inset) * Lx, y: inset * Ly, rx: r, ry: r },
61+ { x: inset * Lx, y: (1 - inset) * Ly, rx: r, ry: r },
62+ { x: (1 - inset) * Lx, y: (1 - inset) * Ly, rx: r, ry: r },
63+ ];
64+ return { type, pads, k: 2.5e6, c: 1.2e3 };
65+ }
66+ case "cymbal":
67+ return {
68+ type,
69+ r_rod: 0.006,
70+ r_felt: Math.min(0.02, 0.1 * Math.min(Lx, Ly)),
71+ k: 2e8,
72+ c: 6e4,
73+ preload: 0.5e-3,
74+ };
75+ case "vise":
76+ return { type, edge: "x0", depth: Math.min(0.015, 0.1 * Lx), tilt: 90 };
77+ case "pinched":
78+ return {
79+ type,
80+ x: Lx / 2,
81+ y: top,
82+ radius: 0.008,
83+ k: 2000,
84+ c: 20,
85+ tilt: previous && "tilt" in previous ? previous.tilt : 0,
86+ };
87+ }
88+}
packages/core/src/spec/presets.tsadded+386−0View file
@@ -0,0 +1,386 @@
1+import type { Damping, Material, PlateGeometry, ProblemSpec, ThreadMaterial } from "./types";
2+
3+/**
4+ * Material presets. Values are room-temperature handbook figures for the named
5+ * alloy (ASM Handbook, MatWeb, Copper Development Association) and are typical
6+ * rather than certified: real stock varies by a few percent in E and rho.
7+ * The damping values are illustrative starting points in the two-parameter
8+ * loss model, not measurements; damping in a real plate depends heavily on the
9+ * mounting, which is part of what this lab is about.
10+ */
11+export interface MaterialPreset {
12+ id: string;
13+ material: Material;
14+ damping: Damping;
15+ /** Where the numbers come from and what they do not capture */
16+ source: string;
17+}
18+
19+export const MATERIAL_PRESETS: MaterialPreset[] = [
20+ {
21+ id: "a36-steel",
22+ material: { name: "A36 structural steel", E: 200e9, nu: 0.26, rho: 7850 },
23+ damping: { sigma0: 1.0, sigma1: 0.005 },
24+ source: "ASTM A36 hot-rolled plate; E and nu typical of low-carbon steel.",
25+ },
26+ {
27+ id: "304-stainless",
28+ material: { name: "304 stainless steel", E: 193e9, nu: 0.29, rho: 8000 },
29+ damping: { sigma0: 1.0, sigma1: 0.005 },
30+ source: "Annealed 304 sheet, typical values.",
31+ },
32+ {
33+ id: "6061-t6",
34+ material: { name: "6061-T6 aluminium", E: 68.9e9, nu: 0.33, rho: 2700 },
35+ damping: { sigma0: 1.2, sigma1: 0.006 },
36+ source: "6061-T6 sheet and plate, typical values.",
37+ },
38+ {
39+ id: "c260-brass",
40+ material: { name: "C260 cartridge brass", E: 110e9, nu: 0.375, rho: 8530 },
41+ damping: { sigma0: 1.0, sigma1: 0.006 },
42+ source: "70/30 brass, half-hard sheet, typical values.",
43+ },
44+ {
45+ id: "ti-6al-4v",
46+ material: { name: "Ti-6Al-4V titanium", E: 113.8e9, nu: 0.342, rho: 4430 },
47+ damping: { sigma0: 0.8, sigma1: 0.004 },
48+ source: "Annealed Grade 5 titanium sheet, typical values.",
49+ },
50+ {
51+ id: "b20-bronze",
52+ material: { name: "B20 bell bronze (CuSn20)", E: 105e9, nu: 0.34, rho: 8800 },
53+ damping: { sigma0: 0.4, sigma1: 0.002 },
54+ source:
55+ "Bell and cymbal bronze with about 20% tin; E ranges 100 to 110 GPa depending on the casting and working. Low damping is what makes it ring.",
56+ },
57+ {
58+ id: "b8-bronze",
59+ material: { name: "B8 bronze (CuSn8)", E: 110e9, nu: 0.34, rho: 8800 },
60+ damping: { sigma0: 0.5, sigma1: 0.0025 },
61+ source: "8% tin bronze used for cymbals and as phosphor bronze sheet; typical values.",
62+ },
63+ {
64+ id: "1095-spring-steel",
65+ material: { name: "1095 spring steel (bar stock)", E: 205e9, nu: 0.29, rho: 7850 },
66+ damping: { sigma0: 0.5, sigma1: 0.003 },
67+ source:
68+ "High-carbon steel used for glockenspiel and chime bars; E and rho typical of carbon steel.",
69+ },
70+];
71+
72+export interface PlatePreset {
73+ id: string;
74+ name: string;
75+ plate: PlateGeometry;
76+ /** Notes on what this stands in for */
77+ note: string;
78+}
79+
80+const inch = 0.0254;
81+
82+export const PLATE_PRESETS: PlatePreset[] = [
83+ {
84+ id: "sheet-300x200x2",
85+ name: "300 x 200 x 2 mm sheet",
86+ plate: { shape: "rectangle", Lx: 0.3, Ly: 0.2, h: 0.002 },
87+ note: "A common cut-to-size sheet; the default problem.",
88+ },
89+ {
90+ id: "sheet-12x12x1-8in",
91+ name: "12 x 12 x 1/8 in plate",
92+ plate: { shape: "rectangle", Lx: 12 * inch, Ly: 12 * inch, h: inch / 8 },
93+ note: "Square stock in imperial sizes.",
94+ },
95+ {
96+ id: "sheet-500x300x1",
97+ name: "500 x 300 x 1 mm sheet",
98+ plate: { shape: "rectangle", Lx: 0.5, Ly: 0.3, h: 0.001 },
99+ note: "Thin and large: many modes below 20 kHz, expensive for finite differences, and the one where the self-weight tension shifts the lowest modes most.",
100+ },
101+ {
102+ id: "disk-300x2",
103+ name: "Disk, 300 mm diameter x 2 mm",
104+ plate: { shape: "ellipse", Lx: 0.3, Ly: 0.3, h: 0.002 },
105+ note: "A round blank; hung from a hole near the rim it is a small gong.",
106+ },
107+ {
108+ id: "disk-250x1",
109+ name: "Disk, 250 mm diameter x 1 mm",
110+ plate: { shape: "ellipse", Lx: 0.25, Ly: 0.25, h: 0.001 },
111+ note: "Thin enough to hold between two fingers and strike.",
112+ },
113+ {
114+ id: "ellipse-300x200x2",
115+ name: "Elliptical plate, 300 x 200 x 2 mm",
116+ plate: { shape: "ellipse", Lx: 0.3, Ly: 0.2, h: 0.002 },
117+ note: "The default sheet cut to an ellipse: same box, same strike, less plate.",
118+ },
119+ {
120+ id: "cymbal-flat-400x1p2",
121+ name: "Flat cymbal stand-in, 400 mm diameter x 1.2 mm",
122+ plate: { shape: "ellipse", Lx: 0.4, Ly: 0.4, h: 0.0012 },
123+ note: "A cymbal is a disk, free at the rim and held at the centre between felts. What this flat disk leaves out is the curvature (a real cymbal is a shallow cone with a raised bell), the taper in thickness and the hole.",
124+ },
125+ {
126+ id: "glockenspiel-bar",
127+ name: "Glockenspiel bar, 200 x 32 x 6 mm",
128+ plate: { shape: "rectangle", Lx: 0.2, Ly: 0.032, h: 0.006 },
129+ note: "Real bars rest on two pads under the nodes of the first mode, which is the pads mount here. Thickness is a fifth of the width, at the edge of thin-plate validity.",
130+ },
131+ {
132+ id: "ruler-300x50x3",
133+ name: "Steel strip, 300 x 50 x 3 mm",
134+ plate: { shape: "rectangle", Lx: 0.3, Ly: 0.05, h: 0.003 },
135+ note: "A ruler-sized strip; clamp one end in a vise and it is the classic cantilever.",
136+ },
137+];
138+
139+export interface ThreadPreset {
140+ id: string;
141+ thread: ThreadMaterial;
142+ note: string;
143+}
144+
145+/**
146+ * Threads. The shear modulus is what sets the torsional stiffness G J / L,
147+ * which for any of these is so small that a plate on one thread spins with a
148+ * period of minutes; it is here so that the spin has a frequency at all.
149+ */
150+export const THREAD_PRESETS: ThreadPreset[] = [
151+ {
152+ id: "nylon-0p5",
153+ thread: { name: "nylon monofilament, 0.5 mm", diameter: 0.5e-3, G: 1.0e9 },
154+ note: "Fishing line or sewing nylon; G about 1 GPa.",
155+ },
156+ {
157+ id: "cotton-1",
158+ thread: { name: "cotton string, 1 mm", diameter: 1e-3, G: 0.3e9 },
159+ note: "A twisted string; its effective shear modulus is low and uncertain.",
160+ },
161+ {
162+ id: "steel-wire-0p3",
163+ thread: { name: "steel wire, 0.3 mm", diameter: 0.3e-3, G: 80e9 },
164+ note: "Music wire; still a weak torsion spring at this diameter.",
165+ },
166+];
167+
168+export interface PadPreset {
169+ id: string;
170+ name: string;
171+ /** Stiffness [N/m^3] and damping [N s/m^3] per unit area */
172+ k: number;
173+ c: number;
174+ note: string;
175+}
176+
177+/**
178+ * Support materials as a stiffness and damping per unit area, E / t for a
179+ * layer of modulus E and thickness t with a loss factor of a few tenths at
180+ * some hundred hertz. Every one of these is an order-of-magnitude figure.
181+ */
182+export const PAD_PRESETS: PadPreset[] = [
183+ {
184+ id: "foam-20",
185+ name: "soft foam, 20 mm",
186+ k: 2.5e6,
187+ c: 1.2e3,
188+ note: "Polyurethane packing foam, E about 50 kPa.",
189+ },
190+ {
191+ id: "felt-5",
192+ name: "felt, 5 mm",
193+ k: 2e8,
194+ c: 6e4,
195+ note: "Dense wool felt, E about 1 MPa: the washers of a cymbal stand, the pads of a glockenspiel.",
196+ },
197+ {
198+ id: "rubber-5",
199+ name: "rubber, 5 mm",
200+ k: 6e8,
201+ c: 1e5,
202+ note: "A rubber sheet, E about 3 MPa.",
203+ },
204+ {
205+ id: "cork-5",
206+ name: "cork, 5 mm",
207+ k: 4e9,
208+ c: 3e5,
209+ note: "Cork, E about 20 MPa.",
210+ },
211+ {
212+ id: "hard-edge",
213+ name: "hard edge (wood or metal)",
214+ k: 1e10,
215+ c: 1e5,
216+ note: "A knife edge or a bare bench: the stiffness is a penalty, not a measurement, and it shortens the time step a little.",
217+ },
218+];
219+
220+export interface ScenarioPreset {
221+ id: string;
222+ name: string;
223+ /** What is on the bench and what to look for */
224+ note: string;
225+ spec: ProblemSpec;
226+}
227+
228+const mat = (id: string) => MATERIAL_PRESETS.find((m) => m.id === id)!;
229+const plate = (id: string) => ({ ...PLATE_PRESETS.find((p) => p.id === id)!.plate });
230+const thread = (id: string) => ({ ...THREAD_PRESETS.find((t) => t.id === id)!.thread });
231+const pad = (id: string) => PAD_PRESETS.find((p) => p.id === id)!;
232+const felt = pad("felt-5");
233+const foam = pad("foam-20");
234+
235+const AIR = { c: 343, rho_air: 1.2 };
236+
237+/**
238+ * Whole problems: plate, material, mount, orientation, excitation, probe and
239+ * listener set together. Each is something you could build and strike; the
240+ * note says what the run should show.
241+ */
242+export const SCENARIO_PRESETS: ScenarioPreset[] = [
243+ {
244+ id: "sheet-on-thread",
245+ name: "Steel sheet on a thread",
246+ note: "A 300 x 200 mm sheet hung from a hole near the top edge and struck off-centre. It swings on the thread at about 0.8 Hz, rocks about the hole at about 3 Hz, spins slowly about the thread, and rings; the unbaffled listener loses the low end.",
247+ spec: {
248+ plate: plate("sheet-300x200x2"),
249+ material: { ...mat("a36-steel").material },
250+ gravity: { g: 9.81 },
251+ mount: { type: "thread", x: 0.15, y: 0.19, length: 0.3, thread: thread("nylon-0p5") },
252+ damping: { ...mat("a36-steel").damping },
253+ excitation: { type: "impulse", x: 0.1, y: 0.08, force: 50, duration: 1e-3, radius: 0.012 },
254+ probe: { x: 0.2, y: 0.12 },
255+ listener: { x: 0.15, y: 0.1, z: 1.0, ...AIR, model: "unbaffled" },
256+ output: { fs: 44100, nframes: 90 },
257+ fd: { nx: 41, ny: 27, cfl: 0.9, duration: 3.0 },
258+ },
259+ },
260+ {
261+ id: "gong-on-cords",
262+ name: "Gong on two cords",
263+ note: "A bronze disk hung from two holes near the rim, struck at the centre. Two cords stop the spin and the yaw; the swing and sway remain, and the low damping of bell bronze lets it ring for the whole run.",
264+ spec: {
265+ plate: plate("disk-300x2"),
266+ material: { ...mat("b20-bronze").material },
267+ gravity: { g: 9.81 },
268+ mount: { type: "threads", x1: 0.091, y1: 0.277, x2: 0.209, y2: 0.277, length: 0.4 },
269+ damping: { ...mat("b20-bronze").damping },
270+ excitation: { type: "impulse", x: 0.15, y: 0.15, force: 100, duration: 2e-3, radius: 0.02 },
271+ probe: { x: 0.15, y: 0.08 },
272+ listener: { x: 0.15, y: 0.15, z: 1.0, ...AIR, model: "unbaffled" },
273+ output: { fs: 44100, nframes: 90 },
274+ fd: { nx: 41, ny: 41, cfl: 0.9, duration: 3.0 },
275+ },
276+ },
277+ {
278+ id: "glockenspiel-bar",
279+ name: "Glockenspiel bar on nodal pads",
280+ note: "A spring-steel bar on two felt strips placed under the nodes of its first free-free mode, 22.4 percent in from each end, struck in the middle. The pads barely disturb that mode; the bar bounces on them a little after the strike.",
281+ spec: {
282+ plate: plate("glockenspiel-bar"),
283+ material: { ...mat("1095-spring-steel").material },
284+ gravity: { g: 9.81 },
285+ mount: {
286+ type: "pads",
287+ pads: [
288+ { x: 0.0448, y: 0.016, rx: 0.004, ry: 0.02 },
289+ { x: 0.1552, y: 0.016, rx: 0.004, ry: 0.02 },
290+ ],
291+ k: felt.k,
292+ c: felt.c,
293+ },
294+ damping: { ...mat("1095-spring-steel").damping },
295+ excitation: { type: "impulse", x: 0.1, y: 0.016, force: 20, duration: 0.3e-3, radius: 0.006 },
296+ probe: { x: 0.05, y: 0.016 },
297+ listener: { x: 0.1, y: 0.016, z: 0.5, ...AIR, model: "baffled" },
298+ output: { fs: 44100, nframes: 90 },
299+ fd: { nx: 61, ny: 11, cfl: 0.9, duration: 0.5 },
300+ },
301+ },
302+ {
303+ id: "sheet-on-foam",
304+ name: "Sheet on four foam blocks",
305+ note: "The steel sheet lying on foam pads under its corners, struck hard in the middle. The strike lifts it off the pads for a moment; watch the pad forces go to zero and the plate land again.",
306+ spec: {
307+ plate: plate("sheet-300x200x2"),
308+ material: { ...mat("a36-steel").material },
309+ gravity: { g: 9.81 },
310+ mount: {
311+ type: "pads",
312+ pads: [
313+ { x: 0.03, y: 0.03, rx: 0.02, ry: 0.02 },
314+ { x: 0.27, y: 0.03, rx: 0.02, ry: 0.02 },
315+ { x: 0.03, y: 0.17, rx: 0.02, ry: 0.02 },
316+ { x: 0.27, y: 0.17, rx: 0.02, ry: 0.02 },
317+ ],
318+ k: foam.k,
319+ c: foam.c,
320+ },
321+ damping: { ...mat("a36-steel").damping },
322+ excitation: { type: "impulse", x: 0.15, y: 0.1, force: 300, duration: 1e-3, radius: 0.012 },
323+ probe: { x: 0.2, y: 0.12 },
324+ listener: { x: 0.15, y: 0.1, z: 1.0, ...AIR, model: "baffled" },
325+ output: { fs: 44100, nframes: 90 },
326+ fd: { nx: 41, ny: 27, cfl: 0.9, duration: 2.0 },
327+ },
328+ },
329+ {
330+ id: "cymbal-on-stand",
331+ name: "Cymbal on a stand",
332+ note: "A flat bronze disk between the felts of a stand, struck near the edge. The felts hold the centre with a preload; a hard enough strike separates one of them for an instant. What is missing is the cymbal's bow and bell.",
333+ spec: {
334+ plate: plate("cymbal-flat-400x1p2"),
335+ material: { ...mat("b20-bronze").material },
336+ gravity: { g: 9.81 },
337+ mount: { type: "cymbal", r_rod: 0.006, r_felt: 0.02, k: felt.k, c: felt.c, preload: 0.5e-3 },
338+ damping: { ...mat("b20-bronze").damping },
339+ excitation: { type: "impulse", x: 0.36, y: 0.2, force: 30, duration: 1e-3, radius: 0.015 },
340+ probe: { x: 0.2, y: 0.33 },
341+ listener: { x: 0.2, y: 0.2, z: 1.0, ...AIR, model: "unbaffled" },
342+ output: { fs: 44100, nframes: 90 },
343+ fd: { nx: 41, ny: 41, cfl: 0.9, duration: 2.0 },
344+ },
345+ },
346+ {
347+ id: "cantilever-in-vise",
348+ name: "Cantilever in a vise",
349+ note: "A steel strip gripped at one end in a vise, held horizontal, its free end pulled down 10 mm and let go. It sags under its own weight first (the static field), then rings at its cantilever frequency; tilt the vise to hang it or stand it up.",
350+ spec: {
351+ plate: plate("ruler-300x50x3"),
352+ material: { ...mat("a36-steel").material },
353+ gravity: { g: 9.81 },
354+ mount: { type: "vise", edge: "x0", depth: 0.015, tilt: 90 },
355+ damping: { ...mat("a36-steel").damping },
356+ excitation: { type: "release", x: 0.29, y: 0.025, displacement: 0.01, radius: 0.008 },
357+ probe: { x: 0.25, y: 0.025 },
358+ listener: { x: 0.15, y: 0.025, z: 0.5, ...AIR, model: "baffled" },
359+ output: { fs: 44100, nframes: 90 },
360+ fd: { nx: 61, ny: 11, cfl: 0.9, duration: 2.0 },
361+ },
362+ },
363+ {
364+ id: "disk-in-hand",
365+ name: "Disk pinched in the hand",
366+ note: "A thin stainless disk held between finger and thumb near its rim, hanging, and struck. The fingers are a soft, lossy spring: the disk swings from them and its vibration dies faster than on a thread.",
367+ spec: {
368+ plate: plate("disk-250x1"),
369+ material: { ...mat("304-stainless").material },
370+ gravity: { g: 9.81 },
371+ mount: { type: "pinched", x: 0.125, y: 0.235, radius: 0.008, k: 2000, c: 20, tilt: 0 },
372+ damping: { ...mat("304-stainless").damping },
373+ excitation: { type: "impulse", x: 0.125, y: 0.1, force: 30, duration: 1e-3, radius: 0.012 },
374+ probe: { x: 0.18, y: 0.125 },
375+ listener: { x: 0.125, y: 0.125, z: 1.0, ...AIR, model: "unbaffled" },
376+ output: { fs: 44100, nframes: 90 },
377+ fd: { nx: 41, ny: 41, cfl: 0.9, duration: 2.0 },
378+ },
379+ },
380+];
381+
382+export function scenarioPreset(id: string): ScenarioPreset {
383+ const s = SCENARIO_PRESETS.find((x) => x.id === id);
384+ if (!s) throw new Error(`unknown scenario ${id}`);
385+ return s;
386+}
packages/core/src/spec/types.tsadded+229−0View file
@@ -0,0 +1,229 @@
1+/**
2+ * The problem specification. This mirrors, field for field, the `params` struct
3+ * that the generated MATLAB source builds (see generate/params.ts), so that the
4+ * controls, the source on screen and the numbers numbl computes all describe
5+ * the same object.
6+ *
7+ * Units are SI throughout: metres, seconds, newtons, pascals, kilograms.
8+ */
9+
10+/**
11+ * The outline of the plate within its bounding box [0, Lx] x [0, Ly]: the box
12+ * itself, or the inscribed ellipse (a disk when Lx = Ly). The solver embeds the
13+ * ellipse in the same grid as the rectangle.
14+ */
15+export type PlateShape = "rectangle" | "ellipse";
16+
17+export interface PlateGeometry {
18+ shape: PlateShape;
19+ /** Extent along x [m]: the side of a rectangle, the diameter of an ellipse */
20+ Lx: number;
21+ /** Extent along y [m] */
22+ Ly: number;
23+ /** Thickness [m] */
24+ h: number;
25+}
26+
27+/** Whether a point of the bounding box lies on the plate. */
28+export function onPlate(plate: PlateGeometry, x: number, y: number): boolean {
29+ if (!(x >= 0 && x <= plate.Lx && y >= 0 && y <= plate.Ly)) return false;
30+ if (plate.shape !== "ellipse") return true;
31+ const a = plate.Lx / 2;
32+ const b = plate.Ly / 2;
33+ return ((x - a) / a) ** 2 + ((y - b) / b) ** 2 <= 1;
34+}
35+
36+/** Area centroid of the plate, which is its centre of mass for a uniform sheet. */
37+export function centroid(plate: PlateGeometry): { x: number; y: number } {
38+ return { x: plate.Lx / 2, y: plate.Ly / 2 };
39+}
40+
41+export interface Material {
42+ name: string;
43+ /** Young's modulus [Pa] */
44+ E: number;
45+ /** Poisson's ratio */
46+ nu: number;
47+ /** Density [kg/m^3] */
48+ rho: number;
49+}
50+
51+/**
52+ * Two-parameter loss model (Bilbao 2009): a frequency-independent rate sigma0
53+ * [1/s] and a term sigma1 [m^2/s] that grows with spatial frequency. A mode
54+ * with Laplacian eigenvalue lambda decays at rate sigma0 + sigma1 * lambda.
55+ */
56+export interface Damping {
57+ sigma0: number;
58+ sigma1: number;
59+}
60+
61+export interface Gravity {
62+ /** Gravitational acceleration [m/s^2]; 0 switches gravity off */
63+ g: number;
64+}
65+
66+/** A thread's material, for its torsional stiffness G J / L. */
67+export interface ThreadMaterial {
68+ name: string;
69+ /** Diameter [m] */
70+ diameter: number;
71+ /** Shear modulus [Pa] */
72+ G: number;
73+}
74+
75+/** One thread through a hole at (x, y) from a fixed hook `length` above it. */
76+export interface ThreadMount {
77+ type: "thread";
78+ x: number;
79+ y: number;
80+ /** Thread length [m] */
81+ length: number;
82+ thread: ThreadMaterial;
83+}
84+
85+/** Two threads of the same length from hooks at the same height. */
86+export interface TwoThreadMount {
87+ type: "threads";
88+ x1: number;
89+ y1: number;
90+ x2: number;
91+ y2: number;
92+ length: number;
93+}
94+
95+/** A rectangular pad of half-widths rx, ry about (x, y) under a horizontal plate. */
96+export interface Pad {
97+ x: number;
98+ y: number;
99+ rx: number;
100+ ry: number;
101+}
102+
103+export interface PadsMount {
104+ type: "pads";
105+ pads: Pad[];
106+ /** Pad material: stiffness [N/m^3] and damping [N s/m^3] per unit area */
107+ k: number;
108+ c: number;
109+}
110+
111+/** A horizontal plate on a stand: rod through the centre, felt washers above and below. */
112+export interface CymbalMount {
113+ type: "cymbal";
114+ /** Radius of the rod (and the hole), where the felt starts [m] */
115+ r_rod: number;
116+ /** Outer radius of the felt washers [m] */
117+ r_felt: number;
118+ /** Felt stiffness [N/m^3] and damping [N s/m^3] per unit area */
119+ k: number;
120+ c: number;
121+ /** Compression of each felt by the wing nut at rest [m] */
122+ preload: number;
123+}
124+
125+export type ViseEdge = "x0" | "x1" | "y0" | "y1";
126+
127+/** One edge gripped in a vise. */
128+export interface ViseMount {
129+ type: "vise";
130+ edge: ViseEdge;
131+ /** How deep the jaws reach onto the plate [m] */
132+ depth: number;
133+ /** 0: hanging down from the jaw; 90: horizontal cantilever; 180: standing up [degrees] */
134+ tilt: number;
135+}
136+
137+/** Held between finger and thumb over a patch. */
138+export interface PinchedMount {
139+ type: "pinched";
140+ x: number;
141+ y: number;
142+ /** Radius of the patch the fingers cover [m] */
143+ radius: number;
144+ /** Stiffness [N/m] and damping [N s/m] of the grip, two-sided */
145+ k: number;
146+ c: number;
147+ /** 0: hanging below the hand; 90: held flat [degrees] */
148+ tilt: number;
149+}
150+
151+export type Mount =
152+ ThreadMount | TwoThreadMount | PadsMount | CymbalMount | ViseMount | PinchedMount;
153+export type MountType = Mount["type"];
154+
155+export interface ImpulseExcitation {
156+ type: "impulse";
157+ x: number;
158+ y: number;
159+ /** Peak force of the half-sine pulse [N] */
160+ force: number;
161+ /** Contact time [s] */
162+ duration: number;
163+ /** Radius of the raised-cosine footprint [m] */
164+ radius: number;
165+}
166+
167+/** Pull the plate aside at a point by a set displacement, hold, and let go. */
168+export interface ReleaseExcitation {
169+ type: "release";
170+ x: number;
171+ y: number;
172+ /** Displacement of the pull point at release [m] */
173+ displacement: number;
174+ /** Radius of the raised-cosine footprint of the pull [m] */
175+ radius: number;
176+}
177+
178+export type Excitation = ImpulseExcitation | ReleaseExcitation;
179+export type ExcitationType = Excitation["type"];
180+
181+export interface Probe {
182+ x: number;
183+ y: number;
184+}
185+
186+export type ListenerModel = "baffled" | "unbaffled";
187+
188+export interface Listener {
189+ /** Position in plate coordinates [m]; z is the distance along the plate normal */
190+ x: number;
191+ y: number;
192+ z: number;
193+ /** Speed of sound [m/s] */
194+ c: number;
195+ /** Air density [kg/m^3] */
196+ rho_air: number;
197+ /** Rayleigh integral in a baffle, or the unbaffled front-back estimate */
198+ model: ListenerModel;
199+}
200+
201+export interface OutputSettings {
202+ /** Sample rate of the series and the audio [Hz] */
203+ fs: number;
204+ /** Number of animation frames to keep */
205+ nframes: number;
206+}
207+
208+export interface FdSettings {
209+ /** Grid nodes along x and y, edges included */
210+ nx: number;
211+ ny: number;
212+ /** Fraction of the largest stable time step; > 1 is unstable */
213+ cfl: number;
214+ /** Simulated duration [s] */
215+ duration: number;
216+}
217+
218+export interface ProblemSpec {
219+ plate: PlateGeometry;
220+ material: Material;
221+ gravity: Gravity;
222+ mount: Mount;
223+ damping: Damping;
224+ excitation: Excitation;
225+ probe: Probe;
226+ listener: Listener;
227+ output: OutputSettings;
228+ fd: FdSettings;
229+}
packages/core/src/spec/validate.tsadded+129−0View file
@@ -0,0 +1,129 @@
1+import { centroid, onPlate, type ProblemSpec } from "./types";
2+
3+export interface ValidationIssue {
4+ /** Dotted path into the spec, e.g. "plate.h" */
5+ path: string;
6+ message: string;
7+ severity: "error" | "warning";
8+}
9+
10+/**
11+ * Structural checks a control panel can show before running anything. The
12+ * `.m` code repeats the checks that matter for the numerics, so a spec that
13+ * passes here can still be rejected by the solver with a legible message.
14+ */
15+export function validateSpec(spec: ProblemSpec): ValidationIssue[] {
16+ const out: ValidationIssue[] = [];
17+ const err = (path: string, message: string) => out.push({ path, message, severity: "error" });
18+ const warn = (path: string, message: string) => out.push({ path, message, severity: "warning" });
19+
20+ const { plate, material, excitation, probe, output, fd, damping, mount, gravity, listener } =
21+ spec;
22+ if (!(plate.Lx > 0)) err("plate.Lx", "must be positive");
23+ if (!(plate.Ly > 0)) err("plate.Ly", "must be positive");
24+ if (!(plate.h > 0)) err("plate.h", "must be positive");
25+ if (plate.h > 0.1 * Math.min(plate.Lx, plate.Ly))
26+ warn("plate.h", "thickness above a tenth of the shorter side: thin-plate theory is unreliable");
27+ if (!(material.E > 0)) err("material.E", "must be positive");
28+ if (!(material.nu >= 0 && material.nu < 0.5)) err("material.nu", "must lie in [0, 0.5)");
29+ if (!(material.rho > 0)) err("material.rho", "must be positive");
30+ if (damping.sigma0 < 0 || damping.sigma1 < 0) err("damping", "loss rates cannot be negative");
31+ if (!(gravity.g >= 0)) err("gravity.g", "cannot be negative");
32+
33+ const where = plate.shape === "ellipse" ? "outside the ellipse" : "outside the plate";
34+ if (!onPlate(plate, excitation.x, excitation.y)) err("excitation", `strike point lies ${where}`);
35+ if (!onPlate(plate, probe.x, probe.y)) err("probe", `probe lies ${where}`);
36+ if (!(excitation.radius > 0)) err("excitation.radius", "footprint radius must be positive");
37+ if (excitation.type === "impulse" && !(excitation.duration > 0))
38+ err("excitation.duration", "contact time must be positive");
39+ if (excitation.type === "release") {
40+ if (!(excitation.displacement > 0)) err("excitation.displacement", "pull must be positive");
41+ if (excitation.displacement > 0.05 * Math.min(plate.Lx, plate.Ly))
42+ warn(
43+ "excitation.displacement",
44+ "a pull beyond a twentieth of the plate's size is outside small-angle theory",
45+ );
46+ }
47+
48+ const cm = centroid(plate);
49+ const near = (x: number, y: number, r: number) => Math.hypot(x - cm.x, y - cm.y) < r;
50+ switch (mount.type) {
51+ case "thread":
52+ if (!onPlate(plate, mount.x, mount.y)) err("mount", `the hole lies ${where}`);
53+ else if (near(mount.x, mount.y, 0.05 * Math.min(plate.Lx, plate.Ly)))
54+ err(
55+ "mount",
56+ "the hole is at the centre of mass; the plate would hang in no particular orientation",
57+ );
58+ if (!(mount.length > 0)) err("mount.length", "thread length must be positive");
59+ if (!(mount.thread.diameter > 0) || !(mount.thread.G > 0))
60+ err("mount.thread", "thread diameter and shear modulus must be positive");
61+ break;
62+ case "threads": {
63+ if (!onPlate(plate, mount.x1, mount.y1)) err("mount", `hole 1 lies ${where}`);
64+ if (!onPlate(plate, mount.x2, mount.y2)) err("mount", `hole 2 lies ${where}`);
65+ if (!(mount.length > 0)) err("mount.length", "thread length must be positive");
66+ const ex = mount.x2 - mount.x1;
67+ const ey = mount.y2 - mount.y1;
68+ const s2 = Math.hypot(ex, ey);
69+ if (s2 < 1e-6) err("mount", "the two holes coincide");
70+ else {
71+ const sc = ((cm.x - mount.x1) * ex + (cm.y - mount.y1) * ey) / (s2 * s2);
72+ if (!(sc > 0 && sc < 1))
73+ err("mount", "the centre of mass is not between the holes: one thread would go slack");
74+ }
75+ break;
76+ }
77+ case "pads":
78+ if (mount.pads.length === 0) err("mount", "need at least one pad");
79+ mount.pads.forEach((p, k) => {
80+ if (!onPlate(plate, p.x, p.y)) err("mount", `pad ${k + 1} is centred ${where}`);
81+ if (!(p.rx > 0) || !(p.ry > 0)) err("mount", `pad ${k + 1} needs positive half-widths`);
82+ });
83+ if (!(mount.k > 0)) err("mount.k", "pad stiffness must be positive");
84+ if (mount.c < 0) err("mount.c", "pad damping cannot be negative");
85+ if (mount.pads.length < 2)
86+ warn(
87+ "mount",
88+ "a plate on one pad tips unless the pad is wide; the static solve will say if it does",
89+ );
90+ break;
91+ case "cymbal":
92+ if (!(mount.r_felt > mount.r_rod) || !(mount.r_rod >= 0))
93+ err("mount", "the felt's outer radius must exceed the rod's");
94+ if (mount.r_felt > 0.5 * Math.min(plate.Lx, plate.Ly))
95+ err("mount", "the felt is larger than the plate");
96+ if (!(mount.k > 0)) err("mount.k", "felt stiffness must be positive");
97+ if (mount.c < 0) err("mount.c", "felt damping cannot be negative");
98+ if (mount.preload < 0) err("mount.preload", "preload cannot be negative");
99+ break;
100+ case "vise": {
101+ const side = mount.edge.startsWith("x") ? plate.Lx : plate.Ly;
102+ if (!(mount.depth > 0)) err("mount.depth", "jaw depth must be positive");
103+ if (mount.depth > 0.5 * side) err("mount.depth", "the jaws hold more than half the plate");
104+ if (!(mount.tilt >= 0 && mount.tilt <= 180))
105+ err("mount.tilt", "tilt runs from 0 to 180 degrees");
106+ break;
107+ }
108+ case "pinched":
109+ if (!onPlate(plate, mount.x, mount.y)) err("mount", `the pinch point lies ${where}`);
110+ if (!(mount.radius > 0)) err("mount.radius", "patch radius must be positive");
111+ if (!(mount.k > 0)) err("mount.k", "grip stiffness must be positive");
112+ if (mount.c < 0) err("mount.c", "grip damping cannot be negative");
113+ if (!(mount.tilt >= 0 && mount.tilt <= 90))
114+ err("mount.tilt", "tilt runs from 0 to 90 degrees");
115+ break;
116+ }
117+
118+ if (!(listener.z !== 0)) err("listener.z", "the listener cannot be in the plane of the plate");
119+ if (!(output.fs > 0)) err("output.fs", "sample rate must be positive");
120+ if (output.nframes < 2) err("output.nframes", "need at least 2 frames");
121+ if (fd.nx < 5 || fd.ny < 5) err("fd", "finite-difference grid needs at least 5 nodes per side");
122+ if (!(fd.duration > 0)) err("fd.duration", "duration must be positive");
123+ if (fd.cfl > 1) warn("fd.cfl", "above the stability limit: the run will blow up");
124+ const dxFd = plate.Lx / (fd.nx - 1);
125+ const dyFd = plate.Ly / (fd.ny - 1);
126+ if (excitation.radius < Math.max(dxFd, dyFd))
127+ warn("excitation.radius", "footprint smaller than a grid cell: the load lands on one node");
128+ return out;
129+}
packages/core/test/fd.test.tsadded+359−0View file
@@ -0,0 +1,359 @@
1+import { describe, expect, it } from "vitest";
2+import {
3+ FD_METHOD,
4+ SCENARIO_PRESETS,
5+ buildPlanBundle,
6+ cloneSpec,
7+ parseMethodPlan,
8+} from "../src/index";
9+import { maxAbs, runScript, runner, scalar, solve, testSpec, vector } from "./helpers";
10+
11+describe("the hanging plate", () => {
12+ it("gets the compound-pendulum stiffness from the prestress operator to rounding", async () => {
13+ const spec = testSpec();
14+ const out = await runScript(
15+ spec,
16+ [
17+ "mdl = plate_model(params);",
18+ "d = norm([mdl.mnt.xc mdl.mnt.yc] - mdl.mnt.pivot);",
19+ "out.k_swing = mdl.R(:, 2)' * mdl.Kg * mdl.R(:, 2);",
20+ "out.mgd = mdl.mnt.mass * mdl.P.g * d;",
21+ "out.k_spin = mdl.R(:, 3)' * mdl.Kg * mdl.R(:, 3);",
22+ "out.k_heave = mdl.R(:, 1)' * mdl.Kg * mdl.R(:, 1);",
23+ "out.mass_check = mdl.ps.mass_check;",
24+ "out.kT = mdl.Krr(1, 1);",
25+ "out.T_over_L = mdl.mnt.mass * mdl.P.g / params.mount.length;",
26+ "out.hz = mdl.rigid_hz(:)';",
27+ "out.G = mdl.G(:)'; out.Krr = mdl.Krr(:)';",
28+ ].join("\n"),
29+ );
30+ const kSwing = scalar(out, "k_swing");
31+ const mgd = scalar(out, "mgd");
32+ expect(Math.abs(kSwing - mgd) / mgd).toBeLessThan(1e-10);
33+ expect(Math.abs(scalar(out, "k_spin"))).toBeLessThan(1e-9 * mgd);
34+ expect(Math.abs(scalar(out, "k_heave"))).toBeLessThan(1e-9 * mgd);
35+ expect(scalar(out, "mass_check")).toBeCloseTo(1, 10);
36+ expect(scalar(out, "kT")).toBeCloseTo(scalar(out, "T_over_L"), 8);
37+ // the two coupled pendulum modes of thread and plate, from the same 3 x 3 system
38+ const G = vector(out, "G");
39+ const K = vector(out, "Krr");
40+ const a = K[4] * G[0] - K[0] * G[4] * 0; // placeholder to keep the arrays used
41+ void a;
42+ const g11 = G[0],
43+ g12 = G[1],
44+ g22 = G[4];
45+ const k11 = K[0],
46+ k22 = K[4];
47+ // det(K - w^2 G) = 0 for the 2 x 2 sway-swing block
48+ const A = g11 * g22 - g12 * g12;
49+ const B = -(k11 * g22 + k22 * g11);
50+ const C = k11 * k22;
51+ const w2a = (-B - Math.sqrt(B * B - 4 * A * C)) / (2 * A);
52+ const w2b = (-B + Math.sqrt(B * B - 4 * A * C)) / (2 * A);
53+ const hz = Array.from(vector(out, "hz")).sort((p, q) => p - q);
54+ expect(hz[1]).toBeCloseTo(Math.sqrt(w2a) / (2 * Math.PI), 6);
55+ expect(hz[2]).toBeCloseTo(Math.sqrt(w2b) / (2 * Math.PI), 6);
56+ // a 300 x 200 mm steel sheet on a 300 mm thread: sway near 0.8 Hz, swing near 2.9 Hz
57+ expect(hz[1]).toBeGreaterThan(0.7);
58+ expect(hz[1]).toBeLessThan(0.9);
59+ expect(hz[2]).toBeGreaterThan(2.6);
60+ expect(hz[2]).toBeLessThan(3.2);
61+ });
62+
63+ it("hangs with its centre of mass below an off-axis hole", async () => {
64+ const spec = testSpec();
65+ spec.mount = {
66+ type: "thread",
67+ x: 0.05,
68+ y: 0.19,
69+ length: 0.3,
70+ thread:
71+ spec.mount.type === "thread"
72+ ? spec.mount.thread
73+ : { name: "nylon", diameter: 0.5e-3, G: 1e9 },
74+ };
75+ const run = await runner.run({ bundle: buildPlanBundle(spec, FD_METHOD)! });
76+ expect(run.ok).toBe(true);
77+ if (!run.ok) return;
78+ const plan = parseMethodPlan(run.value);
79+ // the hole snaps to the nearest grid node
80+ const hx = Math.round(0.05 / plan.dx) * plan.dx;
81+ const hy = Math.round(0.19 / plan.dy) * plan.dy;
82+ const dx = plan.xc - hx;
83+ const dy = plan.yc - hy;
84+ const n = Math.hypot(dx, dy);
85+ expect(plan.gx).toBeCloseTo(dx / n, 6);
86+ expect(plan.gy).toBeCloseTo(dy / n, 6);
87+ expect(plan.gz).toBe(0);
88+ expect(plan.tension.length).toBe(1);
89+ expect(plan.tension[0]).toBeCloseTo(plan.mass * 9.81, 6);
90+ expect(plan.sag).toBe(0);
91+ });
92+
93+ it("is unstable when hung from below its centre of mass, and says so", async () => {
94+ const spec = testSpec();
95+ spec.mount = {
96+ type: "thread",
97+ x: 0.15,
98+ y: 0.01,
99+ length: 0.3,
100+ thread: { name: "nylon", diameter: 0.5e-3, G: 1e9 },
101+ };
102+ // the plate hangs from the hole in any case: plate_mount turns it so the mass is below the hole
103+ const run = await runner.run({ bundle: buildPlanBundle(spec, FD_METHOD)! });
104+ expect(run.ok).toBe(true);
105+ if (!run.ok) return;
106+ const plan = parseMethodPlan(run.value);
107+ expect(plan.gy).toBeCloseTo(1, 6);
108+ expect(plan.rigid_hz.every((h) => Number.isFinite(h))).toBe(true);
109+ });
110+
111+ it("shares the weight between two threads and restrains the yaw", async () => {
112+ const spec = testSpec();
113+ spec.mount = { type: "threads", x1: 0.06, y1: 0.19, x2: 0.24, y2: 0.19, length: 0.3 };
114+ const run = await runner.run({ bundle: buildPlanBundle(spec, FD_METHOD)! });
115+ expect(run.ok).toBe(true);
116+ if (!run.ok) return;
117+ const plan = parseMethodPlan(run.value);
118+ expect(plan.tension.length).toBe(2);
119+ expect(plan.tension[0] + plan.tension[1]).toBeCloseTo(plan.mass * 9.81, 6);
120+ expect(plan.tension[0]).toBeCloseTo(plan.tension[1], 6);
121+ expect(plan.rigid_hz.every((h) => Number.isFinite(h) && h > 0.3)).toBe(true);
122+ expect(plan.rigid_names[2]).toMatch(/yaw/);
123+ });
124+
125+ it("refuses two threads with the centre of mass outside them", async () => {
126+ const spec = testSpec();
127+ spec.mount = { type: "threads", x1: 0.2, y1: 0.19, x2: 0.28, y2: 0.19, length: 0.3 };
128+ const run = await runner.run({ bundle: buildPlanBundle(spec, FD_METHOD)! });
129+ expect(run.ok).toBe(false);
130+ if (!run.ok) expect(run.error).toMatch(/slack/);
131+ });
132+
133+ it("releases from a pull of the asked size and swings", async () => {
134+ const spec = testSpec();
135+ spec.excitation = { type: "release", x: 0.15, y: 0.02, displacement: 0.002, radius: 0.015 };
136+ spec.probe = { x: 0.15, y: 0.02 };
137+ spec.fd.duration = 0.001;
138+ const { result } = await solve(spec);
139+ expect(result.info.stable).toBe(true);
140+ // the probe node is near the pull point; the total departure there starts near the pull
141+ // (the probe sits a row above the pull's centre, where the swing is a little smaller)
142+ expect(Math.abs(result.probe.w[0])).toBeGreaterThan(0.0015);
143+ expect(Math.abs(result.probe.w[0])).toBeLessThan(0.0022);
144+ // the swing coordinate carries most of it and the spin none
145+ const c = result.rigid.series;
146+ expect(Math.abs(c[1][0])).toBeGreaterThan(0.005);
147+ expect(Math.abs(c[2][0])).toBeLessThan(1e-9);
148+ expect(result.info.notes.some((n) => /released/.test(n))).toBe(true);
149+ });
150+});
151+
152+describe("plates on supports", () => {
153+ it("carries the weight on the pads at rest, sagging into them", async () => {
154+ const spec = testSpec("sheet-on-foam");
155+ const run = await runner.run({ bundle: buildPlanBundle(spec, FD_METHOD)! });
156+ expect(run.ok).toBe(true);
157+ if (!run.ok) return;
158+ const plan = parseMethodPlan(run.value);
159+ expect(plan.gz).toBe(-1);
160+ expect(plan.sag).toBeGreaterThan(0);
161+ const { result } = await solve(spec);
162+ const total = result.supports.static.reduce((a, b) => a + b, 0);
163+ expect(total).toBeCloseTo(result.static.mass * 9.81, 6);
164+ expect(result.supports.static.every((f) => f > 0)).toBe(true);
165+ expect(result.static.sag).toBeGreaterThan(1e-4); // soft foam: a fraction of a millimetre
166+ expect(result.info.stable).toBe(true);
167+ });
168+
169+ it("lifts off the pads under a hard strike and lands again", async () => {
170+ const spec = testSpec("sheet-on-foam");
171+ spec.excitation = {
172+ type: "impulse",
173+ x: 0.15,
174+ y: 0.1,
175+ force: 300,
176+ duration: 1e-3,
177+ radius: 0.02,
178+ };
179+ spec.fd.duration = 0.12;
180+ const { result } = await solve(spec);
181+ expect(result.info.stable).toBe(true);
182+ const f = result.supports.series[0];
183+ const n = f.length;
184+ const early = f.subarray(0, Math.floor(n / 3));
185+ const late = f.subarray(Math.floor((2 * n) / 3));
186+ expect(Math.min(...Array.from(early))).toBeLessThan(1e-6); // off the pad
187+ expect(Math.max(...Array.from(late))).toBeGreaterThan(result.supports.static[0] * 0.5); // back on it
188+ });
189+
190+ it("holds the cymbal between two preloaded felts", async () => {
191+ const spec = testSpec("cymbal-on-stand");
192+ spec.fd.nx = 21;
193+ spec.fd.ny = 21;
194+ const { result } = await solve(spec);
195+ expect(result.supports.names).toEqual(["lower felt", "upper felt"]);
196+ const [lower, upper] = result.supports.static;
197+ expect(lower).toBeGreaterThan(0);
198+ expect(upper).toBeLessThan(0);
199+ expect(lower + upper).toBeCloseTo(result.static.mass * 9.81, 6);
200+ expect(result.info.stable).toBe(true);
201+ });
202+
203+ it("sags like a cantilever beam in the vise", async () => {
204+ const spec = testSpec("cantilever-in-vise");
205+ spec.fd.nx = 31;
206+ spec.fd.ny = 7;
207+ spec.excitation = { type: "impulse", x: 0.2, y: 0.025, force: 1, duration: 1e-3, radius: 0.01 };
208+ const out = await runScript(
209+ spec,
210+ [
211+ "mdl = plate_model(params);",
212+ "w0 = reshape(mdl.st.w0, mdl.ny, mdl.nx);",
213+ "out.tip = w0(round((mdl.ny + 1) / 2), end);",
214+ "held = mdl.mnt.held; out.xclamp = max(mdl.dom.X(held));",
215+ "out.mu = mdl.P.mu; out.D = mdl.P.D; out.E = mdl.P.material.E; out.h = params.plate.h; out.Lx = params.plate.Lx;",
216+ ].join("\n"),
217+ );
218+ const Lfree = scalar(out, "Lx") - scalar(out, "xclamp");
219+ const q = scalar(out, "mu") * 9.81; // load per unit area
220+ const E = scalar(out, "E");
221+ const h = scalar(out, "h");
222+ // beam theory for a narrow strip: w = q L^4 / (8 E I / b) with I / b = h^3 / 12
223+ const beam = (q * Lfree ** 4) / (8 * E * (h ** 3 / 12));
224+ const tip = -scalar(out, "tip");
225+ expect(tip).toBeGreaterThan(0.85 * beam);
226+ expect(tip).toBeLessThan(1.15 * beam);
227+ });
228+
229+ it("has no rigid-body coordinates in the vise and three elsewhere", async () => {
230+ const vise = await solve(testSpec("cantilever-in-vise"));
231+ expect(vise.result.rigid.nr).toBe(0);
232+ expect(vise.result.rigid.series.length).toBe(0);
233+ const hand = await solve(testSpec("disk-in-hand"));
234+ expect(hand.result.rigid.nr).toBe(3);
235+ expect(hand.result.rigid.hz.every((h) => Number.isFinite(h) && h >= 0)).toBe(true);
236+ expect(hand.result.rigid.hz.filter((h) => h > 0).length).toBeGreaterThanOrEqual(2);
237+ });
238+});
239+
240+describe("the scheme", () => {
241+ it("keeps the swing of a struck hanging plate bounded and its split exact", async () => {
242+ const spec = testSpec();
243+ spec.fd.duration = 0.45;
244+ spec.damping.sigma0 = 0;
245+ spec.damping.sigma1 = 0;
246+ spec.output.nframes = 10;
247+ const out = await runScript(
248+ spec,
249+ [
250+ "S = plate_fd_setup(params); mdl = S.mdl;",
251+ "Rec = plate_fd_loop(S);",
252+ "E = zeros(3, S.nf); for k = 1:S.nf, E(:, k) = mdl.Q' * reshape(Rec.Wf(:, :, k), [], 1); end",
253+ "out.leak = max(max(abs(E(1:2, :))));",
254+ "out.swing = max(abs(Rec.C(2, :))); out.sway = max(abs(Rec.C(1, :)));",
255+ "n = size(Rec.C, 2); out.early = max(abs(Rec.C(2, 1:floor(n / 3)))); out.late = max(abs(Rec.C(2, floor(2 * n / 3):end)));",
256+ "% the angular impulse of the strike about the hole, and the swing it can give at most",
257+ "J = trapz(S.tt, S.F); rd = (params.excitation.x - mdl.mnt.pivot(1)) * mdl.mnt.ed(1) + (params.excitation.y - mdl.mnt.pivot(2)) * mdl.mnt.ed(2);",
258+ "out.bound = abs(J * rd) / mdl.G(2, 2) / (2 * pi * min(mdl.rigid_hz(mdl.rigid_hz > 0.1)));",
259+ ].join("\n"),
260+ );
261+ expect(scalar(out, "leak")).toBeLessThan(1e-12);
262+ // the swing stays of the order the impulse allows, and does not grow
263+ expect(scalar(out, "swing")).toBeLessThan(3 * scalar(out, "bound"));
264+ expect(scalar(out, "late")).toBeLessThan(
265+ 1.5 * Math.max(scalar(out, "early"), scalar(out, "swing") * 0.5),
266+ );
267+ });
268+
269+ it("factors every operator so the port can apply it as stencils and weights", async () => {
270+ const out = await runScript(
271+ testSpec(),
272+ [
273+ "S = plate_fd_setup(params);",
274+ "u = S.keep; Pz = spdiags(u, 0, S.N, S.N);",
275+ "KBf = Pz * (S.Gs' * S.WB * S.Gs) * Pz; out.eKB = full(max(max(abs(KBf - S.KB)))) / full(max(max(abs(S.KB))));",
276+ "KGf = Pz * (S.Gs' * S.WG * S.Gs) * Pz + spdiags(S.ks .* u, 0, S.N, S.N); out.eKG = full(max(max(abs(KGf - S.KG)))) / max(full(max(max(abs(S.KG)))), eps);",
277+ "Lf = spdiags(S.inva, 0, S.N, S.N) * (S.Gs' * S.WL * S.Gs) * Pz; out.eL = full(max(max(abs(Lf - S.Lm)))) / full(max(max(abs(S.Lm))));",
278+ "[gi, gj, gv] = find(S.Gs); out.eInt = max(abs(gv ./ S.Gscale(gi) - round(gv ./ S.Gscale(gi))));",
279+ ].join("\n"),
280+ );
281+ expect(scalar(out, "eKB")).toBeLessThan(1e-12);
282+ expect(scalar(out, "eKG")).toBeLessThan(1e-12);
283+ expect(scalar(out, "eL")).toBeLessThan(1e-12);
284+ expect(scalar(out, "eInt")).toBeLessThan(1e-9);
285+ });
286+
287+ it("plans a time step at the stability bound", async () => {
288+ const spec = testSpec();
289+ const run = await runner.run({ bundle: buildPlanBundle(spec, FD_METHOD)! });
290+ expect(run.ok).toBe(true);
291+ if (!run.ok) return;
292+ const plan = parseMethodPlan(run.value);
293+ const { Lx, Ly, h } = spec.plate;
294+ const { E, nu, rho } = spec.material;
295+ const kappa = Math.sqrt((E * h ** 3) / (12 * (1 - nu ** 2)) / (rho * h));
296+ const dx = Lx / (spec.fd.nx - 1);
297+ const dy = Ly / (spec.fd.ny - 1);
298+ const lam = 4 / dx ** 2 + 4 / dy ** 2;
299+ const s1 = spec.damping.sigma1;
300+ const bending = 2 / (lam * (s1 + Math.sqrt(kappa ** 2 + s1 ** 2)));
301+ // the prestress and the thread spring lower the bound by a negligible amount
302+ expect(plan.dt_max).toBeLessThanOrEqual(bending);
303+ expect(plan.dt_max).toBeGreaterThan(0.999 * bending);
304+ expect(plan.dt).toBeCloseTo(spec.fd.cfl * plan.dt_max, 12);
305+ expect(plan.cell_steps).toBe(plan.ncells * plan.nsteps);
306+ });
307+
308+ it("is stable just below the CFL limit and diverges just above it", async () => {
309+ const spec = testSpec();
310+ spec.fd.cfl = 0.98;
311+ const stable = await solve(spec);
312+ expect(stable.result.info.stable).toBe(true);
313+ expect(stable.result.probe.w.every(Number.isFinite)).toBe(true);
314+
315+ spec.fd.cfl = 1.03;
316+ const blown = await solve(spec);
317+ expect(blown.result.info.stable).toBe(false);
318+ expect(blown.result.info.diverged_step).toBeGreaterThan(0);
319+ expect(blown.result.probe.w.every(Number.isFinite)).toBe(true);
320+ expect(blown.result.frames.w.data.every((v) => Number.isFinite(v) || Number.isNaN(v))).toBe(
321+ true,
322+ );
323+ expect(blown.result.info.notes.some((n) => /diverged/.test(n))).toBe(true);
324+ });
325+
326+ it("runs every scenario on a coarse grid", async () => {
327+ for (const sc of SCENARIO_PRESETS) {
328+ const spec = testSpec(sc.id);
329+ const { result } = await solve(spec);
330+ expect(result.info.stable, sc.id).toBe(true);
331+ expect(maxAbs(result.probe.w), sc.id).toBeGreaterThan(0);
332+ expect(result.audio.p.every(Number.isFinite), sc.id).toBe(true);
333+ expect(result.frames.w.nf, sc.id).toBe(result.frames.t.length);
334+ expect(result.supports.series.length, sc.id).toBe(result.supports.names.length);
335+ }
336+ }, 240_000);
337+
338+ it("reports its problem size and a series at the sample rate", async () => {
339+ const spec = testSpec();
340+ const { result } = await solve(spec);
341+ expect(result.info.ncells).toBe(spec.fd.nx * spec.fd.ny);
342+ expect(result.info.extra.cell_steps).toBe(result.info.ncells * result.info.nsteps);
343+ const expected = Math.floor(result.info.nsteps * result.info.dt * spec.output.fs);
344+ expect(result.t.length).toBe(expected);
345+ expect(result.audio.p.length).toBe(expected);
346+ expect(result.audio.model).toBe("unbaffled");
347+ });
348+
349+ it("gives the same physics with the baffled listener except for the sound", async () => {
350+ const a = testSpec();
351+ const b = cloneSpec(a);
352+ b.listener.model = "baffled";
353+ const ra = await solve(a);
354+ const rb = await solve(b);
355+ expect(ra.result.probe.w).toEqual(rb.result.probe.w);
356+ expect(maxAbs(ra.result.audio.p)).toBeLessThan(maxAbs(rb.result.audio.p) * 1.5);
357+ expect(ra.result.audio.p).not.toEqual(rb.result.audio.p);
358+ });
359+});
packages/core/test/generate.test.tsadded+108−0View file
@@ -0,0 +1,108 @@
1+import { describe, expect, it } from "vitest";
2+import {
3+ FD_METHOD,
4+ SCENARIO_PRESETS,
5+ buildRunBundle,
6+ formatNumber,
7+ generateParamsBlock,
8+ isPlainStruct,
9+ withEdits,
10+ LIBRARY_FILES,
11+ validateSpec,
12+} from "../src/index";
13+import { runner, testSpec } from "./helpers";
14+
15+describe("source generation and editing", () => {
16+ it("formats numbers as exact, readable MATLAB literals", () => {
17+ expect(formatNumber(200e9)).toBe("200e9");
18+ expect(formatNumber(0.002)).toBe("0.002");
19+ expect(formatNumber(44100)).toBe("44100");
20+ expect(formatNumber(1e-3)).toBe("0.001");
21+ expect(formatNumber(2.5e-7)).toBe("250e-9");
22+ expect(formatNumber(7850)).toBe("7850");
23+ expect(Number(formatNumber(68.9e9))).toBe(68.9e9);
24+ expect(formatNumber(-3)).toBe("-3");
25+ });
26+
27+ it("round-trips the spec through the params block", async () => {
28+ const spec = testSpec();
29+ const block = generateParamsBlock(spec);
30+ const bundle = {
31+ files: [{ name: "p.m", source: block }, ...LIBRARY_FILES],
32+ entry: "p.m",
33+ resultVariable: "params",
34+ };
35+ const run = await runner.run({ bundle });
36+ expect(run.ok).toBe(true);
37+ if (!run.ok || !isPlainStruct(run.value)) return;
38+ const plate = run.value.fields.plate;
39+ const mat = run.value.fields.material;
40+ const mount = run.value.fields.mount;
41+ if (!isPlainStruct(plate) || !isPlainStruct(mat) || !isPlainStruct(mount))
42+ throw new Error("params fields are not structs");
43+ expect(plate.fields.h).toBe(spec.plate.h);
44+ expect(mat.fields.E).toBe(spec.material.E);
45+ expect(mat.fields.name).toBe(spec.material.name);
46+ expect(mount.fields.type).toBe("thread");
47+ });
48+
49+ it("writes every mount as a struct numbl accepts", async () => {
50+ for (const sc of SCENARIO_PRESETS) {
51+ const block = generateParamsBlock(sc.spec);
52+ const run = await runner.run({
53+ bundle: {
54+ files: [
55+ { name: "p.m", source: block + "\nP = plate_setup(params);\n" },
56+ ...LIBRARY_FILES,
57+ ],
58+ entry: "p.m",
59+ resultVariable: "P",
60+ },
61+ });
62+ expect(run.ok, `${sc.id}: ${run.ok ? "" : run.error}`).toBe(true);
63+ }
64+ });
65+
66+ it("runs the edited source, and surfaces numbl errors with file and line", async () => {
67+ const spec = testSpec();
68+ const bundle = buildRunBundle(spec, FD_METHOD);
69+ const main = bundle.files.find((f) => f.name === "main.m")!.source;
70+ const edited = withEdits(bundle, {
71+ "main.m": main.replace(
72+ "result = plate_fd(params);",
73+ "result = plate_fd(params)\nresult = undefined_function_xyz(1);",
74+ ),
75+ });
76+ const run = await runner.run({ bundle: edited });
77+ expect(run.ok).toBe(false);
78+ if (run.ok) return;
79+ expect(run.error).toMatch(/undefined_function_xyz/);
80+ expect(run.error).toMatch(/main\.m/);
81+ });
82+
83+ it("reports a missing result variable rather than an empty screen", async () => {
84+ const spec = testSpec();
85+ const bundle = buildRunBundle(spec, FD_METHOD);
86+ const edited = withEdits(bundle, { "main.m": "x = 1;" });
87+ const run = await runner.run({ bundle: edited });
88+ expect(run.ok).toBe(false);
89+ if (!run.ok) expect(run.error).toMatch(/result/);
90+ });
91+
92+ it("validates obviously bad specs", () => {
93+ const spec = testSpec();
94+ spec.probe.x = 10;
95+ spec.fd.cfl = 1.2;
96+ const issues = validateSpec(spec);
97+ expect(issues.some((i) => i.path === "probe" && i.severity === "error")).toBe(true);
98+ expect(issues.some((i) => i.path === "fd.cfl" && i.severity === "warning")).toBe(true);
99+ const two = testSpec();
100+ two.mount = { type: "threads", x1: 0.2, y1: 0.19, x2: 0.28, y2: 0.19, length: 0.3 };
101+ expect(validateSpec(two).some((i) => /slack/.test(i.message))).toBe(true);
102+ for (const sc of SCENARIO_PRESETS)
103+ expect(
104+ validateSpec(sc.spec).filter((i) => i.severity === "error"),
105+ sc.id,
106+ ).toEqual([]);
107+ });
108+});
packages/core/test/gpu.test.tsadded+180−0View file
@@ -0,0 +1,180 @@
1+import { describe, expect, it } from "vitest";
2+import { NodeRunner } from "../src/node";
3+import {
4+ FD_METHOD,
5+ LIBRARY_FILES,
6+ buildGpuStages,
7+ buildRunBundle,
8+ parsePlateResult,
9+ withEdits,
10+ type ProblemSpec,
11+ type RunResult,
12+} from "../src/index";
13+import { requestGpuDevice, type GpuHandle } from "../src/gpu/fdLoop";
14+import { maxAbs, relL2, testSpec } from "./helpers";
15+
16+/**
17+ * Desktop WebGPU for Node through the optional `webgpu` package (Google Dawn):
18+ * `npm i --no-save webgpu`. Without the package, or on a machine without a GPU
19+ * adapter, the suite is skipped rather than failed, so CI can run it
20+ * opportunistically.
21+ */
22+async function dawn(): Promise<GpuHandle | null> {
23+ try {
24+ const specifier = "webgpu";
25+ const mod = (await import(specifier)) as {
26+ create: (flags: string[]) => GPU;
27+ globals: Record<string, unknown>;
28+ };
29+ Object.assign(globalThis, mod.globals);
30+ Object.defineProperty(globalThis, "navigator", {
31+ value: { gpu: mod.create([]) },
32+ configurable: true,
33+ writable: true,
34+ });
35+ return await requestGpuDevice();
36+ } catch (e) {
37+ console.log(
38+ `gpu.test: no WebGPU device under Node (${e instanceof Error ? e.message : e}); skipping`,
39+ );
40+ return null;
41+ }
42+}
43+
44+const handle = await dawn();
45+const cpu = new NodeRunner();
46+const gpu = new NodeRunner({ gpu: () => Promise.resolve(handle!) });
47+
48+async function both(spec: ProblemSpec, edits: Record<string, string> = {}) {
49+ const stages = buildGpuStages(spec, FD_METHOD)!;
50+ const g = await gpu.run({
51+ bundle: withEdits(buildRunBundle(spec, FD_METHOD, { gpu: true }), edits),
52+ gpu: stages,
53+ });
54+ const c = await cpu.run({ bundle: withEdits(buildRunBundle(spec, FD_METHOD), edits) });
55+ if (!g.ok) throw new Error(`gpu run failed: ${g.error}\n${g.output.join("")}`);
56+ if (!c.ok) throw new Error(`cpu run failed: ${c.error}\n${c.output.join("")}`);
57+ return { g, c, rg: parsePlateResult(g.value), rc: parsePlateResult(c.value) };
58+}
59+
60+const fin = (a: Float64Array) => a.filter(Number.isFinite);
61+
62+/** Relative L2 error over several rows at once, normalized by the norm of all reference rows together. */
63+function relL2Rows(a: Float64Array[], b: Float64Array[]): number {
64+ let num = 0;
65+ let den = 0;
66+ for (let k = 0; k < a.length; k++)
67+ for (let i = 0; i < a[k].length; i++) {
68+ num += (a[k][i] - b[k][i]) ** 2;
69+ den += a[k][i] ** 2;
70+ }
71+ return a.length ? Math.sqrt(num / Math.max(den, 1e-300)) : 0;
72+}
73+
74+describe.skipIf(!handle)("explicit finite differences on WebGPU", () => {
75+ it.each([
76+ "sheet-on-thread",
77+ "gong-on-cords",
78+ "sheet-on-foam",
79+ "cymbal-on-stand",
80+ "cantilever-in-vise",
81+ "disk-in-hand",
82+ ])("reproduces the double-precision loop for %s", async (scenario) => {
83+ const spec = testSpec(scenario);
84+ spec.fd.duration = 0.01;
85+ const { g, rg, rc } = await both(spec);
86+ expect(g.compute?.loop).toBe("webgpu");
87+ expect(rg.info.extra.loop).toBe("webgpu");
88+ expect(rg.info.stable).toBe(true);
89+ expect(rg.t.length).toBe(rc.t.length);
90+ expect(rg.frames.t.length).toBe(rc.frames.t.length);
91+ const eW = relL2(rc.probe.w, rg.probe.w);
92+ const eF = relL2(fin(rc.frames.w.data), fin(rg.frames.w.data));
93+ const eVm = relL2(rc.probe.vm, rg.probe.vm);
94+ const eA = relL2(rc.audio.p, rg.audio.p);
95+ // a coordinate or a support that stays at zero has no scale of its own: compare against the largest
96+ const eR = relL2Rows(rc.rigid.series, rg.rigid.series);
97+ const eS = relL2Rows(rc.supports.series, rg.supports.series);
98+ console.log(
99+ `gpu.test ${scenario}: rel L2 probe w ${eW.toExponential(2)}, frames ${eF.toExponential(2)}, probe vm ${eVm.toExponential(2)}, audio ${eA.toExponential(2)}, rigid ${eR.toExponential(2)}, supports ${eS.toExponential(2)}, ${rg.info.nsteps} steps in ${g.compute?.gpuMs?.toFixed(0)} ms on the GPU`,
100+ );
101+ expect(eW).toBeLessThan(2e-3);
102+ expect(eF).toBeLessThan(2e-3);
103+ expect(eVm).toBeLessThan(2e-2);
104+ expect(eA).toBeLessThan(2e-2);
105+ expect(eR).toBeLessThan(2e-3);
106+ expect(eS).toBeLessThan(2e-2);
107+ expect(maxAbs(rg.probe.w)).toBeGreaterThan(0);
108+ });
109+
110+ it("keeps the swing of a released plate for many steps", async () => {
111+ const spec = testSpec();
112+ spec.excitation = { type: "release", x: 0.15, y: 0.02, displacement: 0.002, radius: 0.015 };
113+ spec.fd.duration = 0.05;
114+ const { rg, rc } = await both(spec);
115+ const eR = relL2(rc.rigid.series[1], rg.rigid.series[1]);
116+ const eW = relL2(rc.probe.w, rg.probe.w);
117+ console.log(
118+ `gpu.test release: rel L2 swing ${eR.toExponential(2)}, probe ${eW.toExponential(2)}`,
119+ );
120+ expect(eR).toBeLessThan(1e-3);
121+ expect(eW).toBeLessThan(2e-3);
122+ });
123+
124+ it("stops at the blow-up where the double-precision loop does", async () => {
125+ const spec = testSpec();
126+ spec.fd.cfl = 1.03;
127+ const { rg, rc } = await both(spec);
128+ expect(rg.info.stable).toBe(false);
129+ expect(rc.info.stable).toBe(false);
130+ expect(rg.info.diverged_step).toBeGreaterThan(0);
131+ expect(Math.abs(rg.info.diverged_step - rc.info.diverged_step)).toBeLessThanOrEqual(
132+ Math.max(10, 0.1 * rc.info.nsteps),
133+ );
134+ expect(rg.probe.w.every(Number.isFinite)).toBe(true);
135+ expect(rg.frames.t.length).toBe(rc.frames.t.length);
136+ });
137+
138+ it("falls back to numbl when the loop file is edited, and says so", async () => {
139+ const spec = testSpec();
140+ const loop = LIBRARY_FILES.find((f) => f.name === "plate_fd_loop.m")!.source;
141+ const { g, rg, rc } = await both(spec, {
142+ "plate_fd_loop.m": loop + "\n% edited in the code view\n",
143+ });
144+ expect(g.compute?.loop).toBe("numbl");
145+ expect(g.compute?.note).toMatch(/edited/);
146+ expect(rg.info.extra.loop).toBe("numbl");
147+ expect(relL2(rc.probe.w, rg.probe.w)).toBeLessThan(1e-12);
148+ });
149+});
150+
151+describe("explicit finite differences without a GPU", () => {
152+ it("runs the whole bundle in numbl when the GPU is asked for but absent", async () => {
153+ const spec = testSpec();
154+ const r: RunResult = await cpu.run({
155+ bundle: buildRunBundle(spec, FD_METHOD, { gpu: true }),
156+ gpu: buildGpuStages(spec, FD_METHOD)!,
157+ });
158+ expect(r.ok).toBe(true);
159+ if (!r.ok) return;
160+ expect(r.compute?.loop).toBe("numbl");
161+ expect(r.compute?.note).toMatch(/not available/);
162+ const res = parsePlateResult(r.value);
163+ expect(res.info.extra.loop).toBe("numbl");
164+ expect(res.info.stable).toBe(true);
165+ });
166+
167+ it("writes the staged script and the plain script for the same problem", () => {
168+ const spec = testSpec();
169+ const plain = buildRunBundle(spec, FD_METHOD).files[0].source;
170+ const staged = buildRunBundle(spec, FD_METHOD, { gpu: true }).files[0].source;
171+ expect(plain).toMatch(/result = plate_fd\(params\);/);
172+ expect(staged).toMatch(/S = plate_fd_setup\(params\);/);
173+ expect(staged).toMatch(/Rec = plate_fd_loop\(S\);/);
174+ expect(staged).toMatch(/result = plate_fd_output\(S, Rec\);/);
175+ const stages = buildGpuStages(spec, FD_METHOD)!;
176+ expect(stages.loopFile).toBe("plate_fd_loop.m");
177+ expect(stages.setup).toMatch(/find\(S\.KB\)/);
178+ expect(stages.output).toMatch(/plate_fd_output\(S, Rec\)/);
179+ });
180+});
packages/core/test/helpers.tsadded+83−0View file
@@ -0,0 +1,83 @@
1+import { NodeRunner } from "../src/node";
2+import {
3+ FD_METHOD,
4+ LIBRARY_FILES,
5+ buildRunBundle,
6+ cloneSpec,
7+ generateParamsBlock,
8+ parsePlateResult,
9+ scenarioPreset,
10+ isPlainStruct,
11+ type PlainStruct,
12+ type PlainValue,
13+ type PlateResult,
14+ type ProblemSpec,
15+ type RunResult,
16+} from "../src/index";
17+
18+export const runner = new NodeRunner();
19+
20+/** A small, fast problem for tests: the default scenario on a coarse grid, a few milliseconds. */
21+export function testSpec(scenario = "sheet-on-thread"): ProblemSpec {
22+ const s = cloneSpec(scenarioPreset(scenario).spec);
23+ s.output.nframes = 8;
24+ const aspect = s.plate.Ly / s.plate.Lx;
25+ s.fd.nx = 21;
26+ s.fd.ny = Math.max(7, Math.round(21 * aspect) + (Math.round(21 * aspect) % 2 === 0 ? 1 : 0));
27+ s.fd.duration = 0.002;
28+ return s;
29+}
30+
31+export async function solve(spec: ProblemSpec): Promise<{ run: RunResult; result: PlateResult }> {
32+ const run = await runner.run({ bundle: buildRunBundle(spec, FD_METHOD) });
33+ if (!run.ok) throw new Error(`fd failed: ${run.error}\n${run.output.join("")}`);
34+ return { run, result: parsePlateResult(run.value) };
35+}
36+
37+/** Run a custom script after the params block and return the struct it leaves in `out`. */
38+export async function runScript(spec: ProblemSpec, body: string): Promise<PlainStruct> {
39+ const main = [generateParamsBlock(spec), "", body, ""].join("\n");
40+ const run = await runner.run({
41+ bundle: {
42+ files: [{ name: "script.m", source: main }, ...LIBRARY_FILES],
43+ entry: "script.m",
44+ resultVariable: "out",
45+ },
46+ });
47+ if (!run.ok) throw new Error(`script failed: ${run.error}\n${run.output.join("")}`);
48+ if (!isPlainStruct(run.value)) throw new Error("script did not leave a struct in out");
49+ return run.value;
50+}
51+
52+export function scalar(s: PlainStruct, name: string): number {
53+ const v: PlainValue | undefined = s.fields[name];
54+ if (typeof v === "number") return v;
55+ if (typeof v === "boolean") return v ? 1 : 0;
56+ if (v && typeof v === "object" && v.kind === "tensor" && v.data.length >= 1) return v.data[0];
57+ throw new Error(`${name} is not a scalar`);
58+}
59+
60+export function vector(s: PlainStruct, name: string): Float64Array {
61+ const v: PlainValue | undefined = s.fields[name];
62+ if (typeof v === "number") return Float64Array.of(v);
63+ if (v && typeof v === "object" && v.kind === "tensor") return v.data;
64+ throw new Error(`${name} is not a vector`);
65+}
66+
67+export function relL2(a: ArrayLike<number>, b: ArrayLike<number>): number {
68+ let num = 0;
69+ let den = 0;
70+ for (let i = 0; i < a.length; i++) {
71+ num += (a[i] - b[i]) ** 2;
72+ den += a[i] ** 2;
73+ }
74+ return Math.sqrt(num / Math.max(den, 1e-300));
75+}
76+
77+export function maxAbs(a: ArrayLike<number>): number {
78+ let m = 0;
79+ for (let i = 0; i < a.length; i++) m = Math.max(m, Math.abs(a[i]));
80+ return m;
81+}
82+
83+export { cloneSpec };
packages/core/test/wav.test.tsadded+39−0View file
@@ -0,0 +1,39 @@
1+import { describe, expect, it } from "vitest";
2+import { encodeWav } from "../src/audio/wav";
3+
4+const str = (b: Uint8Array, off: number, n: number) =>
5+ String.fromCharCode(...b.subarray(off, off + n));
6+
7+describe("encodeWav", () => {
8+ it("writes a well-formed 16-bit mono PCM header", () => {
9+ const b = encodeWav(new Float32Array(10), 44100);
10+ const v = new DataView(b.buffer);
11+ expect(b.length).toBe(44 + 20);
12+ expect(str(b, 0, 4)).toBe("RIFF");
13+ expect(v.getUint32(4, true)).toBe(b.length - 8);
14+ expect(str(b, 8, 4)).toBe("WAVE");
15+ expect(str(b, 12, 4)).toBe("fmt ");
16+ expect(v.getUint16(20, true)).toBe(1);
17+ expect(v.getUint16(22, true)).toBe(1);
18+ expect(v.getUint32(24, true)).toBe(44100);
19+ expect(v.getUint32(28, true)).toBe(88200);
20+ expect(v.getUint16(32, true)).toBe(2);
21+ expect(v.getUint16(34, true)).toBe(16);
22+ expect(str(b, 36, 4)).toBe("data");
23+ expect(v.getUint32(40, true)).toBe(20);
24+ });
25+
26+ it("quantizes, clips, and silences non-finite samples", () => {
27+ const b = encodeWav([0, 1, -1, 0.5, 2, -3, NaN, Infinity], 8000);
28+ const v = new DataView(b.buffer);
29+ const s = (i: number) => v.getInt16(44 + 2 * i, true);
30+ expect(s(0)).toBe(0);
31+ expect(s(1)).toBe(32767);
32+ expect(s(2)).toBe(-32768);
33+ expect(s(3)).toBe(16384);
34+ expect(s(4)).toBe(32767);
35+ expect(s(5)).toBe(-32768);
36+ expect(s(6)).toBe(0);
37+ expect(s(7)).toBe(0);
38+ });
39+});
packages/core/tsconfig.jsonadded+10−0View file
@@ -0,0 +1,10 @@
1+{
2+ "extends": "../../tsconfig.base.json",
3+ "compilerOptions": {
4+ "noEmit": true
5+ },
6+ "include": [
7+ "src",
8+ "test"
9+ ]
10+}
tsconfig.base.jsonadded+17−0View file
@@ -0,0 +1,17 @@
1+{
2+ "compilerOptions": {
3+ "target": "ES2022",
4+ "lib": ["ES2022", "DOM", "DOM.Iterable", "WebWorker"],
5+ "module": "ESNext",
6+ "moduleResolution": "bundler",
7+ "strict": true,
8+ "noUnusedLocals": true,
9+ "noUnusedParameters": true,
10+ "noFallthroughCasesInSwitch": true,
11+ "skipLibCheck": true,
12+ "isolatedModules": true,
13+ "resolveJsonModule": true,
14+ "jsx": "react-jsx",
15+ "types": ["vite/client", "@webgpu/types"]
16+ }
17+}
tsconfig.jsonadded+5−0View file
@@ -0,0 +1,5 @@
1+{
2+ "extends": "./tsconfig.base.json",
3+ "compilerOptions": { "noEmit": true },
4+ "include": ["packages/*/src", "packages/*/test", "apps/*/src", "apps/*/vite.config.ts", "vite.shared.ts", "vitest.config.ts"]
5+}
vite.shared.tsadded+19−0View file
@@ -0,0 +1,19 @@
1+import { readFileSync } from "node:fs";
2+import { fileURLToPath } from "node:url";
3+import path from "node:path";
4+
5+/**
6+ * Vite `define` entries every host needs to run numbl in the browser:
7+ * numbl reads `process` in a few guarded places, and the lab reports numbl's
8+ * package version (its `exports` map does not expose package.json to imports).
9+ */
10+export function numblDefine(): Record<string, string> {
11+ const entry = fileURLToPath(import.meta.resolve("numbl"));
12+ const pkgPath = path.join(path.dirname(entry), "..", "package.json");
13+ const version = (JSON.parse(readFileSync(pkgPath, "utf8")) as { version: string }).version;
14+ return {
15+ "process.platform": JSON.stringify("browser"),
16+ "process.env": "{}",
17+ __NUMBL_VERSION__: JSON.stringify(version),
18+ };
19+}
vitest.config.tsadded+10−0View file
@@ -0,0 +1,10 @@
1+import { defineConfig } from "vitest/config";
2+import { numblDefine } from "./vite.shared";
3+
4+export default defineConfig({
5+ define: { __NUMBL_VERSION__: numblDefine().__NUMBL_VERSION__ },
6+ test: {
7+ include: ["packages/*/test/**/*.test.ts"],
8+ testTimeout: 120_000,
9+ },
10+});
↑↓ move↵openescclose