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.jsPartCraft 0.8.1 32-bit in part11 calculationsReviewed
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.
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.
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.
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.