Resources Numerical accuracy Calculation policy

Voxel grid of a part

Turns a part's surface triangles into the grid of cubes that the voxel stress, heat and generative studies use, and maps grid nodes to the part's faces for loads and supports.

How the precision is kept

The grid, the crossing positions and the node-to-face distances are 64-bit. A cube is solid when its center is inside the surface by a ray-parity test along +x, with the ray deliberately shifted by about 1e-7 of the cube size; this is a floating-point test, not an exact predicate. The outward normal stored for each picked node is a Float32Array, and fea.js uses that 32-bit normal as the direction of pressure and normal force loads.

Open item. Store the mean normal of picked nodes in a Float64Array so pressure and normal force directions keep 64-bit precision.

Calculations under this policy

11 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.

voxel.js PartCraft 0.8.1 32-bit in part 11 calculations Reviewed

Tests tools/partcraft-test/test-simulation.mjs

Grid size and origin

64-bit

The number of cubes along each axis and where the grid starts.

n = max(1, ceil(size ÷ h − 10⁻⁶)); origin = box center − n h ÷ 2

The 1e-6 inside the ceiling keeps a box that is a whole number of cubes from gaining one through rounding; a box up to 1e-6 of a cube larger is therefore also not given an extra cube. The grid is centered on the box.

  • voxel.js
  • lines 25 to 30
  • PartCraft 0.8.1
export function gridDims(bbox, h) {
  const size = [0, 1, 2].map((a) => bbox.max[a] - bbox.min[a]);
  const n = size.map((s) => Math.max(1, Math.ceil(s / h - 1e-6)));
  const origin = [0, 1, 2].map((a) => (bbox.min[a] + bbox.max[a]) / 2 - (n[a] * h) / 2);
  return { nx: n[0], ny: n[1], nz: n[2], origin };
}

Bounding box of the mesh

64-bit

The smallest box around the part's surface triangles.

A minimum and maximum over the vertex array, with no arithmetic. The values are those of the array handed in.

  • voxel.js
  • lines 32 to 36
  • PartCraft 0.8.1
export function meshBBox(mesh) {
  const P = mesh.positions, min = [Infinity, Infinity, Infinity], max = [-Infinity, -Infinity, -Infinity];
  for (let i = 0; i < P.length; i += 3) for (let a = 0; a < 3; a++) { if (P[i + a] < min[a]) min[a] = P[i + a]; if (P[i + a] > max[a]) max[a] = P[i + a]; }
  return { min, max };
}

Study size estimate

64-bit

The rough element count, node count and memory shown before a voxel study is meshed.

elements ≈ round(volume ÷ h³)

The counts are rounded to whole numbers and the node count uses a fixed factor of 1.3; the function's own comment calls it a rough size.

  • voxel.js
  • lines 38 to 46
  • PartCraft 0.8.1
/** Rough size of a study before voxelizing: element count from the solid volume, node count from the box. */
export function estimate(volume, bbox, h) {
  const { nx, ny, nz } = gridDims(bbox, h);
  const elements = Math.round(volume / (h * h * h));
  const nodes = Math.min((nx + 1) * (ny + 1) * (nz + 1), Math.round(elements * 1.3 + 2 * (nx * ny + ny * nz + nz * nx)));
  // solver memory: ~8 vectors of 3 doubles per grid node + per-element data
  const bytes = (nx + 1) * (ny + 1) * (nz + 1) * 3 * 8 * 8 + nx * ny * nz * 13;
  return { elements, nodes, nx, ny, nz, bytes };
}

Where a row's ray crosses the surface

64-bit

The x positions at which the ray through a row of cube centers enters and leaves the part.

x = (w₀ xₐ + w₁ x_b + w₂ x_c) ÷ (w₀ + w₁ + w₂), w from the y/z projection

The ray is moved off the cube centers by about 1e-7 × h so it does not run through an edge or vertex. A triangle counts only when all three projected weights have the same strict sign; the crossing is their weighted mean of the corner x values. An odd count is retried with two other offsets.

  • voxel.js
  • lines 71 to 82
  • PartCraft 0.8.1
