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