Resources Numerical accuracy Calculation policy

Generated part exports: data sheet, VTK, 3MF and OpenVDB

Writes a generated part's field and surface to VTK, 3MF and OpenVDB files and its values to a data sheet.

How the precision is kept

The distance field is written as 32-bit floats, which is what the grid holds and what an OpenVDB FloatGrid is. The VTU surface converts positions to a Float32Array, then computes each triangle's area and normal from them and stores those in Float32Array too. Text numbers in the 3MF and VTI files are written with 12 significant digits; the PDF data sheet prints values with 6.

Open item. Compute the VTU areas and normals from 64-bit positions and write them as Float64, and write text numbers with 17 significant digits.

Calculations under this policy

8 calculations in 2 source files. 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.

field/export.js PartCraft 0.8.1 32-bit in part 5 calculations Reviewed

Tests tools/field-test/export.mjs

Number format of the text exports

64-bit

How a number is written into the 3MF and VTI text.

toPrecision(12), then back through a number to drop trailing zeros. A value that is not finite is written as 0.

  • field/export.js
  • line 46
  • PartCraft 0.8.1
const num = (x) => (Number.isFinite(x) ? String(+x.toPrecision(12)) : '0');

Values on the PDF data sheet

64-bit

How each value is printed on the data sheet's PDF.

A numeric value is printed with toPrecision(6) and the text is cut to 15 characters. The JSON form of the same sheet keeps the full number.

  • field/export.js
  • line 82
  • PartCraft 0.8.1
const val = typeof r.value === 'number' ? String(+r.value.toPrecision(6)) : String(r.value);

Field as VTK ImageData

32-bit in part

The signed distance and an inside mask on the node lattice.

origin = lowest index × h; spacing = h; inside = value < 0

Node values are copied from the grid into a Float32Array (they are 32-bit already). The origin and spacing go through num with 12 significant digits. A box given by the caller is widened to whole nodes with floor and ceil.

  • field/export.js
  • lines 130 to 147
  • PartCraft 0.8.1
/** The field on the node lattice of `grid` over `box` [x0,y0,z0,x1,y1,z1] (default: every stored brick), distance clamped to ±band. */
export function toVTI(grid, { box = null, maxNodes = 6e7 } = {}) {
  const h = grid.h;
  let lo, hi;
  if (box) { lo = [0, 1, 2].map((a) => Math.floor(box[a] / h)); hi = [0, 1, 2].map((a) => Math.ceil(box[a + 3] / h)); }
  else { const e = grid.extent(); if (!e) throw Object.assign(new Error('export: the field is empty'), { code: 'empty' }); lo = e.lo.map((b) => b * 8); hi = e.hi.map((b) => b * 8 + 7); }
  const n = [hi[0] - lo[0] + 1, hi[1] - lo[1] + 1, hi[2] - lo[2] + 1], total = n[0] * n[1] * n[2];
  if (total > maxNodes) throw Object.assign(new Error('export: too many nodes'), { code: 'toobig', vars: { n: total } });
  const dist = new Float32Array(total), mask = new Uint8Array(total);
  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 v = grid.at(lo[0] + i, lo[1] + j, lo[2] + k), c = i + n[0] * (j + n[1] * k);
    dist[c] = v; mask[c] = v < 0 ? 1 : 0;
  }
  const ext = `0 ${n[0] - 1} 0 ${n[1] - 1} 0 ${n[2] - 1}`;
  return `<?xml version="1.0"?>\n<VTKFile type="ImageData" version="1.0" byte_order="LittleEndian" header_type="UInt32">\n` +
    `  <ImageData WholeExtent="${ext}" Origin="${num(lo[0] * h)} ${num(lo[1] * h)} ${num(lo[2] * h)}" Spacing="${num(h)} ${num(h)} ${num(h)}">\n    <Piece Extent="${ext}">\n` +
    `      <PointData Scalars="distance">\n        <DataArray type="Float32" Name="distance" format="binary">${blob(dist)}</DataArray>\n        <DataArray type="UInt8" Name="inside" format="binary">${blob(mask)}</DataArray>\n      </PointData>\n      <CellData/>\n    </Piece>\n  </ImageData>\n</VTKFile>\n`;
}

Surface as VTK UnstructuredGrid

32-bit in part

The surface triangles with each triangle's area and unit normal.

n = (b − a) × (c − a); area = |n| ÷ 2; normal = n ÷ |n|

Positions are first converted to a Float32Array. The edge vectors, cross product and its length are computed in 64-bit from those 32-bit values, and the area and unit normal are then stored in Float32Array. A zero-area triangle keeps a zero normal.

  • field/export.js
  • lines 149 to 164
  • PartCraft 0.8.1