const y = origin[1] + (j + 0.5 + PERTURB[attempt][0]) * h, z = origin[2] + (k + 0.5 + PERTURB[attempt][1]) * h;
xs.length = 0;
for (const t of tris) {
  const a = I[t * 3] * 3, b = I[t * 3 + 1] * 3, c = I[t * 3 + 2] * 3;
  const ay = P[a + 1] - y, az = P[a + 2] - z, by = P[b + 1] - y, bz = P[b + 2] - z, cy = P[c + 1] - y, cz = P[c + 2] - z;
  const w0 = by * cz - bz * cy, w1 = cy * az - cz * ay, w2 = ay * bz - az * by;   // barycentric (×2 area) in the y/z projection
  if ((w0 > 0 && w1 > 0 && w2 > 0) || (w0 < 0 && w1 < 0 && w2 < 0)) {
    const s = w0 + w1 + w2;
    xs.push((w0 * P[a] + w1 * P[b] + w2 * P[c]) / s);
  }
}
ok = xs.length % 2 === 0;

Cubes between two crossings

64-bit

Marks the cubes of a row whose centers lie between an entry and an exit crossing.

i from ceil((x_in − origin) ÷ h − 0.5) to floor((x_out − origin) ÷ h − 0.5)

A center exactly on a crossing is decided by the rounding of one division; there is no tolerance. The cube count is an integer sum.

  • voxel.js
  • line 88
  • PartCraft 0.8.1
const i0 = Math.max(0, Math.ceil((xs[m] - origin[0]) / h - 0.5)), i1 = Math.min(nx - 1, Math.floor((xs[m + 1] - origin[0]) / h - 0.5));

Position of a grid node

64-bit

The coordinates of a grid node from its index.

p = origin + (i, j, k) × h

Each coordinate is one multiplication and one addition from the index, so positions do not accumulate error across the grid.

  • voxel.js
  • lines 104 to 107
  • PartCraft 0.8.1
export const nodeXYZ = (g, n) => {
  const NX = g.nx + 1, NY = g.ny + 1, i = n % NX, j = Math.floor(n / NX) % NY, k = Math.floor(n / (NX * NY));
  return [g.origin[0] + i * g.h, g.origin[1] + j * g.h, g.origin[2] + k * g.h];
};

Distance from a point to a triangle

64-bit

The squared distance from a grid node or cube center to a surface triangle.

Region tests on dot products choose vertex, edge or interior; the edge and interior cases use one division each. The squared distance is returned and compared, so no square root enters the comparisons.

  • voxel.js
  • lines 138 to 161
  • PartCraft 0.8.1
/** Closest point on triangle abc to p (Ericson, Real-Time Collision Detection 5.1.5) → squared distance. */
function triDist2(p, P, a, b, c) {
  const ax = P[a], ay = P[a + 1], az = P[a + 2];
  const abx = P[b] - ax, aby = P[b + 1] - ay, abz = P[b + 2] - az, acx = P[c] - ax, acy = P[c + 1] - ay, acz = P[c + 2] - az;
  const apx = p[0] - ax, apy = p[1] - ay, apz = p[2] - az;
  const d1 = abx * apx + aby * apy + abz * apz, d2 = acx * apx + acy * apy + acz * apz;
  let qx, qy, qz;
  const set = (u, v) => { qx = ax + abx * u + acx * v; qy = ay + aby * u + acy * v; qz = az + abz * u + acz * v; };
  if (d1 <= 0 && d2 <= 0) set(0, 0);
  else {
    const bpx = p[0] - P[b], bpy = p[1] - P[b + 1], bpz = p[2] - P[b + 2];
    const d3 = abx * bpx + aby * bpy + abz * bpz, d4 = acx * bpx + acy * bpy + acz * bpz;
    const cpx = p[0] - P[c], cpy = p[1] - P[c + 1], cpz = p[2] - P[c + 2];
    const d5 = abx * cpx + aby * cpy + abz * cpz, d6 = acx * cpx + acy * cpy + acz * cpz;
    const vc = d1 * d4 - d3 * d2, vb = d5 * d2 - d1 * d6, va = d3 * d6 - d5 * d4;
    if (d3 >= 0 && d4 <= d3) set(1, 0);
    else if (d6 >= 0 && d5 <= d6) set(0, 1);
    else if (vc <= 0 && d1 >= 0 && d3 <= 0) set(d1 / (d1 - d3), 0);
    else if (vb <= 0 && d2 >= 0 && d6 <= 0) set(0, d2 / (d2 - d6));
    else if (va <= 0 && d4 - d3 >= 0 && d5 - d6 >= 0) { const w = (d4 - d3) / ((d4 - d3) + (d5 - d6)); set(1 - w, w); }
    else { const den = 1 / (va + vb + vc); set(vb * den, vc * den); }
  }
  return (p[0] - qx) ** 2 + (p[1] - qy) ** 2 + (p[2] - qz) ** 2;
}

