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