Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 7 additions & 0 deletions web/src/components/panel/LeftPanel.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
55 changes: 43 additions & 12 deletions web/src/components/panel/MeshPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -19,6 +41,7 @@ export function MeshPanel() {
setMaxElementSize,
minElementSize,
setMinElementSize,
geometryMeasure,
meshError,
setMeshError,
logs,
Expand All @@ -37,35 +60,43 @@ export function MeshPanel() {
{stepSurface ? (
<>
<div className={styles.sectionLabel}>Mesh controls</div>
{geometryMeasure && (
<div className={styles.hint} data-testid="geometry-extent">
Model extent {formatExtent(geometryMeasure)} mm
{(() => {
const estimate = formatEstimate(
geometryMeasure,
maxElementSize,
);
return estimate === null ? null : ` · ≈${estimate} elements`;
})()}
</div>
)}
<div className={styles.formRow}>
<span className={styles.formLabel}>Max element size</span>
<input
className={styles.formInput}
data-testid="max-element-size"
type="number"
min={0.5}
max={500}
step={0.5}
step="any"
value={maxElementSize}
disabled={isMeshing}
onChange={(e) =>
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."
/>
<span className={styles.toleranceUnit}>mm</span>
</div>
<div className={styles.formRow}>
<span className={styles.formLabel}>Min element size</span>
<input
className={styles.formInput}
data-testid="min-element-size"
type="number"
min={0}
max={500}
step={0.5}
step="any"
value={minElementSize}
disabled={isMeshing}
onChange={(e) =>
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."
/>
<span className={styles.toleranceUnit}>mm</span>
</div>
Expand Down
70 changes: 65 additions & 5 deletions web/src/hooks/useMesh.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<string | null>(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) {
Expand All @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -98,6 +157,7 @@ export function useMesh() {
setMaxElementSize,
minElementSize,
setMinElementSize,
geometryMeasure: suggestion?.measure ?? null,
meshError,
setMeshError,
logs,
Expand Down
152 changes: 152 additions & 0 deletions web/src/lib/meshSizing.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
Loading
Loading