Simplify the solver to run-per-solve; drop the uihtml event bridge
Each solve now boots a fresh numbl session that runs matlab/main.m
standalone: mip load, solve_pde on the staged mesh.msh/params.json, and
write result.json, which the host reads back with NumblSession.readFile
(new in numbl 0.4.10). Solve errors surface as the run's rejection, so
the solveError event protocol goes away too. Removes placeholder.html,
solver_session.m, and the engine's event/waiter plumbing; a prewarm
session at page load keeps the one-time package download off the first
solve.
13 changed files+221−354
CLAUDE.mdmodified+23−18View file
@@ -5,17 +5,20 @@ Tips for future agents working in this repo.
55 ## Architecture
66
77 ```
8-matlab/ the MATLAB project the numbl session runs
9- main.m `mip load --install surfacefun` -> solver_session()
10- solver_session.m opens the placeholder uihtml figure (the event bridge)
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
1111 solve_pde.m mesh -> surfacemesh -> resample -> surfaceop -> per-patch data
1212 load_gmsh_quads.m minimal MSH 2.2 ASCII reader (canonical form only)
1313 surfacemesh_from_quads.m replaces surfacemesh.fromGmsh (see below)
1414 src/mesh/ Pyodide + meshio upload pipeline (bridge.py runs in Pyodide)
15-src/engine/ thin wrapper over numbl/browser's createNumblSession:
16- the app-level solve protocol (mesh.msh + 'solve' events,
17- one in flight, timeout). numbl owns the worker, VFS, mip
18- bootstrap, and IndexedDB package persistence.
15+src/engine/ run-per-solve wrapper over numbl/browser's
16+ createNumblSession: solve() boots a fresh session with
17+ mesh.msh + params.json staged, reads result.json back via
18+ session.readFile, and disposes the worker. numbl owns the
19+ worker, VFS, mip bootstrap, and IndexedDB package
20+ persistence; prewarm() at page load triggers the one-time
21+ package download.
1922 src/pde/presets.ts PDE definitions, presets, size limits
2023 src/render/ three.js SurfaceView (mesh preview / solution) + parula
2124 scripts/engine-test.mjs headless Node check of the whole MATLAB pipeline
@@ -23,10 +26,10 @@ scripts/engine-test.mjs headless Node check of the whole MATLAB pipeline
2326
2427 ## Key gotchas
2528
26-- **numbl >= 0.4.9 from npm** (`numbl/browser` entry, executeCode
27- searchPaths scanning, and the flip/fieldnames/containers.Map compat fixes
28- all landed in 0.4.9). To develop against a local numbl checkout, point
29- package.json at `file:../../numbl` and run
29+- **numbl >= 0.4.10 from npm** (`NumblSession.readFile`, which the engine
30+ uses to fetch result.json, landed in 0.4.10; the `numbl/browser` entry and
31+ executeCode searchPaths scanning landed in 0.4.9). To develop against a
32+ local numbl checkout, point package.json at `file:../../numbl` and run
3033 `npm run build:lib && npm run build:browser` there after source changes.
3134 - **surfacemesh.fromGmsh is not used.** It locates the QUADS field via
3235 `startsWith(fieldnames(...), 'QUADS')` (cellstr startsWith — unsupported in
@@ -36,22 +39,24 @@ scripts/engine-test.mjs headless Node check of the whole MATLAB pipeline
3639 `src/mesh/bridge.py` writes: MSH 2.2 ASCII, sequential 1-based node ids,
3740 type-3 elements, two tags. Uploaded .msh files in other layouts are fine —
3841 they pass through meshio and get rewritten canonically.
39-- **Solve-time errors are recoverable**: solver_session catches them and
40- sends a `solveError` event; the uihtml session stays live for the next
41- solve. Boot errors and dispatch-level interpreter errors are surfaced by
42- the engine wrapper.
42+- **Solve errors reject the solve() promise** with the MATLAB error message
43+ (a failed script run is a numbl bootError). Each solve is a fresh session,
44+ so nothing needs to stay alive across failures.
4345 - **jsonencode collapses 1-element vectors to scalars.** Patch arrays are
4446 (p+1)^2 >= 9 long so it never bites here, but remember it when adding
4547 payload fields.
4648 - Package caching: numbl/browser persists /system (mip + installed
47- packages) in IndexedDB, wiped after 24 h of inactivity — first solve of a
48- fresh day re-downloads ~28 MB. Delete the `numbl-embed-system` IndexedDB
49- database to test cold boots.
49+ packages) in IndexedDB, wiped after 24 h of inactivity — the prewarm
50+ session at page load re-downloads ~28 MB on a fresh day; solves after
51+ that only pay a per-run `mip load` (~1 s). Delete the
52+ `numbl-embed-system` IndexedDB database to test cold boots.
5053
5154 ## Testing
5255
5356 - `npm run engine-test` — full headless solve in Node against the local
5457 numbl build (dist-lib), including a quantitative eigenfunction check. It
58+ runs matlab/main.m standalone per solve (as the browser does), sharing
59+ one VFS across solves as the stand-in for IndexedDB persistence, and
5560 passes the mip search path explicitly, exercising the same
5661 searchPaths-scan behavior the numbl/browser session relies on. Downloads
5762 are cached in `.cache/` keyed by URL; delete the cache to test fresh
README.mdmodified+9−9View file
@@ -32,14 +32,13 @@ Whether the surface is closed or open is detected from the edge connectivity.
3232
3333 1. `src/mesh/` — meshio in Pyodide parses the upload, keeps the quad cells,
3434 and writes a canonical Gmsh MSH 2.2 ASCII file plus preview arrays.
35-2. `src/engine/` — a managed numbl session (`createNumblSession` from
36- `numbl/browser`): numbl owns the worker and VFS, bootstraps the
37- [mip](https://github.com/mip-org) package manager, and runs
38- [`matlab/main.m`](matlab/main.m), which begins with
39- `mip load --install surfacefun`. The script opens a placeholder `uihtml`
40- figure that is never rendered — it is the event bridge: the host writes
41- `mesh.msh` into the VFS and dispatches `solve` events; the script solves
42- and sends per-patch data back.
35+2. `src/engine/` — each solve boots a fresh managed numbl session
36+ (`createNumblSession` from `numbl/browser`): numbl owns the worker and
37+ VFS and bootstraps the [mip](https://github.com/mip-org) package manager.
38+ The host stages `mesh.msh` and `params.json` and runs
39+ [`matlab/main.m`](matlab/main.m) standalone — it begins with
40+ `mip load --install surfacefun`, solves, and writes `result.json`, which
41+ the host reads back before disposing the worker.
4342 3. `matlab/solve_pde.m` — parses the mesh (`load_gmsh_quads.m`), builds a
4443 `surfacemesh` from the quads, `resample`s it to the requested order, and
4544 solves with `surfaceop`.
@@ -66,4 +65,5 @@ numbl's synchronous-XHR `websave`/`webread` with curl (responses cached in
6665 `.cache/`), and checks a Poisson solve against an exact spherical-harmonic
6766 solution.
6867
69-Requires numbl >= 0.4.9 (the `numbl/browser` managed-session entry).
68+Requires numbl >= 0.4.10 (the `numbl/browser` managed-session entry with
69+`readFile`).
matlab/main.mmodified+11−10View file
@@ -1,13 +1,14 @@
1-% main.m — persistent solver session for mesh-pde-solver.
2-%
3-% Runs once inside a numbl/browser managed session, which bootstraps the mip
4-% package manager (and puts it on the path) before this script starts. mip
5-% fetches surfacefun and its chebfun dependency on first use; the session
6-% persists installed packages across page loads. The script then opens a
7-% placeholder uihtml figure purely as an event bridge: the host writes
8-% mesh.msh into the VFS and dispatches 'solve' events with the PDE
9-% parameters; solver_session solves and sends back per-patch data.
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.
106
117 mip load --install surfacefun;
128
13-solver_session();
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/placeholder.htmldeleted+0−1View file
@@ -1 +0,0 @@
1-<!-- headless: the uihtml bridge is intercepted host-side and never rendered -->
matlab/solver_session.mdeleted+0−22View file
@@ -1,22 +0,0 @@
1-function solver_session()
2-%SOLVER_SESSION Open the uihtml event bridge and serve solve requests.
3-% The figure is never rendered — the host intercepts the uihtml component
4-% and speaks its event protocol directly (see src/engine/).
5-
6-html = fileread('placeholder.html');
7-fig = figure;
8-uihtml(fig, 'HTMLSource', html, 'Data', struct('type', 'ready'), ...
9- 'HTMLEventReceivedFcn', @on_event);
10-end
11-
12-function on_event(src, ev)
13-if ~strcmp(ev.HTMLEventName, 'solve')
14- return
15-end
16-try
17- result = solve_pde('mesh.msh', ev.HTMLEventData);
18- sendEventToHTMLSource(src, 'solution', result);
19-catch err
20- sendEventToHTMLSource(src, 'solveError', struct('message', err.message));
21-end
22-end
package-lock.jsonmodified+4−4View file
@@ -9,7 +9,7 @@
99 "version": "0.0.0",
1010 "dependencies": {
1111 "fflate": "^0.8.2",
12- "numbl": "^0.4.9",
12+ "numbl": "^0.4.10",
1313 "react": "^19.2.7",
1414 "react-dom": "^19.2.7",
1515 "three": "^0.185.1"
@@ -2483,9 +2483,9 @@
24832483 }
24842484 },
24852485 "node_modules/numbl": {
2486- "version": "0.4.9",
2487- "resolved": "https://registry.npmjs.org/numbl/-/numbl-0.4.9.tgz",
2488- "integrity": "sha512-XncVGrYIg+5UksGzbj3f5gcGOkMZVYJAz44p5/cd7rInWiLoI0qBWUKejeS95K1wuXayoFBaD+jaM8ULleBxPg==",
2486+ "version": "0.4.10",
2487+ "resolved": "https://registry.npmjs.org/numbl/-/numbl-0.4.10.tgz",
2488+ "integrity": "sha512-rckOPXCjh6u6LnLoXVy4MupKG/f5wPK77NRq7vWtKjO5vq+9BN6cHgkh92xQkAKnmUFEkFebH2jqmN5OqSZJfQ==",
24892489 "hasInstallScript": true,
24902490 "license": "Apache-2.0",
24912491 "dependencies": {
package.jsonmodified+1−1View file
@@ -12,7 +12,7 @@
1212 },
1313 "dependencies": {
1414 "fflate": "^0.8.2",
15- "numbl": "^0.4.9",
15+ "numbl": "^0.4.10",
1616 "react": "^19.2.7",
1717 "react-dom": "^19.2.7",
1818 "three": "^0.185.1"
scripts/engine-test.mjsmodified+48−65View file
@@ -1,9 +1,11 @@
1-// Headless validation of the solver engine — runs the same MATLAB project the
2-// browser worker runs, in Node, against the installed numbl. Bootstraps mip
3-// exactly like the worker does and lets `mip load --install surfacefun` in
4-// main.m fetch surfacefun/chebfun itself. Node has no synchronous
5-// XMLHttpRequest, so websave/webread are shimmed with curl (responses cached
6-// in .cache/ keyed by URL, so repeat runs are offline).
1+// Headless validation of the solver — runs the same MATLAB project the
2+// 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).
79 //
810 // npm run engine-test
911
@@ -75,11 +77,9 @@ async function main() {
7577
7678 const projectFiles = [
7779 'main.m',
78- 'solver_session.m',
7980 'solve_pde.m',
8081 'surfacemesh_from_quads.m',
8182 'load_gmsh_quads.m',
82- 'placeholder.html',
8383 ]
8484 for (const name of projectFiles) {
8585 vfs.writeFile(`/project/${name}`, enc.encode(readProjectFile(name)))
@@ -88,57 +88,37 @@ async function main() {
8888 '/project/mesh.msh',
8989 fs.readFileSync(path.join(root, 'public', 'samples', 'sphere.msh'))
9090 )
91- vfs.clearChangeTracking()
9291 vfs.setCwd('/project')
9392
94- const events = []
95- let compId = null
96-
97- console.log('running main.m (mip load --install surfacefun) ...')
98- const t0 = Date.now()
99- const result = executeCode(
100- readProjectFile('main.m'),
101- {
102- onOutput: text => process.stdout.write(`[numbl] ${text}`),
103- onDrawnow: () => {},
104- displayResults: false,
105- maxIterations: 1e9,
106- optimization: '1',
107- fileIO: new NodeFileIOAdapter(vfs),
108- system: new BrowserSystemAdapter(vfs),
109- onHtmlSourceEvent: (id, name, dataJson) =>
110- events.push({name, data: JSON.parse(dataJson)}),
111- },
112- projectFiles
113- .filter(n => n.endsWith('.m'))
114- .map(n => ({name: n, source: readProjectFile(n)})),
115- vfs.normalizePath('/project/main.m'),
116- [MIP_SEARCH_PATH]
117- )
118- for (const pi of result.plotInstructions) {
119- if (pi.type === 'uihtml') compId = pi.id
120- }
121- console.log(`boot: ${(Date.now() - t0) / 1000}s, uihtml comp = ${compId}`)
122- const session = result.uihtmlSession
123- if (!session || !compId) throw new Error('no live uihtml session after run')
93+ const workspaceFiles = projectFiles.map(n => ({name: n, source: readProjectFile(n)}))
94+ const decoder = new TextDecoder()
12495
12596 const solve = params => {
126- events.length = 0
97+ vfs.writeFile('/project/params.json', enc.encode(JSON.stringify(params)))
98+ vfs.writeFile('/project/result.json', enc.encode('')) // no stale reads
12799 const t = Date.now()
128- session.dispatchEvent(compId, 'HTMLEventReceived', {
129- name: 'solve',
130- data: params,
131- })
132- const ev = events[events.length - 1]
133- if (!ev) throw new Error('no event came back from solve')
134- console.log(`solve [${params.pde}] -> '${ev.name}' in ${(Date.now() - t) / 1000}s`)
135- return ev
100+ executeCode(
101+ readProjectFile('main.m'),
102+ {
103+ onOutput: text => process.stdout.write(`[numbl] ${text}`),
104+ onDrawnow: () => {},
105+ displayResults: false,
106+ maxIterations: 1e9,
107+ optimization: '1',
108+ fileIO: new NodeFileIOAdapter(vfs),
109+ system: new BrowserSystemAdapter(vfs),
110+ },
111+ workspaceFiles,
112+ vfs.normalizePath('/project/main.m'),
113+ [MIP_SEARCH_PATH]
114+ )
115+ const result = JSON.parse(decoder.decode(vfs.readFile('/project/result.json')))
116+ console.log(`solve [${params.pde}] in ${(Date.now() - t) / 1000}s`)
117+ return result
136118 }
137119
138120 // 1. Poisson on the closed sphere
139- let ev = solve({pde: 'poisson', f: 'x.*y.*z', c: '', p: 6, closed: true})
140- if (ev.name !== 'solution') throw new Error(`poisson failed: ${JSON.stringify(ev.data)}`)
141- let d = ev.data
121+ let d = solve({pde: 'poisson', f: 'x.*y.*z', c: '', p: 6, closed: true})
142122 console.log(` npatches=${d.npatches} n=${d.n} u in [${d.umin.toFixed(6)}, ${d.umax.toFixed(6)}]`)
143123 if (d.npatches !== 216 || d.n !== 7) throw new Error('unexpected solution shape')
144124 if (!isFinite(d.umin) || !isFinite(d.umax) || d.umin === d.umax)
@@ -147,25 +127,28 @@ async function main() {
147127 // Eigenfunction check: x*y*z is a degree-3 solid harmonic, so on the unit
148128 // sphere lap_S (x*y*z) = -12 * (x*y*z). Solving with f = -12*x*y*z must
149129 // reproduce u = x*y*z, whose max on the sphere is 1/(3*sqrt(3)).
150- ev = solve({pde: 'poisson', f: '-12*(x.*y.*z)', c: '', p: 8, closed: true})
151- d = ev.data
130+ d = solve({pde: 'poisson', f: '-12*(x.*y.*z)', c: '', p: 8, closed: true})
152131 const expected = 1 / (3 * Math.sqrt(3))
153132 console.log(` eigencheck: umax=${d.umax.toFixed(6)} expected~${expected.toFixed(6)}`)
154133 if (Math.abs(d.umax - expected) > 0.01) throw new Error('eigenfunction check failed')
155134
156135 // 2. Helmholtz with a variable coefficient
157- ev = solve({pde: 'helmholtz', f: '1 + 0*x', c: '100*(1 - z)', p: 6, closed: true})
158- if (ev.name !== 'solution') throw new Error(`helmholtz failed: ${JSON.stringify(ev.data)}`)
159- console.log(` u in [${ev.data.umin.toFixed(6)}, ${ev.data.umax.toFixed(6)}]`)
160-
161- // 3. Bad expression surfaces as a solveError, session stays alive
162- ev = solve({pde: 'poisson', f: 'this is not matlab', c: '', p: 4, closed: true})
163- if (ev.name !== 'solveError') throw new Error('expected solveError for bad expression')
164- console.log(` error path OK: ${JSON.stringify(ev.data).slice(0, 100)}`)
165-
166- // 4. Session still works after an error
167- ev = solve({pde: 'poisson', f: 'x', c: '', p: 4, closed: true})
168- if (ev.name !== 'solution') throw new Error('session did not survive the error')
136+ d = solve({pde: 'helmholtz', f: '1 + 0*x', c: '100*(1 - z)', p: 6, closed: true})
137+ console.log(` u in [${d.umin.toFixed(6)}, ${d.umax.toFixed(6)}]`)
138+
139+ // 3. Bad expression errors out of the run (the host surfaces the message)
140+ let err = null
141+ try {
142+ solve({pde: 'poisson', f: 'this is not matlab', c: '', p: 4, closed: true})
143+ } catch (e) {
144+ err = e
145+ }
146+ if (!err) throw new Error('expected an error for bad expression')
147+ console.log(` error path OK: ${String(err.message).slice(0, 100)}`)
148+
149+ // 4. A later solve is unaffected (fresh run per solve)
150+ d = solve({pde: 'poisson', f: 'x', c: '', p: 4, closed: true})
151+ if (d.type !== 'solution') throw new Error('solve after error failed')
169152
170153 console.log('engine-test: all checks passed')
171154 }
src/App.tsxmodified+37−43View file
@@ -1,9 +1,8 @@
1-import { useCallback, useEffect, useRef, useState } from 'react'
1+import { useCallback, useEffect, useState } from 'react'
22 import { ACCEPT, formatForFilename } from './mesh/formats'
33 import { initMeshio, parseMeshFile } from './mesh/meshio'
44 import { edgeClassification, type QuadMeshData } from './mesh/quadmesh'
5-import { SolverEngine } from './engine/engine'
6-import type { SolutionData } from './engine/protocol'
5+import { prewarm, solve, type SolutionData } from './engine/engine'
76 import {
87 PDES,
98 MAX_QUADS,
@@ -15,18 +14,9 @@ import {
1514 } from './pde/presets'
1615 import { SurfaceView } from './render/SurfaceView'
1716
18-// Module-level singletons so React StrictMode double-mounting doesn't boot
19-// two engines (each boot downloads the MATLAB packages).
20-let engineSingleton: SolverEngine | null = null
21-function getEngine(): SolverEngine {
22- if (!engineSingleton) {
23- engineSingleton = new SolverEngine()
24- engineSingleton.start().catch(() => {
25- /* surfaced through onProgress/status below */
26- })
27- }
28- return engineSingleton
29-}
17+// Module-level so React StrictMode double-mounting doesn't prewarm twice
18+// (the prewarm downloads the MATLAB packages into numbl's IndexedDB cache).
19+let prewarmPromise: Promise<void> | null = null
3020
3121 interface LoadedMesh {
3222 name: string
@@ -44,13 +34,9 @@ const SAMPLES = [
4434 ]
4535
4636 export default function App() {
47- const engineRef = useRef<SolverEngine>(null)
48- if (!engineRef.current) engineRef.current = getEngine()
49- const engine = engineRef.current
50-
5137 const [meshioStatus, setMeshioStatus] = useState('Loading Python runtime…')
5238 const [meshioReady, setMeshioReady] = useState(false)
53- const [engineStatus, setEngineStatus] = useState('Starting MATLAB engine…')
39+ const [engineStatus, setEngineStatus] = useState('Preparing MATLAB packages…')
5440 const [engineReady, setEngineReady] = useState(false)
5541 const [consoleLines, setConsoleLines] = useState<string[]>([])
5642
@@ -64,10 +50,15 @@ export default function App() {
6450 const [order, setOrder] = useState(DEFAULT_ORDER)
6551
6652 const [solving, setSolving] = useState(false)
53+ const [solveStatus, setSolveStatus] = useState('')
6754 const [solveError, setSolveError] = useState<string | null>(null)
6855 const [solution, setSolution] = useState<SolutionData | null>(null)
6956 const [solveSeconds, setSolveSeconds] = useState<number | null>(null)
7057
58+ const appendConsole = useCallback((text: string) => {
59+ setConsoleLines((lines) => [...lines.slice(-199), text.replace(/\n$/, '')])
60+ }, [])
61+
7162 useEffect(() => {
7263 initMeshio(setMeshioStatus)
7364 .then(() => {
@@ -76,20 +67,17 @@ export default function App() {
7667 })
7768 .catch((err) => setMeshioStatus(`Mesh reader failed: ${String(err.message ?? err)}`))
7869
79- engine.onProgress = setEngineStatus
80- engine.onError = (message) => setEngineStatus(`Engine failed: ${message}`)
81- engine.onOutput = (text) =>
82- setConsoleLines((lines) => [...lines.slice(-199), text.replace(/\n$/, '')])
83- if (engine.lastError) setEngineStatus(`Engine failed: ${engine.lastError}`)
84- const poll = setInterval(() => {
85- if (engine.isReady) {
70+ if (!prewarmPromise) {
71+ prewarmPromise = prewarm({ onProgress: setEngineStatus, onOutput: appendConsole })
72+ }
73+ prewarmPromise
74+ // A failed prewarm isn't fatal — the solve re-attempts the downloads.
75+ .catch((err) => appendConsole(`package prewarm failed: ${String(err?.message ?? err)}`))
76+ .finally(() => {
8677 setEngineReady(true)
8778 setEngineStatus('')
88- clearInterval(poll)
89- }
90- }, 250)
91- return () => clearInterval(poll)
92- }, [engine])
79+ })
80+ }, [appendConsole])
9381
9482 const loadMesh = useCallback(
9583 async (name: string, bytes: Uint8Array) => {
@@ -117,7 +105,6 @@ export default function App() {
117105 nonManifold: cls.nonManifold,
118106 warnings: result.warnings,
119107 })
120- engine.setMesh(result.mesh.mshBytes)
121108 } catch (err) {
122109 setMesh(null)
123110 setMeshError(err instanceof Error ? err.message : String(err))
@@ -125,7 +112,7 @@ export default function App() {
125112 setParsing(false)
126113 }
127114 },
128- [engine],
115+ [],
129116 )
130117
131118 const onUpload = useCallback(
@@ -154,16 +141,21 @@ export default function App() {
154141 if (!mesh) return
155142 setSolveError(null)
156143 setSolving(true)
144+ setSolveStatus('')
157145 setSolveSeconds(null)
158146 const t0 = performance.now()
159147 try {
160- const result = await engine.solve({
161- pde: pde.id,
162- f: fExpr.trim(),
163- c: cExpr.trim(),
164- p: order,
165- closed: mesh.closed,
166- })
148+ const result = await solve(
149+ mesh.data.mshBytes,
150+ {
151+ pde: pde.id,
152+ f: fExpr.trim(),
153+ c: cExpr.trim(),
154+ p: order,
155+ closed: mesh.closed,
156+ },
157+ { onProgress: setSolveStatus, onOutput: appendConsole },
158+ )
167159 setSolution(result)
168160 setSolveSeconds((performance.now() - t0) / 1000)
169161 } catch (err) {
@@ -171,7 +163,7 @@ export default function App() {
171163 } finally {
172164 setSolving(false)
173165 }
174- }, [engine, mesh, pde, fExpr, cExpr, order])
166+ }, [mesh, pde, fExpr, cExpr, order, appendConsole])
175167
176168 const onDownloadMsh = useCallback(() => {
177169 if (!mesh) return
@@ -345,7 +337,9 @@ export default function App() {
345337 {solving ? 'Solving…' : 'Solve'}
346338 </button>
347339 <div className="solve-status">
348- {booting ? (
340+ {solving ? (
341+ <p className="status">{solveStatus || 'Solving…'}</p>
342+ ) : booting ? (
349343 <p className="status">
350344 {[meshioStatus, engineStatus].filter(Boolean).join(' · ') || 'Preparing…'}
351345 </p>
src/engine/engine.tsmodified+87−128View file
@@ -1,139 +1,98 @@
1-// The solver engine, built on numbl/browser's managed session: numbl owns
2-// the worker, the VFS, the mip bootstrap (main.m does
3-// `mip load --install surfacefun`), and IndexedDB persistence of installed
4-// packages across page loads. This wrapper adds the app's solve protocol:
5-// mesh.msh in the VFS + one 'solve' event in flight at a time, with a
6-// timeout.
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.
76
87 import { createNumblSession, type NumblSession } from 'numbl/browser'
9-import { PROJECT_FILES, MAIN_FILE } from './project'
10-import type { SolveParams, SolutionData } from './protocol'
8+import main from '../../matlab/main.m?raw'
9+import solvePde from '../../matlab/solve_pde.m?raw'
10+import surfacemeshFromQuads from '../../matlab/surfacemesh_from_quads.m?raw'
11+import loadGmshQuads from '../../matlab/load_gmsh_quads.m?raw'
1112
1213 const SOLVE_TIMEOUT_MS = 300_000
1314
14-export class EngineError extends Error {}
15-
16-export class SolverEngine {
17- private session: NumblSession | null = null
18- private compId: string | null = null
19- private disposed = false
20- private pendingMesh: Uint8Array | null = null
21-
22- private solveWaiter: {
23- resolve: (data: SolutionData) => void
24- reject: (err: Error) => void
25- timeoutId: ReturnType<typeof setTimeout>
26- } | null = null
27-
28- /** Boot progress messages (downloads, engine start) for the UI. */
29- onProgress: (message: string) => void = () => {}
30- /** MATLAB console output (mip install logs etc.), for a console panel. */
31- onOutput: (text: string) => void = () => {}
32- /** Hard failures (boot errors) — the engine is unusable afterwards. */
33- onError: (message: string) => void = () => {}
34- /** Last hard failure, for subscribers that attach after it happened. */
35- lastError: string | null = null
36-
37- async start(): Promise<void> {
38- try {
39- const session = await createNumblSession({
40- files: PROJECT_FILES.map((f) => ({ path: f.path, content: f.text })),
41- mainFile: MAIN_FILE,
42- onProgress: (message) => this.onProgress(message),
43- onOutput: (text) => this.onOutput(text),
44- onHtmlSourceEvent: (_compId, name, dataJson) =>
45- this.handleScriptEvent(name, dataJson),
46- })
47- if (this.disposed) {
48- session.dispose()
49- return
50- }
51- this.compId = session.uihtmlComponents[0]?.compId ?? null
52- if (!session.hasUihtmlSession || !this.compId) {
53- session.dispose()
54- throw new EngineError('script finished without a live uihtml session')
55- }
56- this.session = session
57- if (this.pendingMesh) {
58- session.writeFile('mesh.msh', this.pendingMesh)
59- this.pendingMesh = null
60- }
61- } catch (err) {
62- const message = err instanceof Error ? err.message : String(err)
63- this.lastError = message
64- if (!this.disposed) this.onError(message)
65- throw err instanceof Error ? err : new EngineError(message)
66- }
67- }
68-
69- /** Make `bytes` the mesh.msh the next solve reads. */
70- setMesh(bytes: Uint8Array): void {
71- if (this.session) this.session.writeFile('mesh.msh', bytes)
72- else this.pendingMesh = bytes
73- }
74-
75- solve(params: SolveParams): Promise<SolutionData> {
76- if (!this.session || !this.compId) {
77- return Promise.reject(new EngineError('engine not ready'))
78- }
79- if (this.solveWaiter) {
80- return Promise.reject(new EngineError('a solve is already running'))
81- }
82- const dispatched = this.session.dispatchHtmlEvent(this.compId, 'solve', params)
83- return new Promise<SolutionData>((resolve, reject) => {
84- const timeoutId = setTimeout(() => {
85- this.settleSolve((w) => w.reject(new EngineError('solve timed out')))
86- }, SOLVE_TIMEOUT_MS)
87- this.solveWaiter = { resolve, reject, timeoutId }
88- // An interpreter-level dispatch failure (vs. the solveError event the
89- // script sends for caught errors) also settles the solve.
90- dispatched.catch((err) => {
91- this.settleSolve((w) =>
92- w.reject(err instanceof Error ? err : new EngineError(String(err))),
93- )
94- })
95- })
96- }
97-
98- get isReady(): boolean {
99- return this.session !== null
100- }
101-
102- get isBusy(): boolean {
103- return this.solveWaiter !== null
104- }
15+export interface SolveParams {
16+ pde: 'poisson' | 'helmholtz'
17+ /** RHS f(x,y,z), a MATLAB expression */
18+ f: string
19+ /** zeroth-order coefficient c(x,y,z) (helmholtz only) */
20+ c: string
21+ /** polynomial order per patch */
22+ p: number
23+ /** every mesh edge shared by exactly two quads (from edgeClassification) */
24+ closed: boolean
25+}
10526
106- dispose(): void {
107- if (this.disposed) return
108- this.disposed = true
109- this.settleSolve((w) => w.reject(new EngineError('engine disposed')))
110- this.session?.dispose()
111- this.session = null
112- }
27+/** Per-patch solution grids, as packed by matlab/solve_pde.m. */
28+export interface SolutionData {
29+ type: 'solution'
30+ /** points per patch edge (p + 1) */
31+ n: number
32+ npatches: number
33+ x: number[][]
34+ y: number[][]
35+ z: number[][]
36+ u: number[][]
37+ umin: number
38+ umax: number
39+ pde: string
40+}
11341
114- // ---- internals ---------------------------------------------------------
42+export interface EngineHooks {
43+ /** Boot progress (package downloads, engine start). */
44+ onProgress?: (message: string) => void
45+ /** MATLAB console output (mip install logs etc.). */
46+ onOutput?: (text: string) => void
47+}
11548
116- private settleSolve(settle: (w: NonNullable<typeof this.solveWaiter>) => void) {
117- const w = this.solveWaiter
118- if (!w) return
119- this.solveWaiter = null
120- clearTimeout(w.timeoutId)
121- settle(w)
122- }
49+/** Install the MATLAB packages ahead of the first solve (fire at page load). */
50+export async function prewarm(hooks: EngineHooks = {}): Promise<void> {
51+ const session = await createNumblSession({
52+ files: [{ path: 'main.m', content: 'mip load --install surfacefun;\n' }],
53+ mainFile: 'main.m',
54+ onProgress: hooks.onProgress,
55+ onOutput: hooks.onOutput,
56+ })
57+ session.dispose()
58+}
12359
124- private handleScriptEvent(name: string, dataJson: string) {
125- if (name === 'solution') {
126- this.settleSolve((w) => {
127- try {
128- w.resolve(JSON.parse(dataJson) as SolutionData)
129- } catch (err) {
130- w.reject(new EngineError(`bad solution payload: ${String(err)}`))
131- }
132- })
133- } else if (name === 'solveError') {
134- const message =
135- (JSON.parse(dataJson) as { message?: string }).message ?? 'unknown solver error'
136- this.settleSolve((w) => w.reject(new EngineError(message)))
137- }
60+/**
61+ * Solve params.pde on the mesh in a fresh session. Rejects with the MATLAB
62+ * error message if the solve fails (bad expression, degenerate mesh, ...).
63+ */
64+export async function solve(
65+ meshBytes: Uint8Array,
66+ params: SolveParams,
67+ hooks: EngineHooks = {},
68+): Promise<SolutionData> {
69+ let session: NumblSession | null = null
70+ let timeoutId: ReturnType<typeof setTimeout> | undefined
71+ try {
72+ return await Promise.race([
73+ new Promise<never>((_, reject) => {
74+ timeoutId = setTimeout(() => reject(new Error('solve timed out')), SOLVE_TIMEOUT_MS)
75+ }),
76+ (async () => {
77+ session = await createNumblSession({
78+ files: [
79+ { path: 'main.m', content: main },
80+ { path: 'solve_pde.m', content: solvePde },
81+ { path: 'surfacemesh_from_quads.m', content: surfacemeshFromQuads },
82+ { path: 'load_gmsh_quads.m', content: loadGmshQuads },
83+ { path: 'params.json', content: JSON.stringify(params) },
84+ { path: 'mesh.msh', content: meshBytes },
85+ ],
86+ mainFile: 'main.m',
87+ onProgress: hooks.onProgress,
88+ onOutput: hooks.onOutput,
89+ })
90+ const bytes = await session.readFile('result.json')
91+ return JSON.parse(new TextDecoder().decode(bytes)) as SolutionData
92+ })(),
93+ ])
94+ } finally {
95+ clearTimeout(timeoutId)
96+ ;(session as NumblSession | null)?.dispose()
13897 }
13998 }
src/engine/project.tsdeleted+0−24View file
@@ -1,24 +0,0 @@
1-/** The MATLAB project the solver worker runs, bundled as raw text. */
2-
3-import main from '../../matlab/main.m?raw'
4-import solverSession from '../../matlab/solver_session.m?raw'
5-import solvePde from '../../matlab/solve_pde.m?raw'
6-import surfacemeshFromQuads from '../../matlab/surfacemesh_from_quads.m?raw'
7-import loadGmshQuads from '../../matlab/load_gmsh_quads.m?raw'
8-import placeholder from '../../matlab/placeholder.html?raw'
9-
10-export interface ProjectFile {
11- path: string
12- text: string
13-}
14-
15-export const PROJECT_FILES: ProjectFile[] = [
16- { path: 'main.m', text: main },
17- { path: 'solver_session.m', text: solverSession },
18- { path: 'solve_pde.m', text: solvePde },
19- { path: 'surfacemesh_from_quads.m', text: surfacemeshFromQuads },
20- { path: 'load_gmsh_quads.m', text: loadGmshQuads },
21- { path: 'placeholder.html', text: placeholder },
22-]
23-
24-export const MAIN_FILE = 'main.m'
src/engine/protocol.tsdeleted+0−28View file
@@ -1,28 +0,0 @@
1-/** The app-level solve protocol spoken over the uihtml event bridge. */
2-
3-export interface SolveParams {
4- pde: 'poisson' | 'helmholtz'
5- /** RHS f(x,y,z), a MATLAB expression */
6- f: string
7- /** zeroth-order coefficient c(x,y,z) (helmholtz only) */
8- c: string
9- /** polynomial order per patch */
10- p: number
11- /** every mesh edge shared by exactly two quads (from edgeClassification) */
12- closed: boolean
13-}
14-
15-/** Per-patch solution grids, as packed by matlab/solve_pde.m. */
16-export interface SolutionData {
17- type: 'solution'
18- /** points per patch edge (p + 1) */
19- n: number
20- npatches: number
21- x: number[][]
22- y: number[][]
23- z: number[][]
24- u: number[][]
25- umin: number
26- umax: number
27- pde: string
28-}
src/render/SurfaceView.tsxmodified+1−1View file
@@ -8,7 +8,7 @@ import { useRef, useEffect, type CSSProperties } from 'react'
88 import * as THREE from 'three'
99 import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
1010 import type { QuadMeshData } from '../mesh/quadmesh'
11-import type { SolutionData } from '../engine/protocol'
11+import type { SolutionData } from '../engine/engine'
1212 import { colormapLookup, colormapGradient } from './colormap'
1313
1414 export interface ViewContent {