From 7282b2e1345ee9b15ab5fddaf72d62d39b75c5a2 Mon Sep 17 00:00:00 2001 From: Emily Boudreaux Date: Sat, 5 Sep 2026 07:46:36 -0400 Subject: [PATCH] feat(justfile): added justfile with catalog of builds --- justfile | 116 +++++++++++ meson.build | 1 + web/demo.js | 195 ++++++++++++++++++ web/index.html | 116 +++++++++++ web/meson.build | 34 ++++ web/smoke.cjs | 26 +++ web/styles.css | 506 ++++++++++++++++++++++++++++++++++++++++++++++ web/wasm_demo.cpp | 173 ++++++++++++++++ 8 files changed, 1167 insertions(+) create mode 100644 justfile create mode 100644 web/demo.js create mode 100644 web/index.html create mode 100644 web/meson.build create mode 100644 web/smoke.cjs create mode 100644 web/styles.css create mode 100644 web/wasm_demo.cpp diff --git a/justfile b/justfile new file mode 100644 index 0000000..f84ec81 --- /dev/null +++ b/justfile @@ -0,0 +1,116 @@ +set shell := ["bash", "-euo", "pipefail", "-c"] + +meson_bin := env_var_or_default("MESON_BIN", "meson") +python_bin := env_var_or_default("PYTHON", "python3") +jobs := env_var_or_default("MFEM_JOBS", "4") +allow_preinstalled := env_var_or_default("MFEM_ALLOW_PREINSTALLED", "false") +dependency_prefix := env_var_or_default("MFEM_DEPENDENCY_PREFIX", "") +web_port := env_var_or_default("MFEM_WEB_PORT", "8000") +macos_target := env_var_or_default("MACOSX_DEPLOYMENT_TARGET", "11.0") + +project_root := justfile_directory() +build_root := project_root + "/" + env_var_or_default("MFEM_BUILD_ROOT", "build") +install_root := project_root + "/" + env_var_or_default("MFEM_INSTALL_ROOT", "install") +basic_dir := build_root + "/basic" +serial_dir := build_root + "/serial" +parallel_dir := build_root + "/parallel" +debug_dir := build_root + "/debug" +cuda_dir := build_root + "/cuda" +hip_dir := build_root + "/hip" +wasm_dir := build_root + "/emscripten" +wasm_cache := build_root + "/emscripten-cache" +basic_install := install_root + "/basic" +serial_install := install_root + "/serial" +parallel_install := install_root + "/parallel" +debug_install := install_root + "/debug" +cuda_install := install_root + "/cuda" +hip_install := install_root + "/hip" + +default: + @just --list + +[private] +configure dir install_dir buildtype profile allow *extra: + #!/usr/bin/env bash + set -euo pipefail + setup_args=( + "{{ dir }}" + "--prefix={{ install_dir }}" + "--buildtype={{ buildtype }}" + "-Dfeature_profile={{ profile }}" + "-Dallow_preinstalled={{ allow }}" + "-Ddependency_prefix={{ dependency_prefix }}" + "-Djobs={{ jobs }}" + "-Dbuild_examples=true" + "-Dbuild_tests=true" + ) + if [[ -f "{{ dir }}/meson-private/coredata.dat" ]]; then + setup_args=(--reconfigure "${setup_args[@]}") + fi + "{{ meson_bin }}" setup "${setup_args[@]}" {{ extra }} + +[private] +build-and-test dir: + "{{ meson_bin }}" compile -C "{{ dir }}" + "{{ meson_bin }}" test -C "{{ dir }}" --print-errorlogs + +basic: (configure basic_dir basic_install "release" "minimal" allow_preinstalled "-Dmfem_mpi=disabled" "-Dmfem_cuda=disabled" "-Dmfem_hip=disabled") (build-and-test basic_dir) + +serial: (configure serial_dir serial_install "release" "portable" allow_preinstalled "-Dmfem_mpi=disabled" "-Dmfem_metis=disabled" "-Dmfem_cuda=disabled" "-Dmfem_hip=disabled") (build-and-test serial_dir) + +parallel: (configure parallel_dir parallel_install "release" "portable" allow_preinstalled "-Dmfem_mpi=enabled" "-Dmfem_sundials=enabled") (build-and-test parallel_dir) + +debug: (configure debug_dir debug_install "debug" "portable" allow_preinstalled) (build-and-test debug_dir) + +cuda: (configure cuda_dir cuda_install "release" "portable" allow_preinstalled "-Dmfem_cuda=enabled" "-Dmfem_hip=disabled") (build-and-test cuda_dir) + +hip: (configure hip_dir hip_install "release" "portable" allow_preinstalled "-Dmfem_hip=enabled" "-Dmfem_cuda=disabled") (build-and-test hip_dir) + +emscripten: + #!/usr/bin/env bash + set -euo pipefail + mkdir -p "{{ wasm_cache }}" + setup_args=( + "{{ wasm_dir }}" + "--cross-file={{ project_root }}/cross/wasm32.ini" + "--prefix={{ install_root }}/emscripten" + "--buildtype=release" + "-Dfeature_profile=portable" + "-Dallow_preinstalled=false" + "-Djobs={{ jobs }}" + "-Dbuild_python=false" + "-Dbuild_examples=true" + "-Dbuild_tests=true" + ) + if [[ -f "{{ wasm_dir }}/meson-private/coredata.dat" ]]; then + setup_args=(--reconfigure "${setup_args[@]}") + fi + EM_CACHE="{{ wasm_cache }}" "{{ meson_bin }}" setup "${setup_args[@]}" + EM_CACHE="{{ wasm_cache }}" "{{ meson_bin }}" compile -C "{{ wasm_dir }}" + EM_CACHE="{{ wasm_cache }}" "{{ meson_bin }}" test -C "{{ wasm_dir }}" --print-errorlogs + +serve-emscripten port=web_port: emscripten + @echo "MFEM browser demo: http://0.0.0.0:{{ port }}" + "{{ python_bin }}" -m http.server "{{ port }}" --bind 0.0.0.0 --directory "{{ wasm_dir }}/web" + +python: + mkdir -p "{{ project_root }}/dist" + MACOSX_DEPLOYMENT_TARGET="{{ macos_target }}" "{{ python_bin }}" -m build --wheel --no-isolation --outdir "{{ project_root }}/dist" + +run-serial: serial + "{{ serial_dir }}/examples/mfem-serial-poisson" + +run-parallel ranks="4": parallel + "{{ python_bin }}" "{{ project_root }}/tools/run_mpi_test.py" --launcher "{{ parallel_dir }}/build-config/mfem/mfem-prefix/bin/mpiexec" --processes "{{ ranks }}" "{{ parallel_dir }}/examples/mfem-parallel-hypre" + +install-basic: basic + "{{ meson_bin }}" install -C "{{ basic_dir }}" + +install-serial: serial + "{{ meson_bin }}" install -C "{{ serial_dir }}" + +install-parallel: parallel + "{{ meson_bin }}" install -C "{{ parallel_dir }}" + +fetch: + "{{ meson_bin }}" subprojects download diff --git a/meson.build b/meson.build index b10f2b5..df95162 100644 --- a/meson.build +++ b/meson.build @@ -30,6 +30,7 @@ subdir('build-check') subdir('build-config') subdir('include/meson_mfem_template') subdir('examples') +subdir('web') subdir('build-python') subdir('tests') diff --git a/web/demo.js b/web/demo.js new file mode 100644 index 0000000..1236a8e --- /dev/null +++ b/web/demo.js @@ -0,0 +1,195 @@ +"use strict"; + +const elements = { + form: document.querySelector("#solve-form"), + cells: document.querySelector("#cells"), + cellsValue: document.querySelector("#cells-value"), + order: document.querySelector("#order"), + button: document.querySelector("#solve-button"), + status: document.querySelector("#runtime-status"), + statusDot: document.querySelector("#status-dot"), + capabilities: document.querySelector("#capability-list"), + canvas: document.querySelector("#mesh-canvas"), + runIndex: document.querySelector("#run-index"), + dofs: document.querySelector("#metric-dofs"), + iterations: document.querySelector("#metric-iterations"), + error: document.querySelector("#metric-error"), + time: document.querySelector("#metric-time"), + log: document.querySelector("#runtime-log"), + footerVersion: document.querySelector("#footer-version"), +}; + +let mfemModule; +let completedRuns = 0; +let lastCells = Number(elements.cells.value); + +function setStatus(message, state = "loading") { + elements.status.textContent = message; + elements.statusDot.dataset.state = state; +} + +function appendLog(message) { + const timestamp = new Date().toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); + const prior = elements.log.textContent.startsWith("Waiting") ? "" : `${elements.log.textContent}\n`; + elements.log.textContent = `${prior}[${timestamp}] ${message}`; +} + +function drawMesh(cells, solved = false) { + const canvas = elements.canvas; + const context = canvas.getContext("2d"); + const ratio = Math.max(1, window.devicePixelRatio || 1); + const bounds = canvas.getBoundingClientRect(); + const width = Math.max(320, Math.round(bounds.width)); + const height = Math.max(260, Math.round(bounds.height)); + canvas.width = width * ratio; + canvas.height = height * ratio; + context.scale(ratio, ratio); + + const gradient = context.createRadialGradient( + width * 0.5, + height * 0.46, + 10, + width * 0.5, + height * 0.5, + width * 0.66, + ); + gradient.addColorStop(0, solved ? "rgba(125, 249, 197, 0.30)" : "rgba(89, 199, 255, 0.16)"); + gradient.addColorStop(0.55, solved ? "rgba(35, 124, 126, 0.20)" : "rgba(27, 72, 104, 0.13)"); + gradient.addColorStop(1, "rgba(8, 14, 24, 0)"); + context.fillStyle = gradient; + context.fillRect(0, 0, width, height); + + const padding = Math.max(30, Math.min(width, height) * 0.11); + const side = Math.min(width - 2 * padding, height - 2 * padding); + const left = (width - side) / 2; + const top = (height - side) / 2; + const step = side / cells; + + context.strokeStyle = solved ? "rgba(151, 255, 215, 0.30)" : "rgba(138, 207, 236, 0.22)"; + context.lineWidth = 1; + context.beginPath(); + for (let index = 0; index <= cells; index += 1) { + const offset = Math.round(index * step) + 0.5; + context.moveTo(left + offset, top); + context.lineTo(left + offset, top + side); + context.moveTo(left, top + offset); + context.lineTo(left + side, top + offset); + } + context.stroke(); + + context.strokeStyle = solved ? "#7df9c5" : "#59c7ff"; + context.lineWidth = 2; + context.strokeRect(left, top, side, side); +} + +function renderCapabilities(capabilities) { + const entries = [ + ["Wasm", capabilities.wasm], + ["MFEM", true], + ["libCEED", capabilities.ceed], + ["zlib", capabilities.zlib], + ["MPI", capabilities.mpi], + ]; + elements.capabilities.replaceChildren( + ...entries.map(([name, enabled]) => { + const badge = document.createElement("span"); + badge.className = enabled ? "capability enabled" : "capability disabled"; + badge.textContent = enabled ? name : `${name} off`; + return badge; + }), + ); + elements.footerVersion.textContent = `MFEM ${capabilities.mfemVersion} · WebAssembly`; +} + +function formatScientific(value) { + return Number(value).toExponential(3); +} + +function formatTime(seconds) { + const milliseconds = Number(seconds) * 1000; + return milliseconds < 1000 ? `${milliseconds.toFixed(1)} ms` : `${Number(seconds).toFixed(2)} s`; +} + +async function runSolve() { + const cells = Number(elements.cells.value); + const order = Number(elements.order.value); + elements.button.disabled = true; + elements.button.classList.add("working"); + setStatus("Solving finite-element system", "working"); + appendLog(`Starting ${cells}×${cells} mesh at polynomial order ${order}.`); + + await new Promise((resolve) => window.setTimeout(resolve, 20)); + try { + const raw = mfemModule.ccall( + "mfem_demo_solve", + "string", + ["number", "number"], + [cells, order], + ); + const result = JSON.parse(raw); + if (!result.ok) { + throw new Error(result.error || "The MFEM solve failed."); + } + + completedRuns += 1; + lastCells = cells; + elements.runIndex.textContent = `Run ${String(completedRuns).padStart(2, "0")}`; + elements.dofs.textContent = Number(result.trueDofs).toLocaleString(); + elements.iterations.textContent = Number(result.iterations).toLocaleString(); + elements.error.textContent = formatScientific(result.l2Error); + elements.time.textContent = formatTime(result.elapsedSeconds); + drawMesh(cells, true); + setStatus("Solve complete", "ready"); + appendLog( + `Converged in ${result.iterations} iterations with residual ${formatScientific(result.finalNorm)}.`, + ); + } catch (error) { + setStatus("Solve failed", "error"); + appendLog(error instanceof Error ? error.message : String(error)); + } finally { + elements.button.disabled = false; + elements.button.classList.remove("working"); + } +} + +elements.cells.addEventListener("input", () => { + elements.cellsValue.value = elements.cells.value; + drawMesh(Number(elements.cells.value), false); +}); + +elements.form.addEventListener("submit", (event) => { + event.preventDefault(); + void runSolve(); +}); + +window.addEventListener("resize", () => drawMesh(lastCells, completedRuns > 0)); + +async function initialize() { + drawMesh(lastCells, false); + try { + if (typeof MFEMDemoModule !== "function") { + throw new Error("The generated MFEM WebAssembly loader was not found."); + } + mfemModule = await MFEMDemoModule({ + locateFile: (path) => new URL(path, window.location.href).href, + print: (message) => appendLog(message), + printErr: (message) => appendLog(`Runtime: ${message}`), + }); + const capabilities = JSON.parse( + mfemModule.ccall("mfem_demo_capabilities", "string", [], []), + ); + renderCapabilities(capabilities); + elements.button.disabled = false; + setStatus("Runtime ready", "ready"); + appendLog(`Loaded MFEM ${capabilities.mfemVersion} from WebAssembly.`); + } catch (error) { + setStatus("Runtime failed to load", "error"); + appendLog(error instanceof Error ? error.message : String(error)); + } +} + +void initialize(); diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..c436995 --- /dev/null +++ b/web/index.html @@ -0,0 +1,116 @@ + + + + + + + MFEM WebAssembly Lab + + + + + +
+ + + + MFEM + WebAssembly Lab + + +
+ + Loading numerical runtime +
+
+ +
+
+
Poisson equation · unit square
+

