Resources Numerical accuracy Calculation policy

Hull surfaces to a closed solid and its volume

Joins NURBS patches into one closed triangle mesh, mirrors and caps it, and works out its volume.

How the precision is kept

Patches are sampled at (n + 1) × (n + 1) parameter points (n = 24 by default) through cc-nurbs in 64-bit and kept in a Float64Array. The mesh, and so the volume, follows the surfaces only as closely as the sampling allows. Corners closer than the weld distance are merged.

Calculations under this policy

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

hull/solid.js PartCraft 0.8.1 64-bit 5 calculations Reviewed

Tests tools/hull/test-solid.mjs

Sampling a patch

64-bit

The parameter values at which each surface is evaluated.

uᵢ = u₀ + (u₁ − u₀) × i ÷ N, N from 2 to 200

The same fractions are used on every patch, so patches that share an edge and are parametrized alike give identical points there. Points come from cc-nurbs tessellate as 64-bit positions.

  • hull/solid.js
  • lines 29 to 31
  • PartCraft 0.8.1
const s = it.surface, [u0, u1] = N.surface.domainU(s), [v0, v1] = N.surface.domainV(s);
const up = Array.from({ length: N1 + 1 }, (_, i) => u0 + (u1 - u0) * i / N1), vp = Array.from({ length: N1 + 1 }, (_, i) => v0 + (v1 - v0) * i / N1);
const t = N.surface.tessellate(s, { uParams: up, vParams: vp });

Mirror half

64-bit

The other half of a hull drawn to the centerline.

p′ = 2 × at − p on the mirror axis

One multiplication and one subtraction on the mirror axis; the other two coordinates are copied.

  • hull/solid.js
  • lines 41 to 42
  • PartCraft 0.8.1
const k = AX[mirror], pts = raw.length / 3;
for (let i = 0; i < pts; i++) { const p = [raw[i * 3], raw[i * 3 + 1], raw[i * 3 + 2]]; p[k] = 2 * at - p[k]; raw.push(...p); }

Welding corners

64-bit

Merges corners of neighboring patches that lie within the weld distance.

merge when √(Δx² + Δy² + Δz²) ≤ eps

A corner joins the first earlier corner within eps by Math.hypot, searched in the 27 grid cells around it. Which corner is kept depends on the order of the points; positions are not averaged.

  • hull/solid.js
  • lines 51 to 58
  • PartCraft 0.8.1
const x = raw[i * 3], y = raw[i * 3 + 1], z = raw[i * 3 + 2], cx = Math.floor(x / eps), cy = Math.floor(y / eps), cz = Math.floor(z / eps);
let found = -1;
for (let dx = -1; dx <= 1 && found < 0; dx++) for (let dy = -1; dy <= 1 && found < 0; dy++) for (let dz = -1; dz <= 1 && found < 0; dz++) {
  const list = grid.get(key(cx + dx, cy + dy, cz + dz));
  if (list) for (const j of list) if (Math.hypot(pos[j * 3] - x, pos[j * 3 + 1] - y, pos[j * 3 + 2] - z) <= eps) { found = j; break; }
}
if (found < 0) { found = pos.length / 3; pos.push(x, y, z); const kk = key(cx, cy, cz); (grid.get(kk) || grid.set(kk, []).get(kk)).push(found); }
map[i] = found;

Planar opening test

64-bit

Decides whether an opening lies in a plane and can be capped.

n by Newell's sums; deviation = max |(p − centroid)·n|; cap when deviation ≤ max(4 eps, 2e-4 × size)

The loop's normal is summed over its edges and made unit, its centroid is the mean of its points, and the largest distance of any loop point from that plane is compared with the tolerance. The cap is a fan from the centroid.

  • hull/solid.js
  • lines 116 to 127
  • PartCraft 0.8.1
let nx = 0, ny = 0, nz = 0, cx = 0, cy = 0, cz = 0;
for (let i = 0; i < loop.length; i++) {
  const p = loop[i] * 3, q = loop[(i + 1) % loop.length] * 3;
  nx += (pos[p + 1] - pos[q + 1]) * (pos[p + 2] + pos[q + 2]); ny += (pos[p + 2] - pos[q + 2]) * (pos[p] + pos[q]); nz += (pos[p] - pos[q]) * (pos[p + 1] + pos[q + 1]);
  cx += pos[p]; cy += pos[p + 1]; cz += pos[p + 2];
}
const nl = Math.hypot(nx, ny, nz);
if (nl < 1e-12) continue;
nx /= nl; ny /= nl; nz /= nl; cx /= loop.length; cy /= loop.length; cz /= loop.length;
let dev = 0;
for (const v of loop) dev = Math.max(dev, Math.abs((pos[v * 3] - cx) * nx + (pos[v * 3 + 1] - cy) * ny + (pos[v * 3 + 2] - cz) * nz));
if (dev > Math.max(eps * 4, size * 2e-4)) continue;

Volume of the closed mesh

64-bit

The volume enclosed by the hull mesh.

V = Σ a·(b × c) ÷ 6

Each triangle's signed tetrahedron to the origin is divided by 6 and added in order. For a hull far from the origin the terms are large and nearly cancel. A negative total flips every triangle and its sign.

  • hull/solid.js
  • lines 135 to 140
  • PartCraft 0.8.1
let vol = 0;
for (const [a, b, c] of T) {
  const A = a * 3, B = b * 3, C = c * 3;
  vol += (pos[A] * (pos[B + 1] * pos[C + 2] - pos[B + 2] * pos[C + 1]) - pos[A + 1] * (pos[B] * pos[C + 2] - pos[B + 2] * pos[C]) + pos[A + 2] * (pos[B] * pos[C + 1] - pos[B + 1] * pos[C])) / 6;
}
if (vol < 0) { T = T.map((t) => [t[0], t[2], t[1]]); vol = -vol; }

In detail

Method
Uniform parameter sampling; weld on a grid of cell size eps, searching 27 cells; one winding by flood fill; planar openings closed by a centroid fan after a Newell-normal planarity test; volume by signed tetrahedra to the origin.
Number types
64-bit numbers; Float64Array positions; Uint32Array indices.
Tolerances
Weld distance 1e-4 × the set's diagonal unless given. Planarity: largest deviation ≤ max(4 × weld distance, 2e-4 × diagonal). A loop whose Newell normal is shorter than 1e-12 is skipped.
Limits
The volume is that of the triangle mesh, not of the surfaces themselves. A welded corner takes the position of the first corner found within the weld distance.

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.

Every policy that takes this approach