1import { useCallback, useEffect, useState } from 'react'
2import { ACCEPT, formatForFilename } from './mesh/formats'
3import { initMeshio, parseMeshFile } from './mesh/meshio'
4import { edgeClassification, type SurfaceMeshData } from './mesh/surfacemesh'
5import { buildSolveScript, prewarm, solve, type SolutionData } from './engine/engine'
6import {
7 PDES,
8 SLOW_CELLS,
9 MIN_ORDER,
10 MAX_ORDER,
11 DEFAULT_ORDER,
12 type PdeDef,
13} from './pde/presets'
14import { SurfaceView } from './render/SurfaceView'
16// Module-level so React StrictMode double-mounting doesn't prewarm twice
17// (the prewarm downloads the MATLAB packages into numbl's IndexedDB cache).
18let prewarmPromise: Promise<void> | null = null
20interface LoadedMesh {
21 name: string
22 data: SurfaceMeshData
23 numVertices: number
24 numCells: number
25 closed: boolean
26 nonManifold: boolean
27 warnings: string[]
28}
30const SAMPLES = [
31 { label: 'Sphere (quads)', file: 'sphere.msh' },
32 { label: 'Sphere (triangles)', file: 'sphere-tri.msh' },
33 { label: 'Torus', file: 'torus.msh' },
34]
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.
38const mshFileName = (name: string) =>
39 (name.replace(/\.[^.]*$/, '').replace(/[^\w.-]+/g, '_') || 'mesh') + '.msh'
41const 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}
49export default function App() {
50 const [meshioStatus, setMeshioStatus] = useState('Loading Python runtime…')
51 const [meshioReady, setMeshioReady] = useState(false)
52 const [engineStatus, setEngineStatus] = useState('Preparing MATLAB packages…')
53 const [engineReady, setEngineReady] = useState(false)
54 const [consoleLines, setConsoleLines] = useState<string[]>([])
56 const [mesh, setMesh] = useState<LoadedMesh | null>(null)
57 const [meshError, setMeshError] = useState<string | null>(null)
58 const [parsing, setParsing] = useState(false)
60 const [pde, setPde] = useState<PdeDef>(PDES[0])
61 const [fExpr, setFExpr] = useState(PDES[0].fPresets[0].expr)
62 const [cExpr, setCExpr] = useState('100*(1 - z)')
63 const [order, setOrder] = useState(DEFAULT_ORDER)
65 const [solving, setSolving] = useState(false)
66 const [solveStatus, setSolveStatus] = useState('')
67 const [solveError, setSolveError] = useState<string | null>(null)
68 const [solution, setSolution] = useState<SolutionData | null>(null)
69 const [solveSeconds, setSolveSeconds] = useState<number | null>(null)
71 const appendConsole = useCallback((text: string) => {
72 setConsoleLines((lines) => [...lines.slice(-199), text.replace(/\n$/, '')])
73 }, [])
75 useEffect(() => {
76 initMeshio(setMeshioStatus)
77 .then(() => {
78 setMeshioReady(true)
79 setMeshioStatus('')
80 })
81 .catch((err) => setMeshioStatus(`Mesh reader failed: ${String(err.message ?? err)}`))
83 if (!prewarmPromise) {
84 prewarmPromise = prewarm({ onProgress: setEngineStatus, onOutput: appendConsole })
85 }
86 prewarmPromise
87 // A failed prewarm isn't fatal — the solve re-attempts the downloads.
88 .catch((err) => appendConsole(`package prewarm failed: ${String(err?.message ?? err)}`))
89 .finally(() => {
90 setEngineReady(true)
91 setEngineStatus('')
92 })
93 }, [appendConsole])
95 const loadMesh = useCallback(
96 async (name: string, bytes: Uint8Array) => {
97 setMeshError(null)
98 setSolveError(null)
99 setSolution(null)
100 setParsing(true)
101 try {
102 const format = formatForFilename(name)
103 if (!format) throw new Error(`Unsupported file extension on "${name}"`)
104 const result = await parseMeshFile(bytes, format)
105 const cls = edgeClassification(result.mesh.cells, result.mesh.cellSize)
106 setMesh({
107 name,
108 data: result.mesh,
109 numVertices: result.numVertices,
110 numCells: result.numCells,
111 closed: cls.closed,
112 nonManifold: cls.nonManifold,
113 warnings: result.warnings,
114 })
115 } catch (err) {
116 setMesh(null)
117 setMeshError(err instanceof Error ? err.message : String(err))
118 } finally {
119 setParsing(false)
120 }
121 },
122 [],
123 )
125 const onUpload = useCallback(
126 async (e: React.ChangeEvent<HTMLInputElement>) => {
127 const file = e.target.files?.[0]
128 e.target.value = ''
129 if (!file) return
130 await loadMesh(file.name, new Uint8Array(await file.arrayBuffer()))
131 },
132 [loadMesh],
133 )
135 const onSample = useCallback(
136 async (file: string) => {
137 const resp = await fetch(`${import.meta.env.BASE_URL}samples/${file}`)
138 if (!resp.ok) {
139 setMeshError(`Failed to fetch sample: HTTP ${resp.status}`)
140 return
141 }
142 await loadMesh(file, new Uint8Array(await resp.arrayBuffer()))
143 },
144 [loadMesh],
145 )
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 )
161 const onSolve = useCallback(async () => {
162 const params = solveParams()
163 if (!mesh || !params) return
164 setSolveError(null)
165 setSolving(true)
166 setSolveStatus('')
167 setSolveSeconds(null)
168 const t0 = performance.now()
169 try {
170 const result = await solve(mesh.data.mshBytes, params, {
171 onProgress: setSolveStatus,
172 onOutput: appendConsole,
173 })
174 setSolution(result)
175 setSolveSeconds((performance.now() - t0) / 1000)
176 } catch (err) {
177 setSolveError(err instanceof Error ? err.message : String(err))
178 } finally {
179 setSolving(false)
180 }
181 }, [mesh, solveParams, appendConsole])
183 const onDownloadMsh = useCallback(() => {
184 if (mesh) download(mshFileName(mesh.name), mesh.data.mshBytes as BlobPart)
185 }, [mesh])
187 const onDownloadScript = useCallback(() => {
188 const params = solveParams()
189 if (params) download('solve_pde.m', buildSolveScript(params))
190 }, [solveParams])
192 const onPdeChange = (id: string) => {
193 const def = PDES.find((p) => p.id === id) ?? PDES[0]
194 setPde(def)
195 setFExpr(def.fPresets[0].expr)
196 }
198 const booting = !meshioReady || !engineReady
199 // points per patch: (p+1)^2 on quads, (p+1)(p+2)/2 on triangles
200 const dof = mesh
201 ? mesh.data.cellSize === 3
202 ? (mesh.numCells * (order + 1) * (order + 2)) / 2
203 : mesh.numCells * (order + 1) * (order + 1)
204 : 0
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
209 return (
210 <div className="app">
211 <header>
212 <h1>Mesh PDE Solver</h1>
213 <p>
214 Upload a triangle or quad surface mesh, pick a PDE, and solve it on the surface with{' '}
215 <a href="https://github.com/danfortunato/surfacefun" target="_blank" rel="noreferrer">
216 surfacefun
217 </a>{' '}
218 running in your browser via <a href="https://numbl.org" target="_blank" rel="noreferrer">numbl</a>.
219 </p>
220 </header>
222 <div className="columns">
223 <aside>
224 <section>
225 <h2>1 · Mesh</h2>
226 <div className="row">
227 <label className="button">
228 Upload mesh…
229 <input type="file" accept={ACCEPT} onChange={onUpload} hidden />
230 </label>
231 {SAMPLES.map((s) => (
232 <button key={s.file} onClick={() => onSample(s.file)} disabled={!meshioReady}>
233 {s.label}
234 </button>
235 ))}
236 </div>
237 <p className="hint">
238 Triangle or quad meshes in any format meshio reads ({ACCEPT.replaceAll(',', ' ')});
239 converted to Gmsh format for surfacefun.
240 </p>
241 <div className="meshinfo">
242 {parsing ? (
243 <div className="status">Reading mesh…</div>
244 ) : meshError ? (
245 <div className="error">{meshError}</div>
246 ) : mesh ? (
247 <>
248 <div>
249 <strong>{mesh.name}</strong> — {mesh.numVertices} vertices, {mesh.numCells}{' '}
250 {mesh.data.cellSize === 3 ? 'triangles' : 'quads'},{' '}
251 {mesh.closed ? 'closed surface' : 'open surface (boundary present)'}
252 </div>
253 {mesh.nonManifold && (
254 <div className="warn">Non-manifold edges detected; the solve may fail.</div>
255 )}
256 {mesh.numCells > SLOW_CELLS && (
257 <div className="warn">Large mesh — the solve may take a while.</div>
258 )}
259 {mesh.warnings.map((w) => (
260 <div className="warn" key={w}>
261 {w}
262 </div>
263 ))}
264 <button className="linkish" onClick={onDownloadMsh}>
265 Download converted .msh
266 </button>
267 </>
268 ) : (
269 <div className="placeholder">No mesh loaded — upload a file or pick a sample.</div>
270 )}
271 </div>
272 </section>
274 <section>
275 <h2>2 · PDE</h2>
276 <label className="field">
277 <span>Equation</span>
278 <select value={pde.id} onChange={(e) => onPdeChange(e.target.value)}>
279 {PDES.map((p) => (
280 <option key={p.id} value={p.id}>
281 {p.label} — {p.equation}
282 </option>
283 ))}
284 </select>
285 </label>
286 <p className="hint pde-note">{pde.note}</p>
288 <label className="field">
289 <span>Right-hand side f(x, y, z)</span>
290 <div className="preset-row">
291 <select
292 value=""
293 onChange={(e) => {
294 if (e.target.value) setFExpr(e.target.value)
295 }}
296 >
297 <option value="">presets…</option>
298 {pde.fPresets.map((p) => (
299 <option key={p.label} value={p.expr}>
300 {p.label}
301 </option>
302 ))}
303 </select>
304 <input
305 type="text"
306 value={fExpr}
307 onChange={(e) => setFExpr(e.target.value)}
308 spellCheck={false}
309 />
310 </div>
311 </label>
313 <label className={pde.cPresets ? 'field' : 'field inactive'}>
314 <span>Coefficient c(x, y, z)</span>
315 <div className="preset-row">
316 <select
317 value=""
318 disabled={!pde.cPresets}
319 onChange={(e) => {
320 if (e.target.value) setCExpr(e.target.value)
321 }}
322 >
323 <option value="">presets…</option>
324 {(pde.cPresets ?? []).map((p) => (
325 <option key={p.label} value={p.expr}>
326 {p.label}
327 </option>
328 ))}
329 </select>
330 <input
331 type="text"
332 value={pde.cPresets ? cExpr : ''}
333 placeholder={pde.cPresets ? undefined : 'not used by this equation'}
334 disabled={!pde.cPresets}
335 onChange={(e) => setCExpr(e.target.value)}
336 spellCheck={false}
337 />
338 </div>
339 </label>
341 <label className="field">
342 <span>
343 Polynomial order p = {order}
344 {mesh ? ` (~${dof.toLocaleString()} unknowns)` : ''}
345 </span>
346 <input
347 type="range"
348 min={MIN_ORDER}
349 max={MAX_ORDER}
350 value={order}
351 onChange={(e) => setOrder(Number(e.target.value))}
352 />
353 </label>
354 </section>
356 <section>
357 <h2>3 · Solve</h2>
358 <button className="solve" onClick={onSolve} disabled={!canSolve}>
359 {solving ? 'Solving…' : 'Solve'}
360 </button>
361 <div className="solve-status">
362 {solving ? (
363 <p className="status">{solveStatus || 'Solving…'}</p>
364 ) : booting ? (
365 <p className="status">
366 {[meshioStatus, engineStatus].filter(Boolean).join(' · ') || 'Preparing…'}
367 </p>
368 ) : solveError ? (
369 <p className="error">{solveError}</p>
370 ) : solution && solveSeconds !== null ? (
371 <p className="status">
372 Solved in {solveSeconds.toFixed(1)} s · u ∈ [{solution.umin.toPrecision(4)},{' '}
373 {solution.umax.toPrecision(4)}]
374 </p>
375 ) : null}
376 </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>
385 </section>
387 <section>
388 <details>
389 <summary>Engine console</summary>
390 <pre className="console">{consoleLines.join('\n') || '(no output yet)'}</pre>
391 </details>
392 </section>
393 </aside>
395 <main>
396 <SurfaceView mesh={mesh?.data ?? null} solution={solution} />
397 </main>
398 </div>
399 </div>
400 )
401}