Resources Numerical accuracy Calculation policy

CraftScript file readers and writers

Turns STL, OBJ, CSV, JSON, NPY and HDF5 files into script values and back, and writes a plot's data as CSV.

How the precision is kept

Inside a script every number is a 64-bit value. Text formats (OBJ, CSV, JSON) are written with JavaScript's shortest text that reads back to the same 64-bit number. Binary STL stores 32-bit numbers by its own definition, so an STL written or read here carries 32-bit coordinates. NPY and HDF5 tensors are written as 64-bit; 64-bit integers read from those files are converted to 64-bit floating point.

Open item. Say in the STL save path that coordinates are reduced to 32-bit by the format, and reject or warn on 64-bit integers above 2^53 instead of rounding them silently.

Calculations under this policy

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

cs-lib-io.js PartCraft 0.8.1 64-bit 12 calculations Reviewed

Tests tools/partcraft-test/test-craftscript-b.mjs

Read an STL file

64-bit

Reads the triangles of a binary or text STL file into a model's node coordinates.

A binary file's coordinates are read with getFloat32 and widened into a Float64Array, so they carry the 32-bit precision of the format. A text file's coordinates are parsed with the unary plus operator into 64-bit numbers. Every value is checked to be finite.

  • cs-lib-io.js
  • lines 157 to 178
  • PartCraft 0.8.1
export function readStl(bytes, name = 'model.stl') {
  if (bytes.length < 15) throw err(t('script.io.stl_short', { name }));
  const head = dec.decode(bytes.subarray(0, Math.min(bytes.length, 512)));
  const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
  const nb = bytes.length >= 84 ? dv.getUint32(80, true) : -1;
  let P;
  if (bytes.length >= 84 && 84 + 50 * nb === bytes.length) {
    if (nb > MAX_TRIS) throw err(t('script.io.too_many_tris', { name }));
    P = new Float64Array(9 * nb);
    for (let t = 0; t < nb; t++) for (let k = 0; k < 9; k++) P[9 * t + k] = dv.getFloat32(84 + 50 * t + 12 + 4 * k, true);
  } else if (/^\s*solid/i.test(head)) {
    const text = dec.decode(bytes), out = [], re = /vertex\s+(\S+)\s+(\S+)\s+(\S+)/gi;
    let m;
    while ((m = re.exec(text))) { out.push(+m[1], +m[2], +m[3]); if (out.length > 9 * MAX_TRIS) throw err(t('script.io.too_many_tris', { name })); }
    if (out.length % 9) throw err(t('script.io.stl_facet', { name }));
    P = Float64Array.from(out);
  } else throw err(t('script.io.stl_bad', { name }));
  for (const v of P) if (!Number.isFinite(v)) throw err(t('script.io.vertex_finite', { name }));
  if (!P.length) throw err(t('script.io.no_tris', { name }));
  const w = weld(P);
  return { t: 'model', source: 'file', ref: name, mesh: meshValue('tri', w.nodes, w.elems) };
}

Weld STL vertices

Exact

Merges the repeated corners of an STL triangle list into shared nodes.

Two corners merge only when the text of their three coordinates is identical, which means the three 64-bit numbers are equal. There is no distance tolerance, so corners that differ in the last bit stay separate nodes.

  • cs-lib-io.js
  • lines 146 to 156
  • PartCraft 0.8.1
/** Weld a triangle soup by exact coordinates. */
function weld(P) {
  const map = new Map(), nodes = [], idx = new Int32Array(P.length / 3);
  for (let v = 0; v < P.length / 3; v++) {
    const x = P[3 * v], y = P[3 * v + 1], z = P[3 * v + 2], k = x + ',' + y + ',' + z;
    let id = map.get(k);
    if (id === undefined) { id = nodes.length / 3; map.set(k, id); nodes.push(x, y, z); }
    idx[v] = id;
  }
  return { nodes: Float64Array.from(nodes), elems: idx };
}

Write a binary STL file

64-bit

Writes a triangle surface with a unit normal per triangle.

n = (b − a) × (c − a) ÷ |(b − a) × (c − a)|

