feat(justfile): added justfile with catalog of builds
This commit is contained in:
195
web/demo.js
Normal file
195
web/demo.js
Normal file
@@ -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();
|
||||
116
web/index.html
Normal file
116
web/index.html
Normal file
@@ -0,0 +1,116 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="description" content="Run a finite-element Poisson solve with MFEM compiled to WebAssembly.">
|
||||
<title>MFEM WebAssembly Lab</title>
|
||||
<link rel="stylesheet" href="./styles.css">
|
||||
<script src="./mfem-wasm-demo.js" defer></script>
|
||||
<script src="./demo.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="masthead">
|
||||
<a class="brand" href="#workspace" aria-label="MFEM WebAssembly Lab home">
|
||||
<span class="brand-mark" aria-hidden="true">M</span>
|
||||
<span>
|
||||
<strong>MFEM</strong>
|
||||
<small>WebAssembly Lab</small>
|
||||
</span>
|
||||
</a>
|
||||
<div class="runtime-state" role="status" aria-live="polite">
|
||||
<span class="status-dot" id="status-dot"></span>
|
||||
<span id="runtime-status">Loading numerical runtime</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="workspace" class="workspace">
|
||||
<section class="control-panel" aria-labelledby="problem-title">
|
||||
<div class="eyebrow">Poisson equation · unit square</div>
|
||||
<h1 id="problem-title">Solve a finite-element system in your browser.</h1>
|
||||
<p class="lede">
|
||||
MFEM assembles and solves the system locally through WebAssembly.
|
||||
Change the mesh or polynomial order and compare the resulting error and workload.
|
||||
</p>
|
||||
|
||||
<form id="solve-form">
|
||||
<div class="field-group">
|
||||
<div class="field-heading">
|
||||
<label for="cells">Cells per axis</label>
|
||||
<output id="cells-value" for="cells">10</output>
|
||||
</div>
|
||||
<input id="cells" name="cells" type="range" min="4" max="40" step="2" value="10">
|
||||
<div class="range-labels" aria-hidden="true"><span>4</span><span>40</span></div>
|
||||
</div>
|
||||
|
||||
<div class="field-group">
|
||||
<label for="order">Polynomial order</label>
|
||||
<select id="order" name="order">
|
||||
<option value="1">1 · Linear</option>
|
||||
<option value="2" selected>2 · Quadratic</option>
|
||||
<option value="3">3 · Cubic</option>
|
||||
<option value="4">4 · Quartic</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button id="solve-button" type="submit" disabled>
|
||||
<span>Run MFEM solve</span>
|
||||
<span aria-hidden="true">→</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="capabilities" aria-label="Compiled capabilities">
|
||||
<span class="capability-label">Runtime</span>
|
||||
<div id="capability-list"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="result-panel" aria-labelledby="result-title">
|
||||
<div class="result-heading">
|
||||
<div>
|
||||
<div class="eyebrow">Computed result</div>
|
||||
<h2 id="result-title">Finite-element mesh</h2>
|
||||
</div>
|
||||
<span class="run-index" id="run-index">Awaiting run</span>
|
||||
</div>
|
||||
|
||||
<div class="mesh-frame">
|
||||
<canvas id="mesh-canvas" width="720" height="500" aria-label="Structured finite-element mesh preview"></canvas>
|
||||
<div class="equation" aria-label="Negative Laplacian of u equals f">
|
||||
<span>−Δu = f</span>
|
||||
<small>u = 0 on ∂Ω</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="metrics" aria-live="polite">
|
||||
<article>
|
||||
<span>True DOFs</span>
|
||||
<strong id="metric-dofs">—</strong>
|
||||
</article>
|
||||
<article>
|
||||
<span>CG iterations</span>
|
||||
<strong id="metric-iterations">—</strong>
|
||||
</article>
|
||||
<article>
|
||||
<span>L² error</span>
|
||||
<strong id="metric-error">—</strong>
|
||||
</article>
|
||||
<article>
|
||||
<span>Browser time</span>
|
||||
<strong id="metric-time">—</strong>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<details class="console-panel">
|
||||
<summary>Runtime details</summary>
|
||||
<pre id="runtime-log">Waiting for WebAssembly initialization…</pre>
|
||||
</details>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<span>All computation stays in this browser tab.</span>
|
||||
<span id="footer-version">MFEM WebAssembly</span>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
34
web/meson.build
Normal file
34
web/meson.build
Normal file
@@ -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
|
||||
26
web/smoke.cjs
Normal file
26
web/smoke.cjs
Normal file
@@ -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;
|
||||
});
|
||||
506
web/styles.css
Normal file
506
web/styles.css
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
173
web/wasm_demo.cpp
Normal file
173
web/wasm_demo.cpp
Normal file
@@ -0,0 +1,173 @@
|
||||
#include <meson_mfem_template/config.hpp>
|
||||
#include <mfem.hpp>
|
||||
|
||||
#include <emscripten/emscripten.h>
|
||||
|
||||
#include <cmath>
|
||||
#include <iomanip>
|
||||
#include <sstream>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
|
||||
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<int>(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<int> essential_boundary(mesh.bdr_attributes.Max());
|
||||
essential_boundary = 1;
|
||||
mfem::Array<int> 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<mfem::SparseMatrix &>(*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;
|
||||
}
|
||||
Reference in New Issue
Block a user