Solve a finite-element system in your browser.

+

+ MFEM assembles and solves the system locally through WebAssembly. + Change the mesh or polynomial order and compare the resulting error and workload. +

+ +
+
+
+ + 10 +
+ + +
+ +
+ + +
+ + +
+ +
+ Runtime +
+
+
+ +
+
+
+
Computed result
+

Finite-element mesh

+
+ Awaiting run +
+ +
+ +
+ −Δu = f + u = 0 on ∂Ω +
+
+ +
+
+ True DOFs + +
+
+ CG iterations + +
+
+ L² error + +
+
+ Browser time + +
+
+ +
+ Runtime details +
Waiting for WebAssembly initialization…
+
+
+
+ + + + diff --git a/web/meson.build b/web/meson.build new file mode 100644 index 0000000..e178d44 --- /dev/null +++ b/web/meson.build @@ -0,0 +1,34 @@ +wasm_browser_demo = [] + +if is_wasm and get_option('build_examples') + configure_file(input: 'index.html', output: 'index.html', copy: true) + configure_file(input: 'styles.css', output: 'styles.css', copy: true) + configure_file(input: 'demo.js', output: 'demo.js', copy: true) + + wasm_browser_demo = executable( + 'mfem-wasm-demo', + 'wasm_demo.cpp', + dependencies: [mfem_dep, template_include_dep], + override_options: ['cpp_std=' + mfem_consumer_cpp_std], + link_args: [ + '-sMODULARIZE=1', + '-sEXPORT_NAME=MFEMDemoModule', + '-sEXPORTED_FUNCTIONS=_main,_mfem_demo_solve,_mfem_demo_capabilities', + '-sEXPORTED_RUNTIME_METHODS=ccall', + '-sNO_EXIT_RUNTIME=1', + '-sENVIRONMENT=web,node', + ], + install: false, + ) + + if get_option('build_tests') + node_program = find_program('node', required: true) + test( + 'wasm-browser-api', + node_program, + args: [files('smoke.cjs'), wasm_browser_demo], + depends: wasm_browser_demo, + timeout: 120, + ) + endif +endif diff --git a/web/smoke.cjs b/web/smoke.cjs new file mode 100644 index 0000000..24672c8 --- /dev/null +++ b/web/smoke.cjs @@ -0,0 +1,26 @@ +"use strict"; + +const path = require("node:path"); + +async function main() { + const modulePath = path.resolve(process.argv[2]); + const createModule = require(modulePath); + const module = await createModule({ print() {}, printErr() {} }); + const capabilities = JSON.parse( + module.ccall("mfem_demo_capabilities", "string", [], []), + ); + const result = JSON.parse( + module.ccall("mfem_demo_solve", "string", ["number", "number"], [6, 2]), + ); + if (!capabilities.wasm || !result.ok || result.trueDofs <= 0 || result.iterations <= 0) { + throw new Error(`Unexpected MFEM Wasm result: ${JSON.stringify(result)}`); + } + process.stdout.write( + `MFEM ${capabilities.mfemVersion} Wasm solve: dofs=${result.trueDofs} iterations=${result.iterations} l2=${result.l2Error}\n`, + ); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/web/styles.css b/web/styles.css new file mode 100644 index 0000000..cfb429a --- /dev/null +++ b/web/styles.css @@ -0,0 +1,506 @@ +:root { + color-scheme: dark; + --bg: #071018; + --surface: #0c1823; + --surface-raised: #102230; + --line: rgba(165, 214, 232, 0.16); + --line-strong: rgba(165, 214, 232, 0.29); + --text: #eef8fb; + --muted: #91aab5; + --cyan: #59c7ff; + --mint: #7df9c5; + --amber: #f5c86c; + --danger: #ff8f8f; + --radius: 18px; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +* { + box-sizing: border-box; +} + +body { + min-width: 320px; + min-height: 100vh; + margin: 0; + color: var(--text); + background: + radial-gradient(circle at 18% -10%, rgba(48, 139, 173, 0.20), transparent 34rem), + linear-gradient(150deg, #071018 0%, #09131d 48%, #060d14 100%); +} + +button, +input, +select { + font: inherit; +} + +.masthead, +footer { + width: min(1420px, calc(100% - 48px)); + margin: 0 auto; +} + +.masthead { + min-height: 92px; + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid var(--line); +} + +.brand { + display: inline-flex; + gap: 12px; + align-items: center; + color: inherit; + text-decoration: none; +} + +.brand-mark { + width: 42px; + height: 42px; + display: grid; + place-items: center; + color: #061019; + background: var(--mint); + border-radius: 11px; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-weight: 900; + box-shadow: 0 0 30px rgba(125, 249, 197, 0.20); +} + +.brand strong, +.brand small { + display: block; +} + +.brand strong { + letter-spacing: 0.16em; + font-size: 0.88rem; +} + +.brand small { + margin-top: 2px; + color: var(--muted); + font-size: 0.76rem; +} + +.runtime-state { + display: flex; + align-items: center; + gap: 9px; + color: var(--muted); + font-size: 0.82rem; +} + +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--amber); + box-shadow: 0 0 14px currentColor; +} + +.status-dot[data-state="ready"] { + background: var(--mint); +} + +.status-dot[data-state="working"] { + background: var(--cyan); + animation: pulse 1s ease-in-out infinite; +} + +.status-dot[data-state="error"] { + background: var(--danger); +} + +.workspace { + width: min(1420px, calc(100% - 48px)); + min-height: calc(100vh - 168px); + margin: 0 auto; + padding: 54px 0 44px; + display: grid; + grid-template-columns: minmax(300px, 0.72fr) minmax(560px, 1.28fr); + gap: clamp(32px, 6vw, 92px); + align-items: start; +} + +.control-panel { + padding-top: 12px; +} + +.eyebrow { + margin-bottom: 14px; + color: var(--mint); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.72rem; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +h1, +h2, +p { + margin-top: 0; +} + +h1 { + max-width: 12ch; + margin-bottom: 20px; + font-size: clamp(2.5rem, 5vw, 4.8rem); + font-weight: 560; + letter-spacing: -0.055em; + line-height: 0.99; +} + +h2 { + margin-bottom: 0; + font-size: clamp(1.4rem, 2vw, 2rem); + font-weight: 560; + letter-spacing: -0.035em; +} + +.lede { + max-width: 56ch; + margin-bottom: 34px; + color: var(--muted); + font-size: 1rem; + line-height: 1.7; +} + +form { + display: grid; + grid-template-columns: 1fr 0.72fr; + gap: 18px; +} + +.field-group { + display: flex; + flex-direction: column; + gap: 11px; +} + +.field-heading { + display: flex; + justify-content: space-between; + align-items: baseline; +} + +label, +.field-heading output { + font-size: 0.78rem; + font-weight: 650; + letter-spacing: 0.035em; +} + +.field-heading output { + color: var(--mint); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; +} + +input[type="range"] { + width: 100%; + accent-color: var(--mint); +} + +.range-labels { + display: flex; + justify-content: space-between; + margin-top: -7px; + color: #607b87; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.64rem; +} + +select { + min-height: 46px; + padding: 0 42px 0 14px; + color: var(--text); + background: var(--surface-raised); + border: 1px solid var(--line-strong); + border-radius: 10px; +} + +button { + grid-column: 1 / -1; + min-height: 54px; + margin-top: 5px; + padding: 0 18px; + display: flex; + align-items: center; + justify-content: space-between; + color: #061019; + background: var(--mint); + border: 0; + border-radius: 11px; + cursor: pointer; + font-weight: 750; + transition: transform 160ms ease, box-shadow 160ms ease, opacity 160ms ease; +} + +button:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 12px 32px rgba(125, 249, 197, 0.18); +} + +button:focus-visible, +select:focus-visible, +input:focus-visible, +summary:focus-visible { + outline: 2px solid var(--cyan); + outline-offset: 3px; +} + +button:disabled { + cursor: wait; + opacity: 0.46; +} + +.capabilities { + margin-top: 24px; + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + +.capability-label { + color: var(--muted); + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.12em; +} + +#capability-list { + display: flex; + gap: 7px; + flex-wrap: wrap; +} + +.capability { + padding: 5px 8px; + border: 1px solid var(--line); + border-radius: 999px; + color: var(--muted); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.65rem; +} + +.capability.enabled { + color: var(--mint); + border-color: rgba(125, 249, 197, 0.27); + background: rgba(125, 249, 197, 0.06); +} + +.result-panel { + overflow: hidden; + background: rgba(12, 24, 35, 0.84); + border: 1px solid var(--line); + border-radius: var(--radius); + box-shadow: 0 32px 90px rgba(0, 0, 0, 0.25); + backdrop-filter: blur(14px); +} + +.result-heading { + padding: 24px 26px 21px; + display: flex; + align-items: flex-end; + justify-content: space-between; + border-bottom: 1px solid var(--line); +} + +.result-heading .eyebrow { + margin-bottom: 7px; +} + +.run-index { + color: var(--muted); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.72rem; +} + +.mesh-frame { + position: relative; + min-height: 410px; + background: + linear-gradient(rgba(255, 255, 255, 0.012) 1px, transparent 1px), + linear-gradient(90deg, rgba(255, 255, 255, 0.012) 1px, transparent 1px), + #09131c; + background-size: 24px 24px; +} + +#mesh-canvas { + width: 100%; + height: 410px; + display: block; +} + +.equation { + position: absolute; + top: 18px; + right: 18px; + padding: 10px 12px; + color: var(--text); + background: rgba(7, 16, 24, 0.78); + border: 1px solid var(--line); + border-radius: 9px; + font-family: ui-serif, Georgia, serif; + text-align: center; +} + +.equation span, +.equation small { + display: block; +} + +.equation small { + margin-top: 3px; + color: var(--muted); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.57rem; +} + +.metrics { + display: grid; + grid-template-columns: repeat(4, 1fr); + border-top: 1px solid var(--line); + border-bottom: 1px solid var(--line); +} + +.metrics article { + min-width: 0; + padding: 18px; + border-right: 1px solid var(--line); +} + +.metrics article:last-child { + border-right: 0; +} + +.metrics span, +.metrics strong { + display: block; +} + +.metrics span { + margin-bottom: 8px; + color: var(--muted); + font-size: 0.68rem; + letter-spacing: 0.055em; + text-transform: uppercase; +} + +.metrics strong { + overflow: hidden; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: clamp(0.92rem, 1.4vw, 1.15rem); + text-overflow: ellipsis; +} + +.console-panel { + padding: 16px 24px 20px; +} + +.console-panel summary { + color: var(--muted); + cursor: pointer; + font-size: 0.75rem; +} + +.console-panel pre { + max-height: 150px; + overflow: auto; + margin: 14px 0 0; + color: #a9c7d2; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.68rem; + line-height: 1.6; + white-space: pre-wrap; +} + +footer { + min-height: 76px; + display: flex; + align-items: center; + justify-content: space-between; + color: #67808b; + border-top: 1px solid var(--line); + font-size: 0.7rem; +} + +@keyframes pulse { + 0%, 100% { opacity: 0.55; transform: scale(0.9); } + 50% { opacity: 1; transform: scale(1.2); } +} + +@media (max-width: 960px) { + .workspace { + grid-template-columns: 1fr; + gap: 42px; + } + + h1 { + max-width: 15ch; + } +} + +@media (max-width: 620px) { + .masthead, + .workspace, + footer { + width: min(100% - 28px, 1420px); + } + + .masthead { + min-height: 78px; + } + + .runtime-state { + max-width: 145px; + justify-content: flex-end; + text-align: right; + } + + .workspace { + padding-top: 32px; + } + + h1 { + font-size: clamp(2.35rem, 13vw, 3.4rem); + } + + form, + .metrics { + grid-template-columns: 1fr 1fr; + } + + .metrics article:nth-child(2) { + border-right: 0; + } + + .metrics article:nth-child(-n + 2) { + border-bottom: 1px solid var(--line); + } + + .mesh-frame, + #mesh-canvas { + min-height: 320px; + height: 320px; + } + + footer { + gap: 18px; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} diff --git a/web/wasm_demo.cpp b/web/wasm_demo.cpp new file mode 100644 index 0000000..0faecd3 --- /dev/null +++ b/web/wasm_demo.cpp @@ -0,0 +1,173 @@ +#include +#include + +#include + +#include +#include +#include +#include +#include + +namespace +{ +constexpr double pi = 3.141592653589793238462643383279502884; + +double exact_solution(const mfem::Vector &point) +{ + return std::sin(pi * point[0]) * std::sin(pi * point[1]); +} + +double forcing_function(const mfem::Vector &point) +{ + return 2.0 * pi * pi * exact_solution(point); +} + +std::string json_escape(const std::string &input) +{ + std::ostringstream output; + for (const unsigned char character : input) + { + switch (character) + { + case '"': output << "\\\""; break; + case '\\': output << "\\\\"; break; + case '\b': output << "\\b"; break; + case '\f': output << "\\f"; break; + case '\n': output << "\\n"; break; + case '\r': output << "\\r"; break; + case '\t': output << "\\t"; break; + default: + if (character < 0x20) + { + output << "\\u" << std::hex << std::setw(4) + << std::setfill('0') << static_cast(character) + << std::dec; + } + else + { + output << character; + } + } + } + return output.str(); +} + +const char *error_response(const std::string &message) +{ + static std::string response; + response = "{\"ok\":false,\"error\":\"" + json_escape(message) + "\"}"; + return response.c_str(); +} +} // namespace + +extern "C" EMSCRIPTEN_KEEPALIVE const char *mfem_demo_capabilities() +{ + static const std::string capabilities = []() { + std::ostringstream output; + output << "{\"mfemVersion\":\"" << json_escape(MFEM_VERSION_STRING) + << "\",\"mpi\":" << (MESON_MFEM_HAS_MPI ? "true" : "false") + << ",\"hypre\":" << (MESON_MFEM_HAS_HYPRE ? "true" : "false") + << ",\"zlib\":" << (MESON_MFEM_HAS_ZLIB ? "true" : "false") + << ",\"ceed\":" << (MESON_MFEM_HAS_CEED ? "true" : "false") + << ",\"fms\":" << (MESON_MFEM_HAS_FMS ? "true" : "false") + << ",\"wasm\":true}"; + return output.str(); + }(); + return capabilities.c_str(); +} + +extern "C" EMSCRIPTEN_KEEPALIVE const char *mfem_demo_solve(int cells, int order) +{ + static std::string response; + try + { + if (cells < 2 || cells > 64) + { + throw std::invalid_argument("cells must be between 2 and 64"); + } + if (order < 1 || order > 4) + { + throw std::invalid_argument("polynomial order must be between 1 and 4"); + } + + static mfem::Device device("cpu"); + (void)device; + mfem::StopWatch timer; + timer.Start(); + + mfem::Mesh mesh = mfem::Mesh::MakeCartesian2D( + cells, cells, mfem::Element::QUADRILATERAL, true, 1.0, 1.0); + mfem::H1_FECollection elements(order, mesh.Dimension()); + mfem::FiniteElementSpace space(&mesh, &elements); + + mfem::Array essential_boundary(mesh.bdr_attributes.Max()); + essential_boundary = 1; + mfem::Array essential_dofs; + space.GetEssentialTrueDofs(essential_boundary, essential_dofs); + + mfem::FunctionCoefficient exact(exact_solution); + mfem::FunctionCoefficient forcing(forcing_function); + mfem::LinearForm rhs(&space); + rhs.AddDomainIntegrator(new mfem::DomainLFIntegrator(forcing)); + rhs.Assemble(); + + mfem::GridFunction solution(&space); + solution = 0.0; + solution.ProjectBdrCoefficient(exact, essential_boundary); + + mfem::BilinearForm diffusion(&space); + mfem::ConstantCoefficient one(1.0); + diffusion.AddDomainIntegrator(new mfem::DiffusionIntegrator(one)); + diffusion.Assemble(); + + mfem::OperatorPtr matrix; + mfem::Vector linear_rhs; + mfem::Vector linear_solution; + diffusion.FormLinearSystem( + essential_dofs, solution, rhs, matrix, linear_solution, linear_rhs); + + mfem::GSSmoother smoother(static_cast(*matrix)); + mfem::CGSolver solver; + solver.SetOperator(*matrix); + solver.SetPreconditioner(smoother); + solver.SetRelTol(1e-10); + solver.SetAbsTol(0.0); + solver.SetMaxIter(400); + solver.SetPrintLevel(0); + solver.Mult(linear_rhs, linear_solution); + diffusion.RecoverFEMSolution(linear_solution, rhs, solution); + + timer.Stop(); + const double l2_error = solution.ComputeL2Error(exact); + if (!solver.GetConverged() || !std::isfinite(l2_error)) + { + throw std::runtime_error("MFEM conjugate-gradient solve did not converge"); + } + + std::ostringstream output; + output << std::setprecision(10) + << "{\"ok\":true" + << ",\"mfemVersion\":\"" << json_escape(MFEM_VERSION_STRING) << '"' + << ",\"cells\":" << cells + << ",\"elements\":" << mesh.GetNE() + << ",\"order\":" << order + << ",\"trueDofs\":" << space.GetTrueVSize() + << ",\"iterations\":" << solver.GetNumIterations() + << ",\"finalNorm\":" << solver.GetFinalNorm() + << ",\"l2Error\":" << l2_error + << ",\"elapsedSeconds\":" << timer.RealTime() + << '}'; + response = output.str(); + return response.c_str(); + } + catch (const std::exception &error) + { + return error_response(error.what()); + } +} + +int main() +{ + return 0; +}