/ concept-collection / mesh-pde-solver
Sign in
concept-collection / mesh-pde-solver
mesh-pde-solver / src / App.tsx
371 lines · 12.6 KBBlameHistoryRaw
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 { 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[]
30const SAMPLES = [
31 { label: 'Sphere (quads)', file: 'sphere.msh' },
32 { label: 'Sphere (triangles)', file: 'sphere-tri.msh' },
33 { label: 'Torus', file: 'torus.msh' },
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 const cls = edgeClassification(result.mesh.cells, result.mesh.cellSize)
93 setMesh({
94 name,
95 data: result.mesh,
96 numVertices: result.numVertices,
97 numCells: result.numCells,
98 closed: cls.closed,
99 nonManifold: cls.nonManifold,
100 warnings: result.warnings,
101 })
102 } catch (err) {
103 setMesh(null)
104 setMeshError(err instanceof Error ? err.message : String(err))
105 } finally {
106 setParsing(false)
107 }
108 },
109 [],
110 )
112 const onUpload = useCallback(
113 async (e: React.ChangeEvent<HTMLInputElement>) => {
114 const file = e.target.files?.[0]
115 e.target.value = ''
116 if (!file) return
117 await loadMesh(file.name, new Uint8Array(await file.arrayBuffer()))
118 },
119 [loadMesh],
120 )
122 const onSample = useCallback(
123 async (file: string) => {
124 const resp = await fetch(`${import.meta.env.BASE_URL}samples/${file}`)
125 if (!resp.ok) {
126 setMeshError(`Failed to fetch sample: HTTP ${resp.status}`)
127 return
128 }
129 await loadMesh(file, new Uint8Array(await resp.arrayBuffer()))
130 },
131 [loadMesh],
132 )
134 const onSolve = useCallback(async () => {
135 if (!mesh) return
136 setSolveError(null)
137 setSolving(true)
138 setSolveStatus('')
139 setSolveSeconds(null)
140 const t0 = performance.now()
141 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 )
153 setSolution(result)
154 setSolveSeconds((performance.now() - t0) / 1000)
155 } catch (err) {
156 setSolveError(err instanceof Error ? err.message : String(err))
157 } finally {
158 setSolving(false)
159 }
160 }, [mesh, pde, fExpr, cExpr, order, appendConsole])
162 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)
170 }, [mesh])
172 const onPdeChange = (id: string) => {
173 const def = PDES.find((p) => p.id === id) ?? PDES[0]
174 setPde(def)
175 setFExpr(def.fPresets[0].expr)
176 }
178 const booting = !meshioReady || !engineReady
179 // points per patch: (p+1)^2 on quads, (p+1)(p+2)/2 on triangles
180 const dof = mesh
181 ? mesh.data.cellSize === 3
182 ? (mesh.numCells * (order + 1) * (order + 2)) / 2
183 : mesh.numCells * (order + 1) * (order + 1)
184 : 0
185 const canSolve = !!mesh && engineReady && !solving && fExpr.trim() !== ''
187 return (
188 <div className="app">
189 <header>
190 <h1>Mesh PDE Solver</h1>
191 <p>
192 Upload a triangle or quad surface mesh, pick a PDE, and solve it on the surface with{' '}
193 <a href="https://github.com/danfortunato/surfacefun" target="_blank" rel="noreferrer">
194 surfacefun
195 </a>{' '}
196 running in your browser via <a href="https://numbl.org" target="_blank" rel="noreferrer">numbl</a>.
197 </p>
198 </header>
200 <div className="columns">
201 <aside>
202 <section>
203 <h2>1 · Mesh</h2>
204 <div className="row">
205 <label className="button">
206 Upload mesh…
207 <input type="file" accept={ACCEPT} onChange={onUpload} hidden />
208 </label>
209 {SAMPLES.map((s) => (
210 <button key={s.file} onClick={() => onSample(s.file)} disabled={!meshioReady}>
211 {s.label}
212 </button>
213 ))}
214 </div>
215 <p className="hint">
216 Triangle or quad meshes in any format meshio reads ({ACCEPT.replaceAll(',', ' ')});
217 converted to Gmsh format for surfacefun.
218 </p>
219 <div className="meshinfo">
220 {parsing ? (
221 <div className="status">Reading mesh…</div>
222 ) : meshError ? (
223 <div className="error">{meshError}</div>
224 ) : mesh ? (
225 <>
226 <div>
227 <strong>{mesh.name}</strong> — {mesh.numVertices} vertices, {mesh.numCells}{' '}
228 {mesh.data.cellSize === 3 ? 'triangles' : 'quads'},{' '}
229 {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.numCells > SLOW_CELLS && (
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 )
moveopenescclose