Replace the two-file MATLAB project with one downloadable script
matlab/solve_template.m holds the whole solve as a single script with
{{PLACEHOLDER}} tokens; the engine fills in the parameters per solve
instead of staging params.json, and the UI offers the filled script for
download once a mesh is loaded (before solving too, since a solve might
fail or hang). The script starts with mip load --install surfacefun and
also runs in desktop MATLAB with the converted .msh alongside.
8 changed files+241−173
CLAUDE.mdmodified+21−14View file
@@ -5,18 +5,24 @@ Tips for future agents working in this repo.
55 ## Architecture
66
77 ```
8-matlab/ the MATLAB project each solve runs standalone
9- main.m `mip load --install surfacefun` -> jsondecode params.json
10- -> solve_pde('mesh.msh', params) -> write result.json
11- solve_pde.m surfacemesh.import -> resample -> surfaceop -> per-patch data
8+matlab/
9+ solve_template.m the whole solve as ONE script — mip load -> import ->
10+ resample -> surfaceop -> write result.json — with
11+ {{PLACEHOLDER}} tokens for the parameters. The engine's
12+ buildSolveScript fills them per solve, and the UI offers
13+ the filled script for download any time a mesh is loaded
14+ (before solving too — a solve might fail or hang). The
15+ same script runs in desktop MATLAB with the converted
16+ .msh alongside: via mip, or by commenting out the mip
17+ line and putting surfacefun on the path.
1218 src/mesh/ Pyodide + meshio upload pipeline (bridge.py runs in Pyodide)
1319 src/engine/ run-per-solve wrapper over numbl/browser's
14- createNumblSession: solve() boots a fresh session with
15- mesh.msh + params.json staged, reads result.json back via
16- session.readFile, and disposes the worker. numbl owns the
17- worker, VFS, mip bootstrap, and IndexedDB package
18- persistence; prewarm() at page load triggers the one-time
19- package download.
20+ createNumblSession: solve() fills the template and boots
21+ a fresh session with the script + mesh staged, reads
22+ result.json back via session.readFile, and disposes the
23+ worker. numbl owns the worker, VFS, mip bootstrap, and
24+ IndexedDB package persistence; prewarm() at page load
25+ triggers the one-time package download.
2026 src/pde/presets.ts PDE definitions, presets, slow-mesh warning threshold
2127 src/render/ three.js SurfaceView (mesh preview / solution) + parula
2228 scripts/engine-test.mjs headless Node check of the whole MATLAB pipeline
@@ -45,9 +51,9 @@ scripts/engine-test.mjs headless Node check of the whole MATLAB pipeline
4551 pass through meshio and get rewritten to 4.1.
4652 - **Triangle patches are flat vectors, not grids.** An order-p tri patch
4753 holds n(n+1)/2 points (n = p+1) in surfacefun's `trianglepts(n)` ordering;
48- quad patches are column-major n-by-n grids. `solve_pde.m` reports `ptype`
49- plus points-per-edge `n`, and SurfaceView triangulates tri patches with a
50- JS port of surfacefun's `trilattice.m`.
54+ quad patches are column-major n-by-n grids. `solve_template.m` reports
55+ `ptype` plus points-per-edge `n`, and SurfaceView triangulates tri patches
56+ with a JS port of surfacefun's `trilattice.m`.
5157 - **Solve errors reject the solve() promise** with the MATLAB error message
5258 (a failed script run is a numbl bootError). Each solve is a fresh session,
5359 so nothing needs to stay alive across failures.
@@ -65,7 +71,8 @@ scripts/engine-test.mjs headless Node check of the whole MATLAB pipeline
6571
6672 - `npm run engine-test` — full headless solve in Node against the local
6773 numbl build (dist-lib), including a quantitative eigenfunction check. It
68- runs matlab/main.m standalone per solve (as the browser does), sharing
74+ fills and runs matlab/solve_template.m standalone per solve (as the
75+ browser does; the fill logic mirrors engine.ts's buildSolveScript), sharing
6976 one VFS across solves as the stand-in for IndexedDB persistence, and
7077 passes the mip search path explicitly, exercising the same
7178 searchPaths-scan behavior the numbl/browser session relies on. Downloads
README.mdmodified+16−8View file
@@ -34,18 +34,26 @@ closed or open is detected from the edge connectivity.
3434 1. `src/mesh/` — meshio in Pyodide parses the upload, keeps the triangle and
3535 quad cells, and writes a canonical Gmsh MSH 4.1 ASCII file plus preview
3636 arrays.
37-2. `src/engine/` — each solve boots a fresh managed numbl session
37+2. `matlab/` — the whole solve is one MATLAB script, generated by filling
38+ the parameters into [`matlab/solve_template.m`](matlab/solve_template.m):
39+ `mip load --install surfacefun`, load the mesh with `surfacemesh.import`,
40+ `resample` to the requested order, solve with `surfaceop`, write
41+ `result.json`.
42+3. `src/engine/` — each solve boots a fresh managed numbl session
3843 (`createNumblSession` from `numbl/browser`): numbl owns the worker and
3944 VFS and bootstraps the [mip](https://github.com/mip-org) package manager.
40- The host stages `mesh.msh` and `params.json` and runs
41- [`matlab/main.m`](matlab/main.m) standalone — it begins with
42- `mip load --install surfacefun`, solves, and writes `result.json`, which
43- the host reads back before disposing the worker.
44-3. `matlab/solve_pde.m` — loads the mesh with `surfacemesh.import`,
45- `resample`s it to the requested order, and solves with `surfaceop`.
45+ The host stages the generated script and the converted mesh, runs the
46+ script standalone, and reads `result.json` back before disposing the
47+ worker.
4648 4. `src/render/SurfaceView.tsx` — three.js view of the mesh or the
4749 per-patch solution data with a parula colormap.
4850
51+The exact script the Solve button runs can be downloaded from the UI (even
52+before solving). It also runs in desktop MATLAB with the downloaded
53+converted `.msh` next to it: install
54+[surfacefun](https://github.com/danfortunato/surfacefun) via mip, or comment
55+out the `mip load` line and put surfacefun and its dependencies on your path.
56+
4957 The first visit downloads the Python runtime (~15 MB, browser-cached) and
5058 the surfacefun/chebfun packages (~28 MB). Installed MATLAB packages persist
5159 in IndexedDB across page loads (numbl wipes them after 24 h of inactivity),
@@ -61,7 +69,7 @@ npm run engine-test # headless solver check in Node (no browser)
6169 python3 scripts/make_samples.py # regenerate public/samples/
6270 ```
6371
64-The engine test runs the exact MATLAB project the worker runs, shimming
72+The engine test runs the exact MATLAB script the worker runs, shimming
6573 numbl's synchronous-XHR `websave`/`webread` with curl (responses cached in
6674 `.cache/`), and checks a Poisson solve against an exact spherical-harmonic
6775 solution.
matlab/main.mdeleted+0−14View file
@@ -1,14 +0,0 @@
1-% main.m — one solve, run standalone in a fresh numbl session each time.
2-% The host stages mesh.msh and params.json next to this script, runs it,
3-% and reads result.json back when it finishes. Installed packages persist
4-% across runs (IndexedDB in the browser), so only the first-ever run
5-% downloads surfacefun/chebfun.
6-
7-mip load --install surfacefun;
8-
9-params = jsondecode(fileread('params.json'));
10-result = solve_pde('mesh.msh', params);
11-
12-fid = fopen('result.json', 'w');
13-fprintf(fid, '%s', jsonencode(result));
14-fclose(fid);
matlab/solve_pde.mdeleted+0−84View file
@@ -1,84 +0,0 @@
1-function result = solve_pde(mshfile, params)
2-%SOLVE_PDE Load the surface mesh and solve the selected PDE on it.
3-% params fields (from the host UI):
4-% pde - 'poisson' (lap u = f) or 'helmholtz' ((lap + c) u = f)
5-% f - right-hand side, a MATLAB expression in x, y, z
6-% c - zeroth-order coefficient expression (helmholtz only)
7-% p - polynomial order per patch
8-% closed - true if every mesh edge is shared by exactly two cells
9-% (determined host-side from the connectivity)
10-
11-dom = surfacemesh.import(mshfile, 'gmsh');
12-dom = resample(dom, params.p + 1);
13-
14-fh = eval(['@(x, y, z) ', params.f]);
15-f = surfacefun(@(x, y, z) fh(x, y, z) + 0*x, dom);
16-
17-pdo = [];
18-pdo.lap = 1;
19-isPoisson = strcmp(params.pde, 'poisson');
20-if ~isPoisson
21- ch = eval(['@(x, y, z) ', params.c]);
22- pdo.c = @(x, y, z) ch(x, y, z) + 0*x;
23-end
24-
25-closed = params.closed;
26-if isPoisson && closed
27- % The closed-surface Laplace-Beltrami problem is rank-deficient by one
28- % and only solvable for mean-zero data; project the RHS accordingly.
29- f = f - mean(f);
30-end
31-
32-L = surfaceop(dom, pdo, f);
33-if closed
34- if isPoisson
35- L.rankdef = true;
36- end
37- u = L.solve();
38-else
39- u = L.solve(0); % zero Dirichlet boundary data on open surfaces
40-end
41-
42-result = pack_solution(dom, u);
43-result.pde = params.pde;
44-end
45-
46-function data = pack_solution(dom, u)
47-% One flat x/y/z/u array per patch: a column-major n-by-n grid for quad
48-% patches (the layout surfacefun-interactive's figure app uses), or the
49-% n*(n+1)/2-point trianglepts(n) set for triangle patches. data.n is the
50-% number of points per patch edge in both cases.
51-np = length(dom);
52-px = cell(1, np);
53-py = cell(1, np);
54-pz = cell(1, np);
55-pu = cell(1, np);
56-umin = inf;
57-umax = -inf;
58-for k = 1:np
59- px{k} = real(dom.x{k}(:).');
60- py{k} = real(dom.y{k}(:).');
61- pz{k} = real(dom.z{k}(:).');
62- vals = real(u.vals{k}(:).');
63- pu{k} = vals;
64- umin = min(umin, min(vals));
65- umax = max(umax, max(vals));
66-end
67-data = struct();
68-data.type = 'solution';
69-if ( dom.ptype(1) == surfacemesh.patchtype.tri )
70- npts = length(dom.x{1});
71- data.n = round((sqrt(8*npts + 1) - 1) / 2);
72- data.ptype = 'tri';
73-else
74- data.n = size(dom.x{1}, 1);
75- data.ptype = 'quad';
76-end
77-data.npatches = np;
78-data.x = px;
79-data.y = py;
80-data.z = pz;
81-data.u = pu;
82-data.umin = umin;
83-data.umax = umax;
84-end
matlab/solve_template.madded+95−0View file
@@ -0,0 +1,95 @@
1+% Solve a PDE on a surface mesh with surfacefun
2+% (https://github.com/danfortunato/surfacefun).
3+%
4+% Generated by mesh-pde-solver: the parameter values below come from the UI,
5+% and this exact script is what the Solve button runs in the browser (via
6+% numbl). To run it in desktop MATLAB, keep the mesh file (the app's
7+% converted .msh download) next to this script; without the mip package
8+% manager, comment out the `mip load` line and put surfacefun and its
9+% dependencies on the path yourself.
10+
11+mip load --install surfacefun;
12+
13+% ---- parameters -----------------------------------------------------------
14+meshfile = '{{MESHFILE}}';
15+pde = '{{PDE}}'; % 'poisson' or 'helmholtz'
16+frhs = @(x, y, z) {{F_EXPR}}; % right-hand side f
17+crhs = @(x, y, z) {{C_EXPR}}; % zeroth-order coefficient c (helmholtz only)
18+p = {{ORDER}}; % polynomial order per patch
19+% With the parameters baked in as constants, one branch of each pde/closed
20+% `if` below is statically dead — suppress the resulting lint file-wide.
21+closed = {{CLOSED}}; %#ok<*BDLGI,*BDSCI,*UNRCH> true if the mesh is closed
22+% ----------------------------------------------------------------------------
23+
24+dom = surfacemesh.import(meshfile, 'gmsh');
25+dom = resample(dom, p + 1);
26+
27+f = surfacefun(@(x, y, z) frhs(x, y, z) + 0*x, dom);
28+
29+pdo = [];
30+pdo.lap = 1;
31+isPoisson = strcmp(pde, 'poisson');
32+if ~isPoisson
33+ pdo.c = @(x, y, z) crhs(x, y, z) + 0*x;
34+end
35+
36+if isPoisson && closed
37+ % The closed-surface Laplace-Beltrami problem is rank-deficient by one
38+ % and only solvable for mean-zero data; project the RHS accordingly.
39+ f = f - mean(f);
40+end
41+
42+L = surfaceop(dom, pdo, f);
43+if closed
44+ if isPoisson
45+ L.rankdef = true;
46+ end
47+ u = L.solve();
48+else
49+ u = L.solve(0); % zero Dirichlet boundary data on open surfaces
50+end
51+
52+% In desktop MATLAB, visualize the solution with: plot(u), colorbar
53+
54+% ---- pack the solution as result.json for the host app --------------------
55+% One flat x/y/z/u array per patch: a column-major n-by-n grid for quad
56+% patches, or the n*(n+1)/2-point trianglepts(n) set for triangle patches.
57+% result.n is the number of points per patch edge in both cases.
58+np = length(dom);
59+px = cell(1, np);
60+py = cell(1, np);
61+pz = cell(1, np);
62+pu = cell(1, np);
63+umin = inf;
64+umax = -inf;
65+for k = 1:np
66+ px{k} = real(dom.x{k}(:).');
67+ py{k} = real(dom.y{k}(:).');
68+ pz{k} = real(dom.z{k}(:).');
69+ vals = real(u.vals{k}(:).');
70+ pu{k} = vals;
71+ umin = min(umin, min(vals));
72+ umax = max(umax, max(vals));
73+end
74+result = struct();
75+result.type = 'solution';
76+if ( dom.ptype(1) == surfacemesh.patchtype.tri )
77+ npts = length(dom.x{1});
78+ result.n = round((sqrt(8*npts + 1) - 1) / 2);
79+ result.ptype = 'tri';
80+else
81+ result.n = size(dom.x{1}, 1);
82+ result.ptype = 'quad';
83+end
84+result.npatches = np;
85+result.x = px;
86+result.y = py;
87+result.z = pz;
88+result.u = pu;
89+result.umin = umin;
90+result.umax = umax;
91+result.pde = pde;
92+
93+fid = fopen('result.json', 'w');
94+fprintf(fid, '%s', jsonencode(result));
95+fclose(fid);
scripts/engine-test.mjsmodified+28−18View file
@@ -1,11 +1,13 @@
1-// Headless validation of the solver — runs the same MATLAB project the
1+// Headless validation of the solver — runs the same MATLAB script the
22 // browser worker runs, in Node, against the installed numbl. Each solve
3-// stages params.json, runs matlab/main.m standalone (as the browser does in
4-// a fresh session), and reads result.json back from the VFS. The VFS is
5-// shared across solves, standing in for numbl/browser's IndexedDB-persisted
6-// /system, so `mip load --install surfacefun` only downloads once. Node has
7-// no synchronous XMLHttpRequest, so websave/webread are shimmed with curl
8-// (responses cached in .cache/ keyed by URL, so repeat runs are offline).
3+// fills matlab/solve_template.m with the parameters (mirroring
4+// buildSolveScript in src/engine/engine.ts), runs it standalone (as the
5+// browser does in a fresh session), and reads result.json back from the
6+// VFS. The VFS is shared across solves, standing in for numbl/browser's
7+// IndexedDB-persisted /system, so `mip load --install surfacefun` only
8+// downloads once. Node has no synchronous XMLHttpRequest, so
9+// websave/webread are shimmed with curl (responses cached in .cache/ keyed
10+// by URL, so repeat runs are offline).
911 //
1012 // npm run engine-test
1113
@@ -58,8 +60,20 @@ class NodeFileIOAdapter extends BrowserFileIOAdapter {
5860 }
5961 }
6062
61-const readProjectFile = name =>
62- fs.readFileSync(path.join(root, 'matlab', name), 'utf8')
63+const template = fs.readFileSync(path.join(root, 'matlab', 'solve_template.m'), 'utf8')
64+
65+// Mirrors buildSolveScript in src/engine/engine.ts.
66+const buildSolveScript = params => {
67+ const fills = {
68+ MESHFILE: 'mesh.msh',
69+ PDE: params.pde,
70+ F_EXPR: params.f,
71+ C_EXPR: params.pde === 'helmholtz' ? params.c : '0',
72+ ORDER: String(params.p),
73+ CLOSED: params.closed ? 'true' : 'false',
74+ }
75+ return template.replace(/\{\{(\w+)\}\}/g, (token, key) => fills[key] ?? token)
76+}
6377
6478 async function main() {
6579 const vfs = new VirtualFileSystem()
@@ -75,25 +89,21 @@ async function main() {
7589 }
7690 console.log(`mip core: ${nMip} files into VFS`)
7791
78- const projectFiles = ['main.m', 'solve_pde.m']
79- for (const name of projectFiles) {
80- vfs.writeFile(`/project/${name}`, enc.encode(readProjectFile(name)))
81- }
8292 vfs.writeFile(
8393 '/project/mesh.msh',
8494 fs.readFileSync(path.join(root, 'public', 'samples', 'sphere.msh'))
8595 )
8696 vfs.setCwd('/project')
8797
88- const workspaceFiles = projectFiles.map(n => ({name: n, source: readProjectFile(n)}))
8998 const decoder = new TextDecoder()
9099
91100 const solve = params => {
92- vfs.writeFile('/project/params.json', enc.encode(JSON.stringify(params)))
101+ const script = buildSolveScript(params)
102+ vfs.writeFile('/project/solve_pde.m', enc.encode(script))
93103 vfs.writeFile('/project/result.json', enc.encode('')) // no stale reads
94104 const t = Date.now()
95105 executeCode(
96- readProjectFile('main.m'),
106+ script,
97107 {
98108 onOutput: text => process.stdout.write(`[numbl] ${text}`),
99109 onDrawnow: () => {},
@@ -103,8 +113,8 @@ async function main() {
103113 fileIO: new NodeFileIOAdapter(vfs),
104114 system: new BrowserSystemAdapter(vfs),
105115 },
106- workspaceFiles,
107- vfs.normalizePath('/project/main.m'),
116+ [{name: 'solve_pde.m', source: script}],
117+ vfs.normalizePath('/project/solve_pde.m'),
108118 [MIP_SEARCH_PATH]
109119 )
110120 const result = JSON.parse(decoder.decode(vfs.readFile('/project/result.json')))
src/App.tsxmodified+52−22View file
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from 'react'
22 import { ACCEPT, formatForFilename } from './mesh/formats'
33 import { initMeshio, parseMeshFile } from './mesh/meshio'
44 import { edgeClassification, type SurfaceMeshData } from './mesh/surfacemesh'
5-import { prewarm, solve, type SolutionData } from './engine/engine'
5+import { buildSolveScript, prewarm, solve, type SolutionData } from './engine/engine'
66 import {
77 PDES,
88 SLOW_CELLS,
@@ -33,6 +33,19 @@ const SAMPLES = [
3333 { label: 'Torus', file: 'torus.msh' },
3434 ]
3535
36+// Filename the converted mesh is staged and downloaded under — embedded in
37+// the generated MATLAB script, so sanitized to stay a plain quotable token.
38+const mshFileName = (name: string) =>
39+ (name.replace(/\.[^.]*$/, '').replace(/[^\w.-]+/g, '_') || 'mesh') + '.msh'
40+
41+const download = (filename: string, content: BlobPart) => {
42+ const a = document.createElement('a')
43+ a.href = URL.createObjectURL(new Blob([content], { type: 'application/octet-stream' }))
44+ a.download = filename
45+ a.click()
46+ URL.revokeObjectURL(a.href)
47+}
48+
3649 export default function App() {
3750 const [meshioStatus, setMeshioStatus] = useState('Loading Python runtime…')
3851 const [meshioReady, setMeshioReady] = useState(false)
@@ -131,25 +144,33 @@ export default function App() {
131144 [loadMesh],
132145 )
133146
147+ // Everything the MATLAB script template needs, from the current UI state.
148+ const solveParams = useCallback(
149+ () =>
150+ mesh && {
151+ pde: pde.id,
152+ f: fExpr.trim(),
153+ c: cExpr.trim(),
154+ p: order,
155+ closed: mesh.closed,
156+ meshFile: mshFileName(mesh.name),
157+ },
158+ [mesh, pde, fExpr, cExpr, order],
159+ )
160+
134161 const onSolve = useCallback(async () => {
135- if (!mesh) return
162+ const params = solveParams()
163+ if (!mesh || !params) return
136164 setSolveError(null)
137165 setSolving(true)
138166 setSolveStatus('')
139167 setSolveSeconds(null)
140168 const t0 = performance.now()
141169 try {
142- const result = await solve(
143- mesh.data.mshBytes,
144- {
145- pde: pde.id,
146- f: fExpr.trim(),
147- c: cExpr.trim(),
148- p: order,
149- closed: mesh.closed,
150- },
151- { onProgress: setSolveStatus, onOutput: appendConsole },
152- )
170+ const result = await solve(mesh.data.mshBytes, params, {
171+ onProgress: setSolveStatus,
172+ onOutput: appendConsole,
173+ })
153174 setSolution(result)
154175 setSolveSeconds((performance.now() - t0) / 1000)
155176 } catch (err) {
@@ -157,18 +178,17 @@ export default function App() {
157178 } finally {
158179 setSolving(false)
159180 }
160- }, [mesh, pde, fExpr, cExpr, order, appendConsole])
181+ }, [mesh, solveParams, appendConsole])
161182
162183 const onDownloadMsh = useCallback(() => {
163- if (!mesh) return
164- const blob = new Blob([mesh.data.mshBytes as BlobPart], { type: 'application/octet-stream' })
165- const a = document.createElement('a')
166- a.href = URL.createObjectURL(blob)
167- a.download = mesh.name.replace(/\.[^.]*$/, '') + '.msh'
168- a.click()
169- URL.revokeObjectURL(a.href)
184+ if (mesh) download(mshFileName(mesh.name), mesh.data.mshBytes as BlobPart)
170185 }, [mesh])
171186
187+ const onDownloadScript = useCallback(() => {
188+ const params = solveParams()
189+ if (params) download('solve_pde.m', buildSolveScript(params))
190+ }, [solveParams])
191+
172192 const onPdeChange = (id: string) => {
173193 const def = PDES.find((p) => p.id === id) ?? PDES[0]
174194 setPde(def)
@@ -182,7 +202,9 @@ export default function App() {
182202 ? (mesh.numCells * (order + 1) * (order + 2)) / 2
183203 : mesh.numCells * (order + 1) * (order + 1)
184204 : 0
185- const canSolve = !!mesh && engineReady && !solving && fExpr.trim() !== ''
205+ // Empty expressions would leave a syntactically broken generated script.
206+ const paramsOk = !!mesh && fExpr.trim() !== '' && (!pde.cPresets || cExpr.trim() !== '')
207+ const canSolve = paramsOk && engineReady && !solving
186208
187209 return (
188210 <div className="app">
@@ -352,6 +374,14 @@ export default function App() {
352374 </p>
353375 ) : null}
354376 </div>
377+ <button className="linkish" onClick={onDownloadScript} disabled={!paramsOk}>
378+ Download MATLAB script
379+ </button>
380+ <p className="hint">
381+ The exact script Solve runs, with the current parameters filled in. It also runs
382+ in desktop MATLAB with the converted .msh (from section 1) next to it — see the
383+ script's header comments.
384+ </p>
355385 </section>
356386
357387 <section>
src/engine/engine.tsmodified+29−13View file
@@ -1,12 +1,11 @@
1-// Run-per-solve engine: each solve boots a fresh numbl/browser session that
2-// runs matlab/main.m standalone (mip load + solve_pde), reads result.json
3-// back from the session VFS, and disposes the worker. numbl persists the
4-// installed packages in IndexedDB, so only the first-ever run downloads them;
5-// prewarm() triggers that download at page load.
1+// Run-per-solve engine: each solve fills matlab/solve_template.m with the
2+// parameters, boots a fresh numbl/browser session that runs it standalone,
3+// reads result.json back from the session VFS, and disposes the worker.
4+// numbl persists the installed packages in IndexedDB, so only the first-ever
5+// run downloads them; prewarm() triggers that download at page load.
66
77 import { createNumblSession, type NumblSession } from 'numbl/browser'
8-import main from '../../matlab/main.m?raw'
9-import solvePde from '../../matlab/solve_pde.m?raw'
8+import solveTemplate from '../../matlab/solve_template.m?raw'
109
1110 const SOLVE_TIMEOUT_MS = 300_000
1211
@@ -20,9 +19,28 @@ export interface SolveParams {
2019 p: number
2120 /** every mesh edge shared by exactly two cells (from edgeClassification) */
2221 closed: boolean
22+ /** filename the mesh is staged under, referenced by the generated script */
23+ meshFile: string
2324 }
2425
25-/** Per-patch solution data, as packed by matlab/solve_pde.m. */
26+/**
27+ * Fill matlab/solve_template.m with actual parameter values. The result is
28+ * the exact script a solve runs, and what the UI offers for download — it
29+ * also runs in desktop MATLAB with surfacefun on the path.
30+ */
31+export function buildSolveScript(params: SolveParams): string {
32+ const fills: Record<string, string> = {
33+ MESHFILE: params.meshFile,
34+ PDE: params.pde,
35+ F_EXPR: params.f,
36+ C_EXPR: params.pde === 'helmholtz' ? params.c : '0',
37+ ORDER: String(params.p),
38+ CLOSED: params.closed ? 'true' : 'false',
39+ }
40+ return solveTemplate.replace(/\{\{(\w+)\}\}/g, (token, key) => fills[key] ?? token)
41+}
42+
43+/** Per-patch solution data, as packed by matlab/solve_template.m. */
2644 export interface SolutionData {
2745 type: 'solution'
2846 /** points per patch edge (p + 1); quad patches carry n*n points
@@ -77,12 +95,10 @@ export async function solve(
7795 (async () => {
7896 session = await createNumblSession({
7997 files: [
80- { path: 'main.m', content: main },
81- { path: 'solve_pde.m', content: solvePde },
82- { path: 'params.json', content: JSON.stringify(params) },
83- { path: 'mesh.msh', content: meshBytes },
98+ { path: 'solve_pde.m', content: buildSolveScript(params) },
99+ { path: params.meshFile, content: meshBytes },
84100 ],
85- mainFile: 'main.m',
101+ mainFile: 'solve_pde.m',
86102 onProgress: hooks.onProgress,
87103 onOutput: hooks.onOutput,
88104 })