/** Surface triangles as an UnstructuredGrid: the points, triangle cells (VTK type 5), and per cell the area and the unit normal. */
export function toVTU({ positions, indices }, { name = 'surface' } = {}) {
  const nt = indices.length / 3, nv = positions.length / 3;
  const P = Float32Array.from(positions), conn = Int32Array.from(indices), off = new Int32Array(nt), types = new Uint8Array(nt).fill(5), area = new Float32Array(nt), nrm = new Float32Array(nt * 3);
  for (let t = 0; t < nt; t++) {
    off[t] = 3 * (t + 1);
    const a = indices[t * 3] * 3, b = indices[t * 3 + 1] * 3, c = indices[t * 3 + 2] * 3;
    const ux = P[b] - P[a], uy = P[b + 1] - P[a + 1], uz = P[b + 2] - P[a + 2], vx = P[c] - P[a], vy = P[c + 1] - P[a + 1], vz = P[c + 2] - P[a + 2];
    const x = uy * vz - uz * vy, y = uz * vx - ux * vz, z = ux * vy - uy * vx, l = Math.sqrt(x * x + y * y + z * z);
    area[t] = l / 2; if (l > 0) { nrm[t * 3] = x / l; nrm[t * 3 + 1] = y / l; nrm[t * 3 + 2] = z / l; }
  }
  return `<?xml version="1.0"?>\n<VTKFile type="UnstructuredGrid" version="1.0" byte_order="LittleEndian" header_type="UInt32">\n  <UnstructuredGrid>\n` +
    `    <Piece NumberOfPoints="${nv}" NumberOfCells="${nt}">\n      <Points>\n        <DataArray type="Float32" Name="${esc(name)}" NumberOfComponents="3" format="binary">${blob(P)}</DataArray>\n      </Points>\n` +
    `      <Cells>\n        <DataArray type="Int32" Name="connectivity" format="binary">${blob(conn)}</DataArray>\n        <DataArray type="Int32" Name="offsets" format="binary">${blob(off)}</DataArray>\n        <DataArray type="UInt8" Name="types" format="binary">${blob(types)}</DataArray>\n      </Cells>\n` +
    `      <CellData Normals="normal">\n        <DataArray type="Float32" Name="area" format="binary">${blob(area)}</DataArray>\n        <DataArray type="Float32" Name="normal" NumberOfComponents="3" format="binary">${blob(nrm)}</DataArray>\n      </CellData>\n    </Piece>\n  </UnstructuredGrid>\n</VTKFile>\n`;
}

3MF vertices

64-bit

The mesh corners as text in the 3MF model, in millimeters.

Each coordinate is written with 12 significant digits, enough to reproduce a 32-bit position exactly but not every 64-bit one.

  • field/export.js
  • line 187
  • PartCraft 0.8.1
for (let i = 0; i < positions.length; i += 3) v.push(`<vertex x="${num(positions[i])}" y="${num(positions[i + 1])}" z="${num(positions[i + 2])}"/>`);
field/vdb.js PartCraft 0.8.1 32-bit in part 3 calculations Reviewed

Tests tools/field-test/vdb.mjs

OpenVDB leaves and active voxels

32-bit in part

Maps the grid's bricks onto OpenVDB leaves and marks the narrow band active.

background = fround(band × h); active when −background < value < background

The background is Math.fround(band × h). Values are copied from the Float32Array bricks into z-fastest order without change, and a voxel is active when its 32-bit value lies strictly inside the band.

  • field/vdb.js
  • lines 109 to 140
  • PartCraft 0.8.1
/** The tree: root → 32³ internal (level 2) → 16³ internal (level 1) → 8³ leaves (our bricks), every list in OpenVDB's order. */
function buildTree(grid) {
  const bg = Math.fround(grid.bw);
  const l2 = new Map();                                  // key "x,y,z" of the level-2 origin → { o:[x,y,z], kids: Map(offset → level-1) }
  let voxels = 0;
  const lo = [Infinity, Infinity, Infinity], hi = [-Infinity, -Infinity, -Infinity];
  for (const key of grid.sortedKeys()) {
    const b = grid.bricks.get(key), [bx, by, bz] = brickOf(key);
    const o2 = [bx >> 9 << 12, by >> 9 << 12, bz >> 9 << 12];                     // 512 bricks = 4096 voxels per level-2 node
    const k2 = o2.join(',');
    let n2 = l2.get(k2); if (!n2) { n2 = { o: o2, kids: new Map() }; l2.set(k2, n2); }
    const off2 = ((bx >> 4) & 31) << 10 | ((by >> 4) & 31) << 5 | ((bz >> 4) & 31);
    let n1 = n2.kids.get(off2); if (!n1) { n1 = { leaves: new Map(), inside: new Set() }; n2.kids.set(off2, n1); }
    const off1 = (bx & 15) << 8 | (by & 15) << 4 | (bz & 15);
    if (b === INSIDE) { n1.inside.add(off1); continue; }
    // reorder x-fastest → z-fastest, and mark the narrow band active
    const vals = new Float32Array(BN), mask = newMask(BN);
    for (let z = 0; z < 8; z++) for (let y = 0; y < 8; y++) for (let x = 0; x < 8; x++) {
      const v = b[x | y << 3 | z << 6], n = x << 6 | y << 3 | z;
      vals[n] = v;
      if (v < bg && v > -bg) {
        setBit(mask, n); voxels++;
        const p = [bx * 8 + x, by * 8 + y, bz * 8 + z];
        for (let a = 0; a < 3; a++) { if (p[a] < lo[a]) lo[a] = p[a]; if (p[a] > hi[a]) hi[a] = p[a]; }
      }
    }
    n1.leaves.set(off1, { o: [bx * 8, by * 8, bz * 8], vals, mask });
  }
  // the root's table is a std::map on Coord: x, then y, then z
  const roots = [...l2.values()].sort((a, b) => a.o[0] - b.o[0] || a.o[1] - b.o[1] || a.o[2] - b.o[2]);
  return { bg, roots, voxels, bbox: voxels ? { lo, hi } : null };
}

