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 ? ( <>
Mesh controls
+ {geometryMeasure && ( +
+ Model extent {formatExtent(geometryMeasure)} mm + {(() => { + const estimate = formatEstimate( + geometryMeasure, + maxElementSize, + ); + return estimate === null ? null : ` · ≈${estimate} elements`; + })()} +
+ )}
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);