The normal is computed in 64-bit from the 64-bit node coordinates; a zero-length normal is divided by 1 instead of 0. Normals and coordinates are then stored with setFloat32, because binary STL holds 32-bit numbers. The saved coordinates therefore keep about 7 significant digits.

  • cs-lib-io.js
  • lines 200 to 214
  • PartCraft 0.8.1
export function writeStl(v, name = 'model') {
  const m = triOf(v, 'save .stl'),   // i18n-ignore: a command name
    nt = m.elems.length / 3, out = new Uint8Array(84 + 50 * nt), dv = new DataView(out.buffer);
  out.set(enc.encode(('CraftScript ' + name).slice(0, 80)));
  dv.setUint32(80, nt, true);
  const X = m.nodes;
  for (let t = 0; t < nt; t++) {
    const a = 3 * m.elems[3 * t], b = 3 * m.elems[3 * t + 1], c = 3 * m.elems[3 * t + 2];
    const ux = X[b] - X[a], uy = X[b + 1] - X[a + 1], uz = X[b + 2] - X[a + 2], vx = X[c] - X[a], vy = X[c + 1] - X[a + 1], vz = X[c + 2] - X[a + 2];
    let nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx; const l = Math.hypot(nx, ny, nz) || 1; nx /= l; ny /= l; nz /= l;
    const o = 84 + 50 * t;
    [nx, ny, nz, X[a], X[a + 1], X[a + 2], X[b], X[b + 1], X[b + 2], X[c], X[c + 1], X[c + 2]].forEach((x, k) => dv.setFloat32(o + 4 * k, x, true));
  }
  return out;
}

Write an OBJ file

64-bit

Writes node coordinates and triangle corner numbers as text.

Each coordinate is written by a template string, which gives the shortest decimal text that reads back to the same 64-bit number. Nothing is rounded.

  • cs-lib-io.js
  • lines 215 to 221
  • PartCraft 0.8.1
export function writeObj(v) {
  const m = triOf(v, 'save .obj'),   // i18n-ignore: a command name
    lines = ['# CraftScript'];
  for (let i = 0; i < m.nodes.length; i += 3) lines.push(`v ${m.nodes[i]} ${m.nodes[i + 1]} ${m.nodes[i + 2]}`);
  for (let t = 0; t < m.elems.length; t += 3) lines.push(`f ${m.elems[t] + 1} ${m.elems[t + 1] + 1} ${m.elems[t + 2] + 1}`);
  return enc.encode(lines.join('\n') + '\n');
}

Read an OBJ file

64-bit

Reads vertex lines and face lines into node coordinates and triangles.

Coordinates are parsed with the unary plus operator into 64-bit numbers and checked to be finite. Faces with more than three corners are split as a fan from the first corner, without a check that the face is convex or flat.

  • cs-lib-io.js
  • lines 179 to 194
  • PartCraft 0.8.1
export function readObj(bytes, name = 'model.obj') {
  const text = dec.decode(bytes), V = [], I = [];
  for (const raw of text.split(/\r?\n/)) {
    const line = raw.trim();
    if (line.startsWith('v ')) { const p = line.split(/\s+/); V.push(+p[1], +p[2], +p[3]); }
    else if (line.startsWith('f ')) {
      const nv = V.length / 3, ids = line.split(/\s+/).slice(1).map((s) => { const k = parseInt(s.split('/')[0], 10); return k < 0 ? nv + k : k - 1; });
      for (const k of ids) if (!(Number.isInteger(k) && k >= 0 && k < nv)) throw err(t('script.io.obj_face', { name }));
      for (let j = 1; j + 1 < ids.length; j++) I.push(ids[0], ids[j], ids[j + 1]);
      if (I.length > 3 * MAX_TRIS) throw err(t('script.io.too_many_tris', { name }));
    }
  }
  for (const v of V) if (!Number.isFinite(v)) throw err(t('script.io.vertex_finite', { name }));
  if (!I.length) throw err(t('script.io.no_faces', { name }));
  return { t: 'model', source: 'file', ref: name, mesh: meshValue('tri', Float64Array.from(V), Int32Array.from(I)) };
}

Read a CSV file

64-bit

Reads a table of numbers into a matrix.