Faces nearest a point

64-bit

Which of the part's faces a grid node lies on, with each face's normal there.

faces with d² ≤ max(d_min² + (10⁻³ h)², (d_min + 10⁻³ h)²)

Every face whose nearest triangle is within 1e-3 × h of the closest one is kept, so a node on an edge belongs to both faces. Distances are Float64Array and normals are 64-bit unit vectors from the triangle's cross product (length guarded by 1 when zero).

  • voxel.js
  • lines 180 to 211
  • PartCraft 0.8.1
/** For each point: the nearest faces (ties within TIE·h) within maxDist → { faces: [[fi...]], normals: [[[nx,ny,nz] per face]], dist }
 *  (each face's normal is that of its own nearest triangle, outward by the mesh's winding). */
export function nearestFaces(mesh, points, h, maxDist) {
  const P = mesh.positions, I = mesh.indices, tf = mesh.triFace;
  const hash = triangleHash(mesh, Math.max(2 * h, maxDist), maxDist);
  const out = { faces: [], normals: [], dist: new Float64Array(points.length) };
  const tie2 = (TIE * h) ** 2;
  const triN = (t) => {
    const a = I[t * 3] * 3, b = I[t * 3 + 1] * 3, c = I[t * 3 + 2] * 3;
    const u = [P[b] - P[a], P[b + 1] - P[a + 1], P[b + 2] - P[a + 2]], w = [P[c] - P[a], P[c + 1] - P[a + 1], P[c + 2] - P[a + 2]];
    const n = [u[1] * w[2] - u[2] * w[1], u[2] * w[0] - u[0] * w[2], u[0] * w[1] - u[1] * w[0]], l = Math.hypot(...n) || 1;
    return [n[0] / l, n[1] / l, n[2] / l];
  };
  points.forEach((p, idx) => {
    let best = Infinity;
    const cand = hash.near(p), d2s = new Float64Array(cand.length);
    cand.forEach((t, m) => { const d2 = triDist2(p, P, I[t * 3] * 3, I[t * 3 + 1] * 3, I[t * 3 + 2] * 3); d2s[m] = d2; if (d2 < best) best = d2; });
    const fs = [], ns = [], fd = [];
    if (best <= maxDist * maxDist) {
      const lim = Math.max(best + tie2, (Math.sqrt(best) + TIE * h) ** 2);
      cand.forEach((t, m) => {
        if (d2s[m] > lim) return;
        const f = tf ? tf[t] : 0; if (f < 0) return;
        const k = fs.indexOf(f);
        if (k < 0) { fs.push(f); ns.push(triN(t)); fd.push(d2s[m]); } else if (d2s[m] < fd[k]) { ns[k] = triN(t); fd[k] = d2s[m]; }
      });
    }
    out.faces.push(fs); out.normals.push(ns);
    out.dist[idx] = Math.sqrt(best);
  });
  return out;
}

Tributary area of surface nodes

64-bit

The share of a picked face's area carried by each grid node, used to spread loads.

each exposed cube face with all four corners on the picked faces gives h² ÷ 4 to each corner; area = Σ h²

Weights and the area are sums of the same value h² ÷ 4 or h², so they are exact multiples up to the rounding of repeated addition. The area is the staircase area, not the true face area.

  • voxel.js
  • lines 245 to 246
  • PartCraft 0.8.1
for (let c = 0; c < 4; c++) w.set(s.quads[q + c], (w.get(s.quads[q + c]) || 0) + g.h * g.h / 4);
area += g.h * g.h;

Mean outward normal at picked nodes

32-bit in part

The direction used for pressure and normal force at each node of a picked face.

n = Σ face normals ÷ |Σ face normals|

