Resources Numerical accuracy Calculation policy

STL import and export

Reads and writes STL triangles, with a unit on import and millimeters on export.

How the precision is kept

Binary STL stores 32-bit floats, and both the reader and the writer follow that. ASCII STL is text and can carry more digits, but its coordinates are also put into a Float32Array before import. The unit factor is then applied in 64-bit and the points are kept in a Float64Array.

Open item. Keep ASCII STL coordinates in a Float64Array instead of narrowing them to 32-bit.

Calculations under this policy

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

io/stl.js ModelCraft 0.8.1 32-bit in part 6 calculations Reviewed

Tests tools/modelcraft-test/meshfiles.mjs

Reading a binary file

64-bit

Reads each triangle's nine coordinates and its color word.

The coordinates are 32-bit floats in the file and are kept as such in a Float32Array: the format's own width. A value that is not below 1e9 in size refuses the file. The stored facet normal is not read.

  • io/stl.js
  • lines 39 to 67
  • ModelCraft 0.8.1
function readBinary(b, dv, n) {
  if (n > MAX_TRIS) throw new Error(t('stl.too_big'));
  let head = '';
  for (let i = 0; i < 80; i++) head += String.fromCharCode(b[i]);
  const ci = head.indexOf('COLOR=');
  const magics = ci >= 0 && ci + 10 <= 80;
  const part = magics ? [b[ci + 6], b[ci + 7], b[ci + 8]].map((v) => srgbToLin(v / 255)) : null;
  const positions = new Float32Array(n * 9);
  const colors = new Float32Array(n * 3);
  let any = false;
  for (let i = 0; i < n; i++) {
    const o = 84 + i * 50;
    for (let k = 0; k < 9; k++) {
      const v = dv.getFloat32(o + 12 + k * 4, true);
      if (!(Math.abs(v) < 1e9)) throw new Error(t('stl.bad', { why: t('stl.why.number') }));
      positions[i * 9 + k] = v;
    }
    const a = dv.getUint16(o + 48, true);
    let rgb = null;
    if (magics) {
      if (!(a & 0x8000)) rgb = [a & 31, (a >> 5) & 31, (a >> 10) & 31];
    } else if (a & 0x8000) rgb = [(a >> 10) & 31, (a >> 5) & 31, a & 31];
    if (rgb) { any = true; for (let k = 0; k < 3; k++) colors[i * 3 + k] = srgbToLin(rgb[k] / 31); }
    else if (part) { any = true; for (let k = 0; k < 3; k++) colors[i * 3 + k] = part[k]; }
    else colors.fill(-1, i * 3, i * 3 + 3);
  }
  const name = head.replace(/\0.*$/s, '').replace(/^solid\s*/i, '').replace(/COLOR=.{4}/s, '').replace(/MATERIAL=.{12}/s, '').trim().slice(0, 80);
  return { ascii: false, name, triangles: n, positions, colors: any ? colors : null, convention: any ? (magics ? 'magics' : 'viscam') : null };
}

Reading a text file

32-bit in part

Parses every 'vertex x y z' line.

Each number is parsed to 64-bit and then the whole list is converted with Float32Array.from. A text file written with more than about 7 digits loses the rest here, before the unit factor is applied.

  • io/stl.js
  • lines 69 to 83
  • ModelCraft 0.8.1
function readAscii(b) {
  const text = new TextDecoder().decode(b);
  const name = (text.match(/^solid[ \t]*([^\r\n]*)/i)?.[1] || '').trim().slice(0, 80);
  const re = /vertex\s+(\S+)\s+(\S+)\s+(\S+)/gi;
  const pts = [];
  let m;
  while ((m = re.exec(text))) {
    const x = +m[1]; const y = +m[2]; const z = +m[3];
    if (![x, y, z].every((v) => Math.abs(v) < 1e9)) throw new Error(t('stl.bad', { why: t('stl.why.number') }));
    pts.push(x, y, z);
    if (pts.length > MAX_TRIS * 9) throw new Error(t('stl.too_big'));
  }
  if (!pts.length || pts.length % 9) throw new Error(t('stl.bad', { why: t('stl.why.short') }));
  return { ascii: true, name, triangles: pts.length / 9, positions: Float32Array.from(pts), colors: null, convention: null };
}