Each cell is converted with Number() into a Float64Array. An empty cell becomes NaN; text that is not a number, NaN or infinity stops the read with the row and column named.

  • cs-lib-io.js
  • lines 223 to 243
  • PartCraft 0.8.1
// ─────────────────────────────────────────────────────────────── CSV
export function readCsv(bytes, name = 'data.csv', { delimiter = null, header = null } = {}) {
  const text = dec.decode(bytes).replace(/^/, '');
  const lines = text.split(/\r?\n/).filter((l) => l.trim() !== '');
  if (!lines.length) return { data: mat(0, 0), headers: [] };
  const d = delimiter || (lines[0].includes('\t') ? '\t' : lines[0].includes(';') && !lines[0].includes(',') ? ';' : ',');
  const split = (l) => { const out = []; let cur = '', q = false; for (let i = 0; i < l.length; i++) { const ch = l[i]; if (q) { if (ch === '"') { if (l[i + 1] === '"') { cur += '"'; i++; } else q = false; } else cur += ch; } else if (ch === '"') q = true; else if (ch === d) { out.push(cur); cur = ''; } else cur += ch; } out.push(cur); return out.map((s) => s.trim()); };
  let rows = lines.map(split);
  const isNum = (s) => s === '' || /^nan$/i.test(s) || /^[-+]?inf(inity)?$/i.test(s) || Number.isFinite(Number(s));
  let headers = [];
  if (header === true || (header === null && rows[0].some((s) => !isNum(s)))) { headers = rows[0]; rows = rows.slice(1); }
  const nc = Math.max(headers.length, ...rows.map((r) => r.length), 0), nr = rows.length;
  if (nr * nc > 2 ** 27) throw err(t('script.io.too_many_values', { name }));
  const out = new Float64Array(nr * nc).fill(NaN);
  rows.forEach((r, i) => r.forEach((s, j) => {
    const v = s === '' ? NaN : /^[-+]?inf(inity)?$/i.test(s) ? (s.startsWith('-') ? -Infinity : Infinity) : Number(s);
    if (s !== '' && Number.isNaN(v) && !/^nan$/i.test(s)) throw err(t('script.io.csv_nan', { name, row: i + 1 + (headers.length ? 1 : 0), col: j + 1, text: s.slice(0, 20) }));
    out[i + j * nr] = v;
  }));
  return { data: mat(nr, nc, out), headers };
}

Write a CSV file

64-bit

Writes a matrix as rows of text.

Each number is written with String(), the shortest text that reads back to the same 64-bit number. NaN and the infinities are written as the words NaN, Inf and -Inf.

  • cs-lib-io.js
  • lines 244 to 252
  • PartCraft 0.8.1
export function writeCsv(v, headers = null) {
  // i18n-ignore: 'save .csv' is a command name
  const tc = asTensor(v, 'save .csv'), [n, m] = size2(tc, 'save .csv');
  const q = (s) => { s = String(s); if (/^[=+\-@]/.test(s)) s = '\'' + s; return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s; };
  const lines = [];
  if (headers && headers.length) lines.push(headers.map(q).join(','));
  for (let i = 0; i < n; i++) { const r = []; for (let j = 0; j < m; j++) { const x = tc.data[i + j * n]; r.push(Number.isNaN(x) ? 'NaN' : x === Infinity ? 'Inf' : x === -Infinity ? '-Inf' : String(x)); } lines.push(r.join(',')); }
  return enc.encode(lines.join('\n') + '\n');
}

Values to JSON

64-bit

Prepares a script value for a JSON file.

Finite numbers pass unchanged and are later written by JSON.stringify with round-trip digits. Non-finite numbers become the strings NaN, Infinity and -Infinity. An element of a 64-bit integer array is converted with Number(), which rounds above 2^53.

  • cs-lib-io.js
  • lines 256 to 271
  • PartCraft 0.8.1
