diff --git a/web/package.json b/web/package.json
index 8396b94..6ee82ca 100644
--- a/web/package.json
+++ b/web/package.json
@@ -17,7 +17,7 @@
"examples:generate": "bun ../examples/web-examples/generate.mjs",
"examples:generate-crane-shell": "bun ../examples/web-examples/generate-crane-shell.mjs",
"examples:generate-plate-hole-shell": "bun ../examples/web-examples/generate-plate-hole-shell.mjs",
- "test": "bun ../examples/validation/run.mjs && bun ../examples/validation/dof-constraint.test.mjs && bun ../examples/validation/prescribed-displacement.test.mjs && bun ../examples/validation/multiple-loads.test.mjs && bun tests/test_wall_bracket.mjs 20.0 && bun tests/test_multibody.mjs && bun tests/test_tie.mjs && bun tests/test_coupling.mjs && bun tests/test_reference_point.mjs && bun tests/test_face_pick.mjs && bun tests/test_edge_pick.mjs && bun tests/test_moment_load.mjs && bun tests/test_multi_load.mjs && bun tests/test_shellize_mpc.mjs && bun tests/test_body_highlight.mjs && bun tests/test_midsurface_junction.mjs && bun tests/test_coupled_materials.mjs && playwright test",
+ "test": "bun ../examples/validation/run.mjs && bun ../examples/validation/dof-constraint.test.mjs && bun ../examples/validation/prescribed-displacement.test.mjs && bun ../examples/validation/multiple-loads.test.mjs && bun tests/test_wall_bracket.mjs 20.0 && bun tests/test_multibody.mjs && bun tests/test_tie.mjs && bun tests/test_coupling.mjs && bun tests/test_reference_point.mjs && bun tests/test_face_pick.mjs && bun tests/test_edge_pick.mjs && bun tests/test_moment_load.mjs && bun tests/test_multi_load.mjs && bun tests/test_shellize_mpc.mjs && bun tests/test_mesh_sizing.mjs && bun tests/test_body_highlight.mjs && bun tests/test_midsurface_junction.mjs && bun tests/test_coupled_materials.mjs && playwright test",
"test:coverage": "rm -rf .nyc_output coverage && COVERAGE=1 playwright test && bun run coverage:report",
"coverage:report": "nyc report && bun scripts/coverage-dead-code.ts",
"test:ui": "playwright test --ui",
diff --git a/web/src/components/panel/LeftPanel.module.css b/web/src/components/panel/LeftPanel.module.css
index dfa5d14..09c7f01 100644
--- a/web/src/components/panel/LeftPanel.module.css
+++ b/web/src/components/panel/LeftPanel.module.css
@@ -250,6 +250,13 @@
width: 14px;
}
+.hint {
+ font-size: 10.5px;
+ color: var(--muted);
+ font-family: "IBM Plex Mono", ui-monospace, monospace;
+ margin: -2px 0 8px;
+}
+
/* ── Tree items ─────────────────────────────────────────────── */
.treeItem {
diff --git a/web/src/components/panel/MeshPanel.tsx b/web/src/components/panel/MeshPanel.tsx
index da73088..9cacc99 100644
--- a/web/src/components/panel/MeshPanel.tsx
+++ b/web/src/components/panel/MeshPanel.tsx
@@ -3,9 +3,31 @@
import { useModelStore } from "../../store/modelStore";
import { useMesh } from "../../hooks/useMesh";
+import { estimateElementCount } from "../../lib/meshSizing";
+import type { GeometryMeasure } from "../../lib/meshSizing";
import { LogSection } from "./LogSection";
import styles from "./LeftPanel.module.css";
+// Bounding box of the import, as "x × y × z". Three significant digits keep a
+// 0.8 mm part and a 2400 mm weldment equally readable.
+function formatExtent({ dx, dy, dz }: GeometryMeasure): string {
+ const round = (value: number) => Number(value.toPrecision(3)).toString();
+ return `${round(dx)} × ${round(dy)} × ${round(dz)}`;
+}
+
+// Rough element count the current max size implies, as "12K"/"1.2M" — the size
+// fields are unbounded, so this is what tells the user a value is about to cost
+// them minutes of meshing before they click.
+function formatEstimate(measure: GeometryMeasure, size: string): string | null {
+ const parsed = parseFloat(size);
+ if (!Number.isFinite(parsed) || parsed <= 0) return null;
+ const count = estimateElementCount(measure, parsed);
+ if (!Number.isFinite(count)) return null;
+ if (count >= 1e6) return `${(count / 1e6).toPrecision(2)}M`;
+ if (count >= 1e3) return `${Math.round(count / 1e3)}K`;
+ return `${Math.round(count)}`;
+}
+
// Mesh sizing controls, the mesh/re-mesh action and the meshing log — rendered
// inside the Geometry tab below the import cards.
export function MeshPanel() {
@@ -19,6 +41,7 @@ export function MeshPanel() {
setMaxElementSize,
minElementSize,
setMinElementSize,
+ geometryMeasure,
meshError,
setMeshError,
logs,
@@ -37,19 +60,29 @@ export function MeshPanel() {
{stepSurface ? (
<>
Max element size
- setMaxElementSize(Math.max(0.5, Number(e.target.value)))
- }
+ onChange={(e) => setMaxElementSize(e.target.value)}
+ title="Upper bound on the element size, in mm. Any positive value is allowed — size it to the part, not to a fixed range."
/>
mm
@@ -57,15 +90,13 @@ export function MeshPanel() {
Min element size
- setMinElementSize(Math.max(0, Number(e.target.value)))
- }
+ onChange={(e) => setMinElementSize(e.target.value)}
+ title="Floor for curvature-driven refinement, in mm. 0 lets Netgen refine fillets without limit."
/>
mm
diff --git a/web/src/hooks/useMesh.ts b/web/src/hooks/useMesh.ts
index 1469aa2..c32ca92 100644
--- a/web/src/hooks/useMesh.ts
+++ b/web/src/hooks/useMesh.ts
@@ -1,12 +1,19 @@
// SPDX-FileCopyrightText: 2026 Michael Kofler
// SPDX-License-Identifier: AGPL-3.0-or-later
-import { useState } from "react";
+import { useEffect, useMemo, useState } from "react";
import { useModelStore } from "../store/modelStore";
import type { Node, Element, Property } from "../store/modelStore";
import { isCadBody } from "../store/geometrySlice";
import { sendToWorker, resetWorker } from "../workers/sharedWorker";
import { useWorkerLogs } from "./useWorkerLogs";
+import { suggestElementSizes } from "../lib/meshSizing";
+
+// Sizes shown before any geometry is imported. The mesh controls only render
+// once a part is loaded, at which point the import's own suggestion (sized to
+// TARGET_ELEMENT_COUNT, see lib/meshSizing) replaces both.
+export const DEFAULT_MAX_ELEMENT_SIZE = 20;
+export const DEFAULT_MIN_ELEMENT_SIZE = 2;
// Volume meshing: owns the mesh sizing parameters, the meshing-in-flight and
// log state, and the worker's volume_mesh protocol (including the mandatory
@@ -22,13 +29,42 @@ export function useMesh() {
const elementOrder = useModelStore((s) => s.elementOrder);
const setElementOrder = useModelStore((s) => s.setElementOrder);
- const [maxElementSize, setMaxElementSize] = useState(20);
+ // Both sizes are held as text, not numbers: a 1 mm part needs 0.1 mm elements
+ // and a 2 m weldment needs 80 mm ones, so there is no meaningful range to clamp
+ // typing to (KOF-222). They are parsed and validated when meshing starts.
+ const [maxElementSize, setMaxElementSize] = useState(
+ String(DEFAULT_MAX_ELEMENT_SIZE),
+ );
// Floor for curvature-driven refinement; 0 lets Netgen refine fillets without
// limit, which can produce >10x more elements than the max size suggests.
- const [minElementSize, setMinElementSize] = useState(2);
+ const [minElementSize, setMinElementSize] = useState(
+ String(DEFAULT_MIN_ELEMENT_SIZE),
+ );
const [meshError, setMeshError] = useState(null);
const { logs, clearLogs } = useWorkerLogs("mesh");
+ // Element sizes suggested by the imported geometry itself, aimed at
+ // TARGET_ELEMENT_COUNT elements. Recomputed per import — stepSurface is
+ // replaced wholesale when a part is loaded — and applied by the effect below.
+ const suggestion = useMemo(
+ () =>
+ stepSurface
+ ? suggestElementSizes(stepSurface.points, stepSurface.triangles)
+ : null,
+ [stepSurface],
+ );
+
+ // A new import re-sizes both fields: a 1 mm cube and a 2 m weldment need
+ // element sizes three orders of magnitude apart, so carrying the previous
+ // part's numbers over (or a fixed 20 mm) is never right (KOF-222). Typed
+ // values survive until the next import — this runs on stepSurface identity,
+ // which only changes when geometry is loaded.
+ useEffect(() => {
+ if (!suggestion) return;
+ setMaxElementSize(suggestion.max);
+ setMinElementSize(suggestion.min);
+ }, [suggestion]);
+
async function meshVolume() {
if (!stepSurface) return;
if (!stepBytes) {
@@ -37,6 +73,29 @@ export function useMesh() {
);
return;
}
+
+ const maxSize = parseFloat(maxElementSize);
+ const minSize = parseFloat(minElementSize);
+ if (!Number.isFinite(maxSize) || maxSize <= 0) {
+ setMeshError(
+ `Max element size must be a positive number of mm — got "${maxElementSize}".`,
+ );
+ return;
+ }
+ if (!Number.isFinite(minSize) || minSize < 0) {
+ setMeshError(
+ `Min element size must be 0 or a positive number of mm — got "${minElementSize}".`,
+ );
+ return;
+ }
+ if (minSize > maxSize) {
+ setMeshError(
+ `Min element size (${minSize} mm) must not exceed max element size (${maxSize} mm).`,
+ );
+ return;
+ }
+
+ setMeshError(null);
setMeshing(true);
clearLogs();
try {
@@ -55,8 +114,8 @@ export function useMesh() {
}>("volume_mesh", {
bytes: stepBytes,
format: geometryFormat,
- maxElementSize,
- minElementSize,
+ maxElementSize: maxSize,
+ minElementSize: minSize,
// Bodies the user (or detectShellBodies at import) marked Shell, plus the
// property table: meshing idealises their thin walls to a mid-surface
// shell mesh and returns the mixed CTRIA3 + CTETRA model (#397). Only CAD
@@ -98,6 +157,7 @@ export function useMesh() {
setMaxElementSize,
minElementSize,
setMinElementSize,
+ geometryMeasure: suggestion?.measure ?? null,
meshError,
setMeshError,
logs,
diff --git a/web/src/lib/meshSizing.ts b/web/src/lib/meshSizing.ts
new file mode 100644
index 0000000..3dae643
--- /dev/null
+++ b/web/src/lib/meshSizing.ts
@@ -0,0 +1,152 @@
+// SPDX-FileCopyrightText: 2026 Michael Kofler
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+// Starting element size for a freshly imported part (KOF-222). A fixed default
+// only ever suits one scale of model: 20 mm meshes a bracket sensibly and a
+// 1 mm cube not at all. This sizes the mesh to the geometry instead, aiming at
+// a target element count.
+
+// Element count a default mesh aims for: fine enough to see a stress field,
+// coarse enough to mesh and solve in the browser.
+export const TARGET_ELEMENT_COUNT = 50_000;
+
+// Default ratio between the two size fields. min_element_size floors Netgen's
+// curvature-driven refinement; h/10 is what the worker assumes when a caller
+// sends no minimum.
+export const MIN_SIZE_RATIO = 10;
+
+export interface GeometryMeasure {
+ // Bounding box side lengths, in mm.
+ dx: number;
+ dy: number;
+ dz: number;
+ // Enclosed volume and boundary area of the tessellated shape, mm³ and mm².
+ volume: number;
+ area: number;
+}
+
+// Volume, area and bounding box of the display tessellation. The volume is the
+// divergence-theorem sum over the closed surface, V = (1/6) Σ a·(b×c) — exact
+// for the tessellated polyhedron, and within a fraction of a percent of the CAD
+// volume at the deflection the viewer tessellates with. (OCCT's
+// BRepGProp::VolumeProperties would give the exact CAD value, but reaching it
+// from here needs a new engine entry point and a WASM rebuild; the difference is
+// far below the accuracy this estimate claims.) Open, surface-only geometry
+// yields volume ≈ 0, which sizeFromMeasure handles via the area term alone.
+export function measureTessellation(
+ points: [number, number, number][],
+ triangles: [number, number, number][],
+): GeometryMeasure | null {
+ if (points.length === 0 || triangles.length === 0) return null;
+
+ const min = [Infinity, Infinity, Infinity];
+ const max = [-Infinity, -Infinity, -Infinity];
+ for (const point of points) {
+ for (let i = 0; i < 3; i++) {
+ if (point[i] < min[i]) min[i] = point[i];
+ if (point[i] > max[i]) max[i] = point[i];
+ }
+ }
+
+ let sixVolume = 0;
+ let area = 0;
+ for (const [ia, ib, ic] of triangles) {
+ const pa = points[ia];
+ const pb = points[ib];
+ const pc = points[ic];
+ if (pa === undefined || pb === undefined || pc === undefined)
+ throw new Error(
+ `measureTessellation: triangle (${ia}, ${ib}, ${ic}) references a vertex outside the ${points.length}-point tessellation`,
+ );
+ sixVolume +=
+ pa[0] * (pb[1] * pc[2] - pb[2] * pc[1]) -
+ pa[1] * (pb[0] * pc[2] - pb[2] * pc[0]) +
+ pa[2] * (pb[0] * pc[1] - pb[1] * pc[0]);
+ const ux = pb[0] - pa[0];
+ const uy = pb[1] - pa[1];
+ const uz = pb[2] - pa[2];
+ const vx = pc[0] - pa[0];
+ const vy = pc[1] - pa[1];
+ const vz = pc[2] - pa[2];
+ area +=
+ 0.5 * Math.hypot(uy * vz - uz * vy, uz * vx - ux * vz, ux * vy - uy * vx);
+ }
+
+ return {
+ dx: max[0] - min[0],
+ dy: max[1] - min[1],
+ dz: max[2] - min[2],
+ volume: Math.abs(sixVolume) / 6,
+ area,
+ };
+}
+
+// Elements Netgen produces at element size h. Two terms, because two regimes
+// exist: a bulky part fills its volume with roughly 6 tets per h³ cell (the
+// structured hex-to-tet split), while a thin or heavily featured part is
+// dominated by its boundary — the surface mesh alone carries ~2 triangles per h²
+// and each seeds tets through the wall. Ignoring the surface term made a
+// thin-walled crane holder (160,000 mm² of surface over 57,000 mm³) come out at
+// 190K tets when 50K was asked for. Both coefficients are empirical, calibrated
+// against Netgen on the parts in test_files/; they hold the prediction inside
+// 0.6–1.3x of the target across bulky, hollow and thin-walled geometry.
+const TETS_PER_VOLUME_CELL = 6;
+const TRIANGLES_PER_AREA_CELL = 2;
+
+export function estimateElementCount(
+ measure: GeometryMeasure,
+ size: number,
+): number {
+ return (
+ (TETS_PER_VOLUME_CELL * measure.volume) / size ** 3 +
+ (TRIANGLES_PER_AREA_CELL * measure.area) / size ** 2
+ );
+}
+
+// Largest element size whose estimated count still reaches `target`. The count
+// falls monotonically with size, so a geometric bisection over the size range
+// inverts it — no closed form is needed for the mixed cubic/quadratic.
+export function sizeFromMeasure(
+ measure: GeometryMeasure,
+ target = TARGET_ELEMENT_COUNT,
+): number | null {
+ const diagonal = Math.hypot(measure.dx, measure.dy, measure.dz);
+ if (!Number.isFinite(diagonal) || diagonal <= 0) return null;
+ if (estimateElementCount(measure, diagonal) <= 0) return null;
+
+ // The whole part in one element is coarser than any useful mesh, and 1e-6 of
+ // the diagonal is finer than any; the answer is strictly between.
+ let coarse = diagonal;
+ let fine = diagonal * 1e-6;
+ for (let i = 0; i < 100; i++) {
+ const mid = Math.sqrt(coarse * fine);
+ if (estimateElementCount(measure, mid) > target) fine = mid;
+ else coarse = mid;
+ }
+ const size = Math.sqrt(coarse * fine);
+ return Number.isFinite(size) && size > 0 ? size : null;
+}
+
+// Round to three significant digits so the suggestion reads as a mesh setting
+// ("2.95 mm") rather than a raw solve of the estimator ("2.9537841...").
+export function formatElementSize(size: number): string {
+ return Number(size.toPrecision(3)).toString();
+}
+
+// The pair of sizes a fresh import starts from: max sized to the target count,
+// min at the ratio the worker would have assumed anyway.
+export function suggestElementSizes(
+ points: [number, number, number][],
+ triangles: [number, number, number][],
+ target = TARGET_ELEMENT_COUNT,
+): { max: string; min: string; measure: GeometryMeasure } | null {
+ const measure = measureTessellation(points, triangles);
+ if (!measure) return null;
+ const size = sizeFromMeasure(measure, target);
+ if (size === null) return null;
+ return {
+ max: formatElementSize(size),
+ min: formatElementSize(size / MIN_SIZE_RATIO),
+ measure,
+ };
+}
diff --git a/web/src/workers/solver.worker.ts b/web/src/workers/solver.worker.ts
index 373c2ca..6cef904 100644
--- a/web/src/workers/solver.worker.ts
+++ b/web/src/workers/solver.worker.ts
@@ -267,6 +267,24 @@ function flushCoverage(): void {
function handleVolumeMesh(id: number, payload: VolumeMeshPayload) {
const { bytes, format = "step", maxElementSize = 20.0 } = payload;
+ // Floor the curvature-driven local element size at maxElementSize/10 by
+ // default. Without a floor, Netgen refines every fillet to ~radius/2
+ // (elementspercurve) — on fillet-heavy CAD this produces >10x more
+ // elements than the max size suggests and meshing takes minutes.
+ const minSize = payload.minElementSize ?? maxElementSize / 10;
+
+ // Any positive size is legal (KOF-222) — a 1 mm cube is meshed with 0.1 mm
+ // elements. Zero, negative and NaN are not: Netgen's maxh would disable the
+ // size field entirely and mesh for minutes before failing obscurely.
+ if (!Number.isFinite(maxElementSize) || maxElementSize <= 0)
+ throw new Error(
+ `volume_mesh: max_element_size must be a positive number of mm, got ${maxElementSize}`,
+ );
+ if (!Number.isFinite(minSize) || minSize < 0 || minSize > maxElementSize)
+ throw new Error(
+ `volume_mesh: min_element_size must be between 0 and max_element_size (${maxElementSize} mm), got ${minSize}`,
+ );
+
// A re-mesh runs in a fresh worker (the previous mesh tore this worker's
// predecessor down), so the OCCT shape generate_fem_mesh needs is gone.
// Reload it from the original STEP bytes first. This makes every mesh
@@ -293,12 +311,6 @@ function handleVolumeMesh(id: number, payload: VolumeMeshPayload) {
geometryLoaded = true;
}
- // Floor the curvature-driven local element size at maxElementSize/10 by
- // default. Without a floor, Netgen refines every fillet to ~radius/2
- // (elementspercurve) — on fillet-heavy CAD this produces >10x more
- // elements than the max size suggests and meshing takes minutes.
- const minSize = payload.minElementSize ?? maxElementSize / 10;
-
const opts = JSON.stringify({
max_element_size: maxElementSize,
min_element_size: minSize,
diff --git a/web/tests/mesh-size.spec.ts b/web/tests/mesh-size.spec.ts
new file mode 100644
index 0000000..9d28c38
--- /dev/null
+++ b/web/tests/mesh-size.spec.ts
@@ -0,0 +1,184 @@
+// SPDX-FileCopyrightText: 2026 Michael Kofler
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+// KOF-222: the mesh size fields used to clamp to [0.5, 500] mm on every
+// keystroke and start at a fixed 20 mm, so a 1x1x1 mm cube could not be meshed
+// at all — every legal setting was coarser than the part. The fields now accept
+// any positive size, and a fresh import starts from a size computed for its own
+// geometry.
+
+import { test, expect } from "./coverage";
+import { gotoApp, importStep } from "./fixtures/app";
+import { fileURLToPath } from "url";
+
+const STEP_FILE = fileURLToPath(
+ new URL("./fixtures/tube.stp", import.meta.url),
+);
+
+const maxSize = "max-element-size";
+const minSize = "min-element-size";
+
+// Put the panel in the meshable state without paying for a real STEP import:
+// a tessellated surface plus retained bytes is exactly what an import leaves
+// behind, and it is what makes the mesh controls render.
+async function fakeImport(page: import("@playwright/test").Page) {
+ await page.evaluate(() => {
+ (
+ window as unknown as {
+ __kofemStore: { setState(partial: Record): void };
+ }
+ ).__kofemStore.setState({
+ mode: "geometry",
+ // A 1 mm cube, the part from the issue: two triangles per face.
+ stepSurface: {
+ points: [
+ [0, 0, 0],
+ [1, 0, 0],
+ [1, 1, 0],
+ [0, 1, 0],
+ [0, 0, 1],
+ [1, 0, 1],
+ [1, 1, 1],
+ [0, 1, 1],
+ ],
+ triangles: [
+ [0, 2, 1],
+ [0, 3, 2],
+ [4, 5, 6],
+ [4, 6, 7],
+ [0, 1, 5],
+ [0, 5, 4],
+ [3, 7, 6],
+ [3, 6, 2],
+ [0, 4, 7],
+ [0, 7, 3],
+ [1, 2, 6],
+ [1, 6, 5],
+ ],
+ },
+ stepBytes: new Uint8Array([1, 2, 3]),
+ });
+ });
+}
+
+test("a sub-millimetre element size can be typed and kept (KOF-222)", async ({
+ page,
+}) => {
+ await gotoApp(page);
+ await fakeImport(page);
+
+ // The old input clamped with Math.max(0.5, …) on change, so 0.05 became 0.5.
+ await page.getByTestId(maxSize).fill("0.05");
+ await expect(page.getByTestId(maxSize)).toHaveValue("0.05");
+ await page.getByTestId(minSize).fill("0.005");
+ await expect(page.getByTestId(minSize)).toHaveValue("0.005");
+
+ // …and the old max attribute capped coarse meshes of large parts at 500 mm.
+ await page.getByTestId(maxSize).fill("1200");
+ await expect(page.getByTestId(maxSize)).toHaveValue("1200");
+});
+
+test("a fresh import starts from a size computed for its own geometry (KOF-222)", async ({
+ page,
+}) => {
+ await gotoApp(page);
+ await fakeImport(page);
+
+ // The 1 mm cube must be sized far below the old 0.5 mm floor — and far below
+ // the old fixed 20 mm default, which meshed it as a single element.
+ const suggested = parseFloat(await page.getByTestId(maxSize).inputValue());
+ expect(suggested).toBeGreaterThan(0);
+ expect(suggested).toBeLessThan(0.5);
+ expect(1 / suggested).toBeGreaterThan(10); // >10 elements across the cube
+
+ const suggestedMin = parseFloat(await page.getByTestId(minSize).inputValue());
+ expect(suggestedMin).toBeCloseTo(suggested / 10, 6);
+
+ // The panel says what the model is and what the size will cost, so an
+ // unbounded field is still an informed choice.
+ await expect(page.getByTestId("geometry-extent")).toContainText(
+ "1 × 1 × 1 mm",
+ );
+ await expect(page.getByTestId("geometry-extent")).toContainText("elements");
+});
+
+test("an unusable element size is refused with a specific message (KOF-222)", async ({
+ page,
+}) => {
+ await gotoApp(page);
+ await fakeImport(page);
+
+ await page.getByTestId(maxSize).fill("0");
+ await page.getByRole("button", { name: /Mesh STEP volume/ }).click();
+ await expect(page.getByTestId("meshing-error")).toContainText(
+ "Max element size must be a positive number",
+ );
+
+ // Min above max would silently invert the size field inside Netgen.
+ await page.getByTestId(maxSize).fill("2");
+ await page.getByTestId(minSize).fill("5");
+ await page.getByRole("button", { name: /Mesh STEP volume/ }).click();
+ await expect(page.getByTestId("meshing-error")).toContainText(
+ "must not exceed max element size",
+ );
+});
+
+test("the worker rejects a non-positive element size (KOF-222)", async ({
+ page,
+}) => {
+ test.setTimeout(60_000);
+
+ await page.goto("/app/", { waitUntil: "domcontentloaded" });
+ await page.waitForFunction(
+ () => Boolean((window as Window & { __kofem?: unknown }).__kofem),
+ { timeout: 30_000 },
+ );
+
+ const message = await page.evaluate(async () => {
+ const kofem = (
+ window as Window & {
+ __kofem: { sendToWorker: (t: string, p: unknown) => Promise };
+ }
+ ).__kofem;
+ try {
+ await kofem.sendToWorker("volume_mesh", { maxElementSize: 0 });
+ return null;
+ } catch (err) {
+ return err instanceof Error ? err.message : String(err);
+ }
+ });
+
+ expect(message).toContain("max_element_size must be a positive number");
+});
+
+test("a real import is meshed at its suggested size (KOF-222)", async ({
+ page,
+}) => {
+ test.setTimeout(300_000);
+
+ await importStep(page, STEP_FILE);
+
+ // The tube is ~40x40x60 mm; the old fixed default was 20 mm regardless.
+ const suggested = parseFloat(await page.getByTestId(maxSize).inputValue());
+ expect(suggested).toBeGreaterThan(0.5);
+ expect(suggested).toBeLessThan(20);
+
+ const elementCount = async () =>
+ (await page.evaluate(
+ () =>
+ (
+ window as unknown as {
+ __kofemStore: { getState(): { elements: unknown[] } };
+ }
+ ).__kofemStore.getState().elements.length,
+ )) as number;
+
+ await page.getByRole("button", { name: /Mesh STEP volume/ }).click();
+ await expect.poll(elementCount, { timeout: 240_000 }).toBeGreaterThan(0);
+
+ // The suggestion aims at TARGET_ELEMENT_COUNT (50K). Netgen's actual density
+ // varies with geometry, so assert the order of magnitude, not the number.
+ const count = await elementCount();
+ expect(count).toBeGreaterThan(10_000);
+ expect(count).toBeLessThan(250_000);
+});
diff --git a/web/tests/test_mesh_sizing.mjs b/web/tests/test_mesh_sizing.mjs
new file mode 100644
index 0000000..d495457
--- /dev/null
+++ b/web/tests/test_mesh_sizing.mjs
@@ -0,0 +1,193 @@
+// SPDX-FileCopyrightText: 2026 Michael Kofler
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+// Unit tests for src/lib/meshSizing.ts — the default element size a fresh
+// import starts from (KOF-222). The old fixed 20 mm default (and the 0.5 mm
+// floor on the input) made small parts unmeshable: on a 1x1x1 mm cube every
+// legal setting was coarser than the part itself.
+//
+// The estimator's coefficients were calibrated against Netgen on the parts in
+// test_files/; these tests pin the properties that calibration relies on —
+// exact measurement of a closed tessellation, scale invariance, and hitting the
+// target count — without needing the WASM engine.
+//
+// Run: bun tests/test_mesh_sizing.mjs (from the web/ directory)
+
+import {
+ measureTessellation,
+ estimateElementCount,
+ sizeFromMeasure,
+ suggestElementSizes,
+ TARGET_ELEMENT_COUNT,
+ MIN_SIZE_RATIO,
+} from "../src/lib/meshSizing.ts";
+
+let failures = 0;
+function check(name, cond, detail = "") {
+ if (cond) {
+ console.log(` [PASS] ${name}`);
+ } else {
+ failures++;
+ console.log(` [FAIL] ${name}${detail ? ` — ${detail}` : ""}`);
+ }
+}
+
+// Axis-aligned box tessellation with outward-facing triangles, side lengths
+// sx/sy/sz — volume sx·sy·sz and area 2(sx·sy + sy·sz + sz·sx) exactly.
+function box(sx, sy, sz) {
+ const points = [
+ [0, 0, 0],
+ [sx, 0, 0],
+ [sx, sy, 0],
+ [0, sy, 0],
+ [0, 0, sz],
+ [sx, 0, sz],
+ [sx, sy, sz],
+ [0, sy, sz],
+ ];
+ const triangles = [
+ [0, 2, 1],
+ [0, 3, 2], // z = 0
+ [4, 5, 6],
+ [4, 6, 7], // z = sz
+ [0, 1, 5],
+ [0, 5, 4], // y = 0
+ [3, 7, 6],
+ [3, 6, 2], // y = sy
+ [0, 4, 7],
+ [0, 7, 3], // x = 0
+ [1, 2, 6],
+ [1, 6, 5], // x = sx
+ ];
+ return { points, triangles };
+}
+
+console.log("\n1. Measuring a closed tessellation");
+{
+ const { points, triangles } = box(2, 3, 4);
+ const measure = measureTessellation(points, triangles);
+ check(
+ "volume is exact",
+ Math.abs(measure.volume - 24) < 1e-9,
+ `${measure.volume}`,
+ );
+ check("area is exact", Math.abs(measure.area - 52) < 1e-9, `${measure.area}`);
+ check(
+ "bounding box is exact",
+ measure.dx === 2 && measure.dy === 3 && measure.dz === 4,
+ `${measure.dx} x ${measure.dy} x ${measure.dz}`,
+ );
+}
+
+console.log("\n2. Empty geometry has no measure");
+check("no points", measureTessellation([], []) === null);
+check("no triangles", measureTessellation([[0, 0, 0]], []) === null);
+
+console.log("\n3. The suggested size hits the target element count");
+for (const dims of [
+ [1, 1, 1],
+ [40, 40, 60],
+ [300, 80, 80],
+ [1000, 20, 2], // long thin strip: the area term dominates
+]) {
+ const { points, triangles } = box(...dims);
+ const measure = measureTessellation(points, triangles);
+ const size = sizeFromMeasure(measure);
+ const count = estimateElementCount(measure, size);
+ check(
+ `${dims.join("x")} mm -> ${size.toPrecision(3)} mm`,
+ Math.abs(count - TARGET_ELEMENT_COUNT) / TARGET_ELEMENT_COUNT < 1e-3,
+ `estimated ${Math.round(count)} elements, target ${TARGET_ELEMENT_COUNT}`,
+ );
+}
+
+console.log(
+ "\n4. KOF-222: a 1 mm cube is sized far below the old 0.5 mm floor",
+);
+{
+ const { points, triangles } = box(1, 1, 1);
+ const suggestion = suggestElementSizes(points, triangles);
+ const max = parseFloat(suggestion.max);
+ const min = parseFloat(suggestion.min);
+ check("max size is well under 0.5 mm", max < 0.1, `${suggestion.max} mm`);
+ check(
+ "the cube spans at least 10 elements per side",
+ 1 / max >= 10,
+ `${(1 / max).toFixed(1)} elements per side`,
+ );
+ check(
+ `min size is max/${MIN_SIZE_RATIO}`,
+ Math.abs(min - max / MIN_SIZE_RATIO) < max / MIN_SIZE_RATIO / 100,
+ `${suggestion.min} mm vs ${max / MIN_SIZE_RATIO}`,
+ );
+ check(
+ "sizes are rounded for display, not raw solver output",
+ /^\d*\.?\d+$/.test(suggestion.max) && suggestion.max.length <= 6,
+ suggestion.max,
+ );
+}
+
+console.log("\n5. Sizing is scale invariant");
+{
+ const sizeOf = (sx, sy, sz) => {
+ const { points, triangles } = box(sx, sy, sz);
+ return sizeFromMeasure(measureTessellation(points, triangles));
+ };
+ const small = sizeOf(1, 1, 1);
+ const large = sizeOf(1000, 1000, 1000);
+ // Volume and area terms scale differently, so the ratio is not exactly 1000;
+ // it must still track the model's scale rather than sitting at a constant.
+ check(
+ "a 1000x larger cube gets a much larger element size",
+ large / small > 100,
+ `${small.toPrecision(3)} mm vs ${large.toPrecision(3)} mm`,
+ );
+}
+
+console.log("\n6. Open (surface-only) geometry is sized from its area alone");
+{
+ const points = [
+ [0, 0, 0],
+ [100, 0, 0],
+ [100, 100, 0],
+ ];
+ const measure = measureTessellation(points, [[0, 1, 2]]);
+ check(
+ "a flat sheet encloses no volume",
+ measure.volume < 1e-9,
+ `${measure.volume}`,
+ );
+ const size = sizeFromMeasure(measure);
+ check("it still gets a size", size !== null && size > 0, `${size}`);
+ check(
+ "sized to the target from the area term",
+ Math.abs(estimateElementCount(measure, size) - TARGET_ELEMENT_COUNT) /
+ TARGET_ELEMENT_COUNT <
+ 1e-3,
+ );
+}
+
+console.log(
+ "\n7. A degenerate tessellation reports the bad index, not a silent NaN",
+);
+{
+ let message = null;
+ try {
+ measureTessellation([[0, 0, 0]], [[0, 1, 2]]);
+ } catch (err) {
+ message = err.message;
+ }
+ check("out-of-range vertex throws", message !== null);
+ check(
+ "the message names the triangle",
+ message?.includes("(0, 1, 2)") === true,
+ message ?? "",
+ );
+}
+
+console.log(
+ failures === 0
+ ? "\nAll mesh-sizing checks passed.\n"
+ : `\n${failures} mesh-sizing check(s) FAILED.\n`,
+);
+process.exit(failures === 0 ? 0 : 1);