Builds and applies the multigrid cycle that speeds up the conjugate-gradient solve of the voxel stress analysis.
How the precision is kept
All level matrices, block inverses and work vectors are Float64Array. The cycle is an approximate inverse used as a preconditioner; the file's header says the conjugate-gradient solve in fea.js works in the exactly projected space, so the damping, the penalty and the coarse solve change the convergence rate and not the answer. The stopping rule of that solve is not in this file.
Calculations under this policy
6 calculations in 1 source file. The code is read from the released app when this page loads. It is the calculation people run, not a copy kept for this page.
mg.jsPartCraft 0.8.164-bit6 calculationsReviewed
Tests tools/partcraft-test/test-simulation.mjs
Coarse element matrix
64-bit
Forms Rᵀ K R for one fine element inside a coarse element.
K_coarse += s × Rᵀ K R
Two passes of weighted sums in Float64Array; the weights are products of 1 and 0.5, so applying them adds no rounding beyond the sums themselves.
mg.js
lines 29 to 40
PartCraft 0.8.1
/** Rᵀ K R for one octant: K (24×24 fine element), w[f][C] → adds s·result into out (24×24). */
function galerkin(K, w, s, out) {
const T = new Float64Array(576); // T = K R (24 × 24)
for (let i = 0; i < 24; i++) for (let C = 0; C < 8; C++) for (let d = 0; d < 3; d++) {
let t = 0; for (let f = 0; f < 8; f++) { const wf = w[f][C]; if (wf) t += K[i * 24 + 3 * f + d] * wf; }
T[i * 24 + 3 * C + d] = t;
}
for (let C = 0; C < 8; C++) for (let a = 0; a < 3; a++) for (let j = 0; j < 24; j++) {
let t = 0; for (let f = 0; f < 8; f++) { const wf = w[f][C]; if (wf) t += wf * T[(3 * f + a) * 24 + j]; }
out[(3 * C + a) * 24 + j] += s * t;
}
}
Inverse of each node's 3 × 3 block
64-bit
Inverts the diagonal block of every node for the block Jacobi smoother.
D⁻¹ = adj(D) ÷ det(D)
Cofactor inverse in 64-bit numbers. The only guard is that the determinant is not exactly zero; a nearly singular block is inverted as it is.
The residual is formed per node in 64-bit numbers and multiplied by the stored block inverse. ω is a fixed tuning value; it affects how fast the outer solve converges.
Moves residuals to the coarser grid and corrections back to the finer one.
weight = ½ for each direction in which the fine node lies between two coarse nodes
Weights are 1, 0.5, 0.25 or 0.125. The same weights are used both ways, which keeps the cycle symmetric. The bit operations here act on grid indexes only.
mg.js
lines 178 to 190
PartCraft 0.8.1
// fine node (i,j,k) ← coarse nodes; visit(fineNode, coarseNode, weight)
function eachTransfer(F, C, fn) {
const FX = F.nx + 1, FY = F.ny + 1, CX = C.nx + 1, CY = C.ny + 1;
for (let k = 0; k <= F.nz; k++) for (let j = 0; j <= F.ny; j++) for (let i = 0; i <= F.nx; i++) {
const n = i + FX * (j + FY * k);
if (!F.active[n]) continue;
const I = i >> 1, J = j >> 1, K = k >> 1, oi = i & 1, oj = j & 1, ok = k & 1;
for (let c = 0; c <= ok; c++) for (let b = 0; b <= oj; b++) for (let a = 0; a <= oi; a++) {
const w = (oi ? 0.5 : 1) * (oj ? 0.5 : 1) * (ok ? 0.5 : 1);
fn(n, (I + a) + CX * ((J + b) + CY * (K + c)), w);
}
}
}
Coarsest-level factorization
64-bit
Factors the coarsest level's matrix once so each cycle can solve it directly.
A = L Lᵀ in band storage, nodes renumbered with the longest axis slowest
Float64Array band. A diagonal term that is not above 1e-300 is replaced by 1e-300 before the square root, so a matrix that is not positive definite is not reported. Inactive nodes get a unit diagonal. The bit operations in this function act on degree-of-freedom indexes only.
mg.js
lines 194 to 230
PartCraft 0.8.1
/** Banded Cholesky of a level (explicit element matrices, or the implicit level 0 when it is the only one). */
function bandedCholesky(L, model0) {
// renumber nodes with the longest axis slowest, so the band is the product of the two shortest
const N = [L.nx + 1, L.ny + 1, L.nz + 1], ax = [0, 1, 2].sort((a, b) => N[a] - N[b]);
const perm = new Int32Array(L.nn);
for (let k = 0; k < N[2]; k++) for (let j = 0; j < N[1]; j++) for (let i = 0; i < N[0]; i++) { const c = [i, j, k]; perm[i + N[0] * (j + N[1] * k)] = c[ax[0]] + N[ax[0]] * (c[ax[1]] + N[ax[1]] * c[ax[2]]); }
const n = 3 * L.nn, bw = 3 * (N[ax[0]] * N[ax[1]] + N[ax[0]] + 1) + 2;
const A = new Float64Array(n * (bw + 1)); // row i holds columns i−bw … i at A[i·(bw+1) + (j − i + bw)]
const pd = (d) => 3 * perm[(d / 3) | 0] + (d % 3);
const add = (i, j, v) => { i = pd(i); j = pd(j); if (j > i) return; A[i * (bw + 1) + (j - i + bw)] += v; };
if (L.kind === 'explicit') {
for (let m = 0; m < L.node0.length; m++) for (let a = 0; a < 24; a++) for (let b = 0; b < 24; b++) {
const da = 3 * (L.node0[m] + L.off[(a / 3) | 0]) + (a % 3), db = 3 * (L.node0[m] + L.off[(b / 3) | 0]) + (b % 3);
add(da, db, L.K[576 * m + a * 24 + b]);
}
} else {
const { node0, off, Ke, kScale } = model0;
for (let m = 0; m < node0.length; m++) {
const s = (L.scale ? L.scale[m] : 1) * kScale;
for (let a = 0; a < 24; a++) for (let b = 0; b < 24; b++) add(3 * (node0[m] + off[(a / 3) | 0]) + (a % 3), 3 * (node0[m] + off[(b / 3) | 0]) + (b % 3), s * Ke[a * 24 + b]);
}
L.consNodes.forEach((nd, k) => { for (let a = 0; a < 3; a++) for (let b = 0; b < 3; b++) add(3 * nd + a, 3 * nd + b, L.pen[9 * k + a * 3 + b]); });
}
for (let nd = 0; nd < L.nn; nd++) if (!L.active[nd]) for (let a = 0; a < 3; a++) A[(3 * perm[nd] + a) * (bw + 1) + bw] = 1;
for (let i = 0; i < n; i++) {
const ri = i * (bw + 1);
for (let j = Math.max(0, i - bw); j <= i; j++) {
const rj = j * (bw + 1);
let s = A[ri + (j - i + bw)];
const k0 = Math.max(0, i - bw, j - bw);
for (let k = k0; k < j; k++) s -= A[ri + (k - i + bw)] * A[rj + (k - j + bw)];
if (j === i) A[ri + bw] = Math.sqrt(s > 1e-300 ? s : 1e-300);
else A[ri + (j - i + bw)] = s / A[rj + bw];
}
}
return { A, n, bw, perm, y: new Float64Array(n) };
}
Coarsest-level solve
64-bit
Forward and back substitution with the stored factor.
Two triangular sweeps over the band in 64-bit numbers, with the right-hand side permuted in and the solution permuted out.
mg.js
lines 231 to 237
PartCraft 0.8.1
function cholSolve({ A, n, bw, perm, y }, b) {
for (let nd = 0; nd < perm.length; nd++) for (let a = 0; a < 3; a++) y[3 * perm[nd] + a] = b[3 * nd + a];
const x = y;
for (let i = 0; i < n; i++) { let s = x[i]; const ri = i * (bw + 1); for (let k = Math.max(0, i - bw); k < i; k++) s -= A[ri + (k - i + bw)] * x[k]; x[i] = s / A[ri + bw]; }
for (let i = n - 1; i >= 0; i--) { x[i] /= A[i * (bw + 1) + bw]; const ri = i * (bw + 1); const xi = x[i]; for (let k = Math.max(0, i - bw); k < i; k++) x[k] -= A[ri + (k - i + bw)] * xi; }
for (let nd = 0; nd < perm.length; nd++) for (let a = 0; a < 3; a++) b[3 * nd + a] = y[3 * perm[nd] + a];
}
In detail
Method
Voxel grid coarsened by two per level; coarse element matrices as Galerkin products Rᵀ K R with trilinear interpolation; damped block Jacobi smoother (ω 0.6, one sweep before and after); banded Cholesky on the coarsest level; one symmetric V-cycle per call.
Number types
Float64Array for matrices and vectors; Int32Array and Uint8Array for indexes and flags. Interpolation weights are 1, 0.5, 0.25 and 0.125, all exact in binary.
Tolerances
Coarsening stops at about 1500 nodes. A 3 × 3 block with zero determinant, or an inactive node, gets a zero inverse. A Cholesky pivot not above 1e-300 is replaced by 1e-300.
Limits
Replacing a non-positive pivot hides a matrix that is not positive definite instead of reporting it.
Approach: 64-bit, stated limit
The arithmetic is 64-bit, but the method or the data sets the accuracy: a solver tolerance, a mesh, a step size, a datum shift, a table. The limit is stated where the result is shown.