/** A value → JSON-safe plain data (§2 shapes with arrays for typed arrays; non-finite numbers as strings). */
export function toJsonValue(v, depth = 0) {
  if (depth > 32) throw err(t('script.io.json_deep_save'));
  if (typeof v === 'number') return finite(v);
  if (typeof v === 'boolean' || typeof v === 'string') return v;
  if (v === null || v === undefined) return null;
  if (ArrayBuffer.isView(v)) return Array.from(v, (x) => (typeof x === 'bigint' ? Number(x) : finite(x)));
  if (Array.isArray(v)) return v.map((x) => toJsonValue(x, depth + 1));
  if (typeof v === 'object') {
    if (v.t === 'func') throw err(t('script.io.save_func'));
    const o = {};
    for (const [k, x] of Object.entries(v)) if (typeof x !== 'function' && typeof x !== 'symbol') o[k] = toJsonValue(x, depth + 1);
    return o;
  }
  throw err(t('script.io.json_cannot', { type: typeof v }));
}

Read an NPY file

64-bit

Reads a NumPy array file into a tensor.

Every element type is copied into a Float64Array. Type f4 values keep the 32-bit precision they were stored with. Types i8 and u8 are read as BigInt and converted with Number(), which rounds integers above 2^53.

  • cs-lib-io.js
  • lines 328 to 351
  • PartCraft 0.8.1
export function readNpy(bytes, name = 'data.npy') {
  if (bytes.length < 10 || bytes[0] !== 0x93 || dec.decode(bytes.subarray(1, 6)) !== 'NUMPY') throw err(t('script.io.npy_not', { name }));
  const major = bytes[6], dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
  const hl = major === 1 ? dv.getUint16(8, true) : dv.getUint32(8, true), h0 = major === 1 ? 10 : 12;
  if (h0 + hl > bytes.length) throw err(t('script.io.npy_header_end', { name }));
  const header = dec.decode(bytes.subarray(h0, h0 + hl));
  const descr = /'descr'\s*:\s*'([<>|=])([a-z]\d+)'/.exec(header), fo = /'fortran_order'\s*:\s*(True|False)/.exec(header), sh = /'shape'\s*:\s*\(([^)]*)\)/.exec(header);
  if (!descr || !fo || !sh) throw err(t('script.io.npy_header', { name }));
  const T = NPY_TYPES[descr[2]];
  if (!T) throw err(t('script.io.npy_type', { name, type: descr[2] }));
  const shape = sh[1].split(',').map((s) => s.trim()).filter(Boolean).map(Number);
  if (!shape.every((s) => Number.isInteger(s) && s >= 0)) throw err(t('script.io.npy_shape', { name }));
  const n = shape.reduce((a, b) => a * b, 1), size = T[1], off = h0 + hl;
  if (n > 2 ** 27) throw err(t('script.io.too_many_values', { name }));
  if (off + n * size > bytes.length) throw err(t('script.io.npy_short', { name }));
  const little = descr[1] !== '>';
  const data = new Float64Array(n);
  for (let i = 0; i < n; i++) { const x = dv[T[2]](off + i * size, little); data[i] = typeof x === 'bigint' ? Number(x) : x; }
  const s = shapeOut(shape);
  const out = fo[1] === 'True' ? data : cToF(data, shape.length ? shape : [1]);
  const tn = tensor(s, out);
  if (descr[2] === 'b1') tn.dtype = 'u8', tn.data = Uint8Array.from(out);
  return tn;
}

Write an NPY file

64-bit

Writes a tensor as a NumPy array file.

The file is always type '<f8': each element is written with setFloat64, bit for bit, in column-major order with fortran_order True.

  • cs-lib-io.js
  • lines 352 to 365
  • PartCraft 0.8.1
export function writeNpy(v) {
  const t = asTensor(v, 'save .npy');   // i18n-ignore: a command name
  const shape = t.shape.length === 2 && t.shape[1] === 1 ? [t.shape[0], 1] : t.shape;
  let header = `{'descr': '<f8', 'fortran_order': True, 'shape': (${shape.join(', ')}${shape.length === 1 ? ',' : ''}), }`;
  const pad = 64 - ((10 + header.length + 1) % 64);
  header += ' '.repeat(pad % 64) + '\n';
  const hb = enc.encode(header), out = new Uint8Array(10 + hb.length + 8 * t.data.length);
  out.set([0x93, 0x4e, 0x55, 0x4d, 0x50, 0x59, 1, 0]);
  new DataView(out.buffer).setUint16(8, hb.length, true);
  out.set(hb, 10);
  const body = new DataView(out.buffer, 10 + hb.length);
  for (let i = 0; i < t.data.length; i++) body.setFloat64(8 * i, t.data[i], true);
  return out;
}

