28f8ec1mesh-pde-solver: upload a quad mesh, solve PDEs on the surface in-browserJeremy Magland 1import { useCallback, useEffect, useRef, useState } from 'react'
2import { ACCEPT, formatForFilename } from './mesh/formats'
3import { initMeshio, parseMeshFile } from './mesh/meshio'
4import { edgeClassification, type QuadMeshData } from './mesh/quadmesh'
5import { SolverEngine } from './engine/engine'
6import type { SolutionData } from './engine/protocol'
7import {
8 PDES,
9 MAX_QUADS,
10 SLOW_QUADS,
11 MIN_ORDER,
12 MAX_ORDER,
13 DEFAULT_ORDER,
14 type PdeDef,
15} from './pde/presets'
16import { SurfaceView } from './render/SurfaceView'
18// Module-level singletons so React StrictMode double-mounting doesn't boot
19// two engines (each boot downloads the MATLAB packages).
20let engineSingleton: SolverEngine | null = null
21function 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}
31interface LoadedMesh {
32 name: string
33 data: QuadMeshData
34 numVertices: number
35 numQuads: number
36 closed: boolean
37 nonManifold: boolean
38 warnings: string[]
39}
41const SAMPLES = [
42 { label: 'Sphere (cubed)', file: 'sphere.msh' },
43 { label: 'Torus', file: 'torus.msh' },
44]
46export default function App() {
47 const engineRef = useRef<SolverEngine>(null)
48 if (!engineRef.current) engineRef.current = getEngine()
49 const engine = engineRef.current
51 const [meshioStatus, setMeshioStatus] = useState('Loading Python runtime…')
52 const [meshioReady, setMeshioReady] = useState(false)
53 const [engineStatus, setEngineStatus] = useState('Starting MATLAB engine…')
54 const [engineReady, setEngineReady] = useState(false)
55 const [consoleLines, setConsoleLines] = useState<string[]>([])
57 const [mesh, setMesh] = useState<LoadedMesh | null>(null)
58 const [meshError, setMeshError] = useState<string | null>(null)
59 const [parsing, setParsing] = useState(false)
61 const [pde, setPde] = useState<PdeDef>(PDES[0])
62 const [fExpr, setFExpr] = useState(PDES[0].fPresets[0].expr)
63 const [cExpr, setCExpr] = useState('100*(1 - z)')
64 const [order, setOrder] = useState(DEFAULT_ORDER)
66 const [solving, setSolving] = useState(false)
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 useEffect(() => {
72 initMeshio(setMeshioStatus)
73 .then(() => {
74 setMeshioReady(true)
75 setMeshioStatus('')
76 })
77 .catch((err) => setMeshioStatus(`Mesh reader failed: ${String(err.message ?? err)}`))
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) {
86 setEngineReady(true)
87 setEngineStatus('')
88 clearInterval(poll)
89 }
90 }, 250)
91 return () => clearInterval(poll)
92 }, [engine])
94 const loadMesh = useCallback(
95 async (name: string, bytes: Uint8Array) => {
96 setMeshError(null)
97 setSolveError(null)
98 setSolution(null)
99 setParsing(true)
100 try {
101 const format = formatForFilename(name)
102 if (!format) throw new Error(`Unsupported file extension on "${name}"`)
103 const result = await parseMeshFile(bytes, format)
104 if (result.numQuads > MAX_QUADS) {
105 throw new Error(
106 `${result.numQuads} quads is too many for an in-browser solve ` +
107 `(limit ${MAX_QUADS}); please upload a coarser mesh.`,
108 )
109 }
110 const cls = edgeClassification(result.mesh.quads)
111 setMesh({
112 name,
113 data: result.mesh,
114 numVertices: result.numVertices,
115 numQuads: result.numQuads,
116 closed: cls.closed,
117 nonManifold: cls.nonManifold,
118 warnings: result.warnings,
119 })
120 engine.setMesh(result.mesh.mshBytes)
121 } catch (err) {
122 setMesh(null)
123 setMeshError(err instanceof Error ? err.message : String(err))
124 } finally {
125 setParsing(false)
126 }
127 },
128 [engine],
129 )
131 const onUpload = useCallback(
132 async (e: React.ChangeEvent<HTMLInputElement>) => {
133 const file = e.target.files?.[0]
134 e.target.value = ''
135 if (!file) return
136 await loadMesh(file.name, new Uint8Array(await file.arrayBuffer()))
137 },
138 [loadMesh],
139 )
141 const onSample = useCallback(
142 async (file: string) => {
143 const resp = await fetch(`${import.meta.env.BASE_URL}samples/${file}`)
144 if (!resp.ok) {
145 setMeshError(`Failed to fetch sample: HTTP ${resp.status}`)
146 return
147 }
148 await loadMesh(file, new Uint8Array(await resp.arrayBuffer()))
149 },
150 [loadMesh],
151 )
153 const onSolve = useCallback(async () => {
154 if (!mesh) return
155 setSolveError(null)
156 setSolving(true)
157 setSolveSeconds(null)
158 const t0 = performance.now()
159 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 })
167 setSolution(result)
168 setSolveSeconds((performance.now() - t0) / 1000)
169 } catch (err) {
170 setSolveError(err instanceof Error ? err.message : String(err))
171 } finally {
172 setSolving(false)
173 }
174 }, [engine, mesh, pde, fExpr, cExpr, order])
176 const onDownloadMsh = useCallback(() => {
177 if (!mesh) return
178 const blob = new Blob([mesh.data.mshBytes as BlobPart], { type: 'application/octet-stream' })
179 const a = document.createElement('a')
180 a.href = URL.createObjectURL(blob)
181 a.download = mesh.name.replace(/\.[^.]*$/, '') + '.msh'
182 a.click()
183 URL.revokeObjectURL(a.href)
184 }, [mesh])
186 const onPdeChange = (id: string) => {
187 const def = PDES.find((p) => p.id === id) ?? PDES[0]
188 setPde(def)
189 setFExpr(def.fPresets[0].expr)
190 }
192 const booting = !meshioReady || !engineReady
193 const dof = mesh ? mesh.numQuads * (order + 1) * (order + 1) : 0
194 const canSolve = !!mesh && engineReady && !solving && fExpr.trim() !== ''
196 return (
197 <div className="app">
198 <header>
199 <h1>Mesh PDE Solver</h1>
200 <p>
201 Upload a quadrilateral surface mesh, pick a PDE, and solve it on the surface with{' '}
202 <a href="https://github.com/danfortunato/surfacefun" target="_blank" rel="noreferrer">
203 surfacefun
204 </a>{' '}
205 running in your browser via <a href="https://numbl.org" target="_blank" rel="noreferrer">numbl</a>.
206 </p>
207 </header>
209 <div className="columns">
210 <aside>
211 <section>
212 <h2>1 · Mesh</h2>
213 <div className="row">
214 <label className="button">
215 Upload mesh…
216 <input type="file" accept={ACCEPT} onChange={onUpload} hidden />
217 </label>
218 {SAMPLES.map((s) => (
219 <button key={s.file} onClick={() => onSample(s.file)} disabled={!meshioReady}>
220 {s.label}
221 </button>
222 ))}
223 </div>
224 <p className="hint">
225 Quad meshes in any format meshio reads ({ACCEPT.replaceAll(',', ' ')}); converted to
226 Gmsh format for surfacefun.
227 </p>
228 <div className="meshinfo">
229 {parsing ? (
230 <div className="status">Reading mesh…</div>
231 ) : meshError ? (
232 <div className="error">{meshError}</div>
233 ) : mesh ? (
234 <>
235 <div>
236 <strong>{mesh.name}</strong> — {mesh.numVertices} vertices, {mesh.numQuads}{' '}
237 quads, {mesh.closed ? 'closed surface' : 'open surface (boundary present)'}
238 </div>
239 {mesh.nonManifold && (
240 <div className="warn">Non-manifold edges detected; the solve may fail.</div>
241 )}
242 {mesh.numQuads > SLOW_QUADS && (
243 <div className="warn">Large mesh — the solve may take a while.</div>
244 )}
245 {mesh.warnings.map((w) => (
246 <div className="warn" key={w}>
247 {w}
248 </div>
249 ))}
250 <button className="linkish" onClick={onDownloadMsh}>
251 Download converted .msh
252 </button>
253 </>
254 ) : (
255 <div className="placeholder">No mesh loaded — upload a file or pick a sample.</div>
256 )}
257 </div>
258 </section>
260 <section>
261 <h2>2 · PDE</h2>
262 <label className="field">
263 <span>Equation</span>
264 <select value={pde.id} onChange={(e) => onPdeChange(e.target.value)}>
265 {PDES.map((p) => (
266 <option key={p.id} value={p.id}>
267 {p.label} — {p.equation}
268 </option>
269 ))}
270 </select>
271 </label>
272 <p className="hint pde-note">{pde.note}</p>
274 <label className="field">
275 <span>Right-hand side f(x, y, z)</span>
276 <div className="preset-row">
277 <select
278 value=""
279 onChange={(e) => {
280 if (e.target.value) setFExpr(e.target.value)
281 }}
282 >
283 <option value="">presets…</option>
284 {pde.fPresets.map((p) => (
285 <option key={p.label} value={p.expr}>
286 {p.label}
287 </option>
288 ))}
289 </select>
290 <input
291 type="text"
292 value={fExpr}
293 onChange={(e) => setFExpr(e.target.value)}
294 spellCheck={false}
295 />
296 </div>
297 </label>
299 <label className={pde.cPresets ? 'field' : 'field inactive'}>
300 <span>Coefficient c(x, y, z)</span>
301 <div className="preset-row">
302 <select
303 value=""
304 disabled={!pde.cPresets}
305 onChange={(e) => {
306 if (e.target.value) setCExpr(e.target.value)
307 }}
308 >
309 <option value="">presets…</option>
310 {(pde.cPresets ?? []).map((p) => (
311 <option key={p.label} value={p.expr}>
312 {p.label}
313 </option>
314 ))}
315 </select>
316 <input
317 type="text"
318 value={pde.cPresets ? cExpr : ''}
319 placeholder={pde.cPresets ? undefined : 'not used by this equation'}
320 disabled={!pde.cPresets}
321 onChange={(e) => setCExpr(e.target.value)}
322 spellCheck={false}
323 />
324 </div>
325 </label>
327 <label className="field">
328 <span>
329 Polynomial order p = {order}
330 {mesh ? ` (~${dof.toLocaleString()} unknowns)` : ''}
331 </span>
332 <input
333 type="range"
334 min={MIN_ORDER}
335 max={MAX_ORDER}
336 value={order}
337 onChange={(e) => setOrder(Number(e.target.value))}
338 />
339 </label>
340 </section>
342 <section>
343 <h2>3 · Solve</h2>
344 <button className="solve" onClick={onSolve} disabled={!canSolve}>
345 {solving ? 'Solving…' : 'Solve'}
346 </button>
347 <div className="solve-status">
348 {booting ? (
349 <p className="status">
350 {[meshioStatus, engineStatus].filter(Boolean).join(' · ') || 'Preparing…'}
351 </p>
352 ) : solveError ? (
353 <p className="error">{solveError}</p>
354 ) : solution && solveSeconds !== null ? (
355 <p className="status">
356 Solved in {solveSeconds.toFixed(1)} s · u ∈ [{solution.umin.toPrecision(4)},{' '}
357 {solution.umax.toPrecision(4)}]
358 </p>
359 ) : null}
360 </div>
361 </section>
363 <section>
364 <details>
365 <summary>Engine console</summary>
366 <pre className="console">{consoleLines.join('\n') || '(no output yet)'}</pre>
367 </details>
368 </section>
369 </aside>
371 <main>
372 <SurfaceView mesh={mesh?.data ?? null} solution={solution} />
373 </main>
374 </div>
375 </div>
376 )
377}