OpenVDB value compression

32-bit in part

Chooses how a node's inactive values are stored and writes the values.

Values are compared after Math.fround, as 32-bit floats, and written with setFloat32. The grid's values are 32-bit already, so nothing further is lost here.

  • field/vdb.js
  • lines 60 to 101
  • PartCraft 0.8.1
/**
 * io::writeCompressedValues with COMPRESS_ACTIVE_MASK, as Compression.h decides it: look at up to three distinct inactive values
 * (skipping child slots), pick the metadata byte, then write the inactive values it needs, the selection mask when there are two,
 * and the active values (or every value when there are more than two inactive ones). Float32 comparisons are exact, as there.
 */
function writeValues(out, vals, count, valueMask, childMask, bg) {
  const f = Math.fround, bgf = f(bg), mbg = f(-bg);
  const inactive = [bgf, bgf];
  let unique = 0;
  for (let i = 0; i < count && unique < 3; i++) {
    if (isOn(valueMask, i) || (childMask && isOn(childMask, i))) continue;
    const v = f(vals[i]);
    const seen = (unique > 0 && v === inactive[0]) || (unique > 1 && v === inactive[1]);
    if (!seen) { if (unique < 2) inactive[unique] = v; unique++; }
  }
  let meta = NO_MASK_OR_INACTIVE_VALS;
  if (unique === 1) {
    if (inactive[0] !== bgf) meta = inactive[0] === mbg ? NO_MASK_AND_MINUS_BG : NO_MASK_AND_ONE_INACTIVE_VAL;
  } else if (unique === 2) {
    if (inactive[0] !== bgf && inactive[1] !== bgf) meta = MASK_AND_TWO_INACTIVE_VALS;
    else if (inactive[1] === bgf) meta = inactive[0] === mbg ? MASK_AND_NO_INACTIVE_VALS : MASK_AND_ONE_INACTIVE_VAL;
    else if (inactive[0] === bgf) { meta = inactive[1] === mbg ? MASK_AND_NO_INACTIVE_VALS : MASK_AND_ONE_INACTIVE_VAL; const s = inactive[0]; inactive[0] = inactive[1]; inactive[1] = s; }
  } else if (unique > 2) meta = NO_MASK_AND_ALL_VALS;
  out.u8(meta);
  if (meta === NO_MASK_AND_ONE_INACTIVE_VAL || meta === MASK_AND_ONE_INACTIVE_VAL || meta === MASK_AND_TWO_INACTIVE_VALS) {
    out.f32(inactive[0]);
    if (meta === MASK_AND_TWO_INACTIVE_VALS) out.f32(inactive[1]);
  }
  if (meta === NO_MASK_AND_ALL_VALS) { out.f32s(vals, count); return; }
  const active = [];
  if (meta === NO_MASK_OR_INACTIVE_VALS || meta === NO_MASK_AND_MINUS_BG || meta === NO_MASK_AND_ONE_INACTIVE_VAL) {
    for (let i = 0; i < count; i++) if (isOn(valueMask, i)) active.push(vals[i]);
  } else {
    const sel = newMask(count);
    for (let i = 0; i < count; i++) {
      if (isOn(valueMask, i)) active.push(vals[i]);
      else if (f(vals[i]) === inactive[1]) setBit(sel, i);
    }
    out.mask(sel);
  }
  out.f32s(active, active.length);
}

OpenVDB voxel size

64-bit

The transform from lattice index to millimeters.

scale = h; also 1 ÷ h, 1 ÷ h² and 1 ÷ (2h)

The voxel size and its three derived values are written as 64-bit doubles, each three times.

  • field/vdb.js
  • lines 202 to 203
  • PartCraft 0.8.1
g.str('UniformScaleMap');                              // Transform::write: map type, then ScaleMap's five vectors
for (const v of [h, h, 1 / h, 1 / (h * h), 1 / (2 * h)]) { g.f64(v); g.f64(v); g.f64(v); }

In detail

Method
VTK XML with inline base64 binary; a 3MF Core 1.3 model in a stored zip; OpenVDB file format 224 as the file's header says OpenVDB 12.1.0 writes it (tree 5-4-3, active-mask compression only); PDF 1.4 written by hand.
Number types
Float32Array for distances, VTU points, areas and normals; 64-bit doubles for the VDB transform; decimal text with 12 significant digits for 3MF vertices and the VTI origin and spacing.
Limits
A voxel size or coordinate that needs more than 12 significant digits is rounded in the 3MF and VTI text. A number that is not finite is written as 0. The JSON data sheet keeps full values; the PDF rounds to 6 significant digits.

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