Write a tensor to HDF5

64-bit

Writes a tensor as an HDF5 dataset in row-major order.

A plain tensor is reordered into a new Float64Array and written as 64-bit. A tensor marked u8 or i32 is converted with Uint8Array.from or Int32Array.from, which drops any fraction and wraps values outside the integer range.

  • cs-lib-io.js
  • lines 378 to 384
  • PartCraft 0.8.1
case 'tensor': {
  const shape = v.shape.slice();
  const data = v.dtype === 'u8' ? Uint8Array.from(fToC(v.data, shape)) : v.dtype === 'i32' ? Int32Array.from(fToC(v.data, shape)) : fToC(v.data, shape);
  const d = g.create_dataset({ name, data, shape });
  if (v.dtype === 'u8') d.create_attribute('cs_type', 'logical');
  return;
}

Read an HDF5 dataset

64-bit

Reads a dataset into a number, a logical value or a tensor.

The dataset's values are copied into a Float64Array whatever their stored type; BigInt values are converted with Number(), which rounds integers above 2^53. A 32-bit float dataset keeps the precision it was stored with.

  • cs-lib-io.js
  • lines 437 to 444
  • PartCraft 0.8.1
const n = shape.reduce((a, b) => a * b, 1);
if (v.length !== n) throw err(t('script.io.h5_size'));
const f = cToF(Float64Array.from(v, toNum), shape.length ? shape : [1]);
if (!shape.length && at.cs_type === 'bool') return !!f[0];
if (!shape.length) return f[0];
const tn = tensor(shapeOut(shape), f);
if (at.cs_type === 'logical') { tn.dtype = 'u8'; tn.data = Uint8Array.from(f); }
return tn;
cs-plot-export.js PartCraft 0.8.1 64-bit 2 calculations Reviewed

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

Plot data as CSV

64-bit

Writes the x and y values of a script plot as a CSV file.

Each number is written with String(), the shortest text that reads back to the same 64-bit number. A value that is not finite is written as an empty cell. Series share one x column only when their x values are exactly equal.

  • cs-plot-export.js
  • lines 120 to 132
  • PartCraft 0.8.1
export function plotCsv(p) {
  const S = p.series;
  const same = S.every((s) => s.x.length === S[0].x.length && s.x.every((v, i) => v === S[0].x[i]));
  const rows = [];
  if (same) {
    rows.push([p.xlabel || 'x', ...S.map((s, k) => s.name || (S.length > 1 ? `y${k + 1}` : p.ylabel || 'y'))]);
    for (let i = 0; i < S[0].x.length; i++) rows.push([S[0].x[i], ...S.map((s) => s.y[i])]);
  } else {
    rows.push([t('cs.plot.csv.series'), p.xlabel || 'x', p.ylabel || 'y']);
    S.forEach((s, k) => { for (let i = 0; i < s.y.length; i++) rows.push([s.name || t('cs.plot.csv.series_n', { n: k + 1 }), s.x[i], s.y[i]]); });
  }
  return rows.map((r) => r.map(csvCell).join(',')).join('\r\n') + '\r\n';
}

One CSV cell

64-bit

Turns one value into the text of a CSV cell.

Numbers are not rounded or formatted for display; only text cells are quoted or prefixed.

  • cs-plot-export.js
  • lines 114 to 119
  • PartCraft 0.8.1
const csvCell = (v) => {
  if (typeof v === 'number') return Number.isFinite(v) ? String(v) : '';
  let s = String(v ?? '');
  if (/^[=+\-@\t\r]/.test(s)) s = "'" + s;
  return /[",\r\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
};

In detail

Method
Direct copies with a type conversion per format. STL vertices are welded by exact coordinate equality.
Number types
Float64Array for values in memory; 32-bit floats in binary STL files and in NPY files of type f4; BigInt for 64-bit integers in NPY and HDF5 files, converted with Number().
Limits
Binary STL is 32-bit. A 64-bit integer above 2^53 read from an NPY or HDF5 file is rounded to the nearest 64-bit floating point number. Reading text relies on the JavaScript number parser.

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