The normalized mean is stored in a Float32Array. fea.js boundaryConditions reads this array as the load direction of every pressure and every force without a given direction, so those nodal loads and their reported total carry 32-bit precision in direction.

  • voxel.js
  • lines 234 to 238
  • PartCraft 0.8.1
const pack = (nodes, weight, normals, area, quadArea) => {
  const normal = new Float32Array(nodes.length * 3);
  normals.forEach((ns, i) => { const s = [0, 0, 0]; for (const n of ns) for (let a = 0; a < 3; a++) s[a] += n[a]; const l = Math.hypot(...s) || 1; for (let a = 0; a < 3; a++) normal[i * 3 + a] = s[a] / l; });
  return { nodes: Int32Array.from(nodes), weight: Float64Array.from(weight), normal, normals, area, quadArea };
};

Cubes near picked faces

64-bit

Marks the cubes whose centers are within a distance of picked faces, for preserve and obstacle regions.

kept when the distance from the cube center to the faces' triangles ≤ dist

Candidates are limited to the faces' bounding box grown by the distance; the decision is the 64-bit squared-distance comparison in nearestFaces, with no tolerance beyond the given distance.

  • voxel.js
  • lines 282 to 298
  • PartCraft 0.8.1
/** Elements whose centre is within dist of the given faces' triangles (preserve / obstacle regions). */
export function elementsNearFaces(g, mesh, faceIdx, dist) {
  const want = new Set(faceIdx);
  const sub = { positions: mesh.positions, indices: [], triFace: [] };
  for (let t = 0; t < mesh.indices.length / 3; t++) if (want.has(mesh.triFace ? mesh.triFace[t] : 0)) { sub.indices.push(mesh.indices[t * 3], mesh.indices[t * 3 + 1], mesh.indices[t * 3 + 2]); sub.triFace.push(0); }
  const out = new Uint8Array(g.nx * g.ny * g.nz);
  if (!sub.indices.length) return out;
  const bb = { min: [Infinity, Infinity, Infinity], max: [-Infinity, -Infinity, -Infinity] };
  for (const v of sub.indices) for (let a = 0; a < 3; a++) { bb.min[a] = Math.min(bb.min[a], mesh.positions[v * 3 + a]); bb.max[a] = Math.max(bb.max[a], mesh.positions[v * 3 + a]); }
  const dims = [g.nx, g.ny, g.nz];
  const lo = [0, 1, 2].map((a) => Math.max(0, Math.floor((bb.min[a] - dist - g.origin[a]) / g.h - 0.5))), hi = [0, 1, 2].map((a) => Math.min(dims[a] - 1, Math.ceil((bb.max[a] + dist - g.origin[a]) / g.h - 0.5)));
  const cand = [];
  for (let k = lo[2]; k <= hi[2]; k++) for (let j = lo[1]; j <= hi[1]; j++) for (let i = lo[0]; i <= hi[0]; i++) cand.push(i + g.nx * (j + g.ny * k));
  const nf = nearestFaces(sub, cand.map((e) => elemCentre(g, e)), g.h, dist);
  cand.forEach((e, i) => { if (nf.faces[i].length) out[e] = 1; });
  return out;
}

In detail

Method
Ray parity along +x through each cube center with triangles binned by row; closest point on a triangle after Ericson 5.1.5 (cited in the code); nearest faces through a spatial hash; tributary areas from the exposed cube faces.
Number types
64-bit numbers; Float64Array for distances and weights; Uint8Array for solid cubes; Int32Array for node lists; Float32Array for the mean normal at each picked node. The tessellation's vertex array is read as handed in.
Tolerances
Grid count per axis: ceil(size ÷ h − 1e-6). Ray offsets of 1.2e-7 to 5.8e-7 × h, three attempts when a row's crossing count is odd; a row still odd drops its last crossing and is counted. Faces within 1e-3 × h of the nearest one are all assigned to a node; a node is mapped only when the nearest face is within h. Box design space: nodes within h ÷ 2 + 1e-9 of the faces.
Limits
The voxel surface is a staircase; the file header puts it at most about h √3 ÷ 2 from the true surface. The size estimate before meshing is a rounded count.

Approach: 32-bit step, flagged

One step stores or exchanges 32-bit values that reach a number a person reads. It is listed here with its open item, and the step is either moved to 64-bit or the app will warn where the number is read.

Every policy that takes this approach