Unit factors

64-bit

The factor from the file's unit to meters.

mm 0.001; cm 0.01; m 1; inch 0.0254; foot 0.3048

0.0254 and 0.3048 are the exact definitions of the inch and the foot. None of the four decimal factors is an exact binary number, so a product can differ in its last bit from a division by 1000 or 100.

  • io/stl.js
  • line 23
  • ModelCraft 0.8.1
const UNIT = { mm: 0.001, cm: 0.01, m: 1, in: 0.0254, ft: 0.3048 };

Welding corners and applying the unit

64-bit

Makes one vertex for every distinct corner and scales it to meters.

same vertex when x, y and z are equal as text; stored as (x, y, z) × unit factor

Corners weld only when their 32-bit values are exactly equal; there is no distance tolerance. The multiplication by the unit factor is 64-bit.

  • io/stl.js
  • lines 93 to 96
  • ModelCraft 0.8.1
const k = P[c * 3] + ',' + P[c * 3 + 1] + ',' + P[c * 3 + 2];
let v = keyOf.get(k);
if (v === undefined) { v = pts.length / 3; keyOf.set(k, v); pts.push(P[c * 3] * s, P[c * 3 + 1] * s, P[c * 3 + 2] * s); }
fi[c] = v;

Triangles with no area

64-bit

Drops triangles whose corners lie on one line.

dropped when |(b − a) × (c − a)| < 1e-18

The limit is absolute, in square meters after the unit factor, and does not scale with the model.

  • io/stl.js
  • lines 103 to 105
  • ModelCraft 0.8.1
const ux = pts[b * 3] - pts[a * 3]; const uy = pts[b * 3 + 1] - pts[a * 3 + 1]; const uz = pts[b * 3 + 2] - pts[a * 3 + 2];
const vx = pts[c * 3] - pts[a * 3]; const vy = pts[c * 3 + 1] - pts[a * 3 + 1]; const vz = pts[c * 3 + 2] - pts[a * 3 + 2];
if (Math.hypot(uy * vz - uz * vy, uz * vx - ux * vz, ux * vy - uy * vx) < 1e-18) continue;

Exported triangles

64-bit

Writes each triangle's normal and corners in millimeters.

n = (b − a) × (c − a), made unit; corner (mm) = world corner (m) × 1000

The normal and the factor of 1000 are 64-bit; the values are then written with setFloat32, the only width binary STL has. A triangle with no area gets a zero normal, because its length falls back to 1.

  • io/stl.js
  • lines 148 to 154
  • ModelCraft 0.8.1
const [a, b, c] = tr.map((x) => x.pos);
const ux = b[0] - a[0]; const uy = b[1] - a[1]; const uz = b[2] - a[2];
const vx = c[0] - a[0]; const vy = c[1] - a[1]; const vz = c[2] - a[2];
let n = [uy * vz - uz * vy, uz * vx - ux * vz, ux * vy - uy * vx];
const l = Math.hypot(...n) || 1;
n = n.map((x) => x / l);
for (const v of [n, a.map((x) => x * 1000), b.map((x) => x * 1000), c.map((x) => x * 1000)]) { dv.setFloat32(o, v[0], true); dv.setFloat32(o + 4, v[1], true); dv.setFloat32(o + 8, v[2], true); o += 12; }

In detail

Method
Triangles welded where their corners are exactly equal; unit factor per coordinate; export in world coordinates times 1000.
Number types
Float32Array for the positions read (binary and ASCII); Float64Array for the imported mesh's points; 32-bit floats in the exported file.
Tolerances
A coordinate must be below 1e9 in size. A triangle whose edge cross product is shorter than 1e-18 (absolute, after the unit factor) is dropped.
Limits
Unit factors: 0.001 (mm), 0.01 (cm), 0.0254 (inch) and 0.3048 (foot); the last two are the exact definitions, and none of the four is an exact binary number. Exported coordinates are 32-bit millimeters: at 100 m from the origin their spacing is about 0.008 mm.

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