Resources Numerical accuracy Calculation policy

Part export to STL, 3MF, DXF and STEP

Writes the part's mesh, a sketch, a face profile or a flat pattern to a file.

How the precision is kept

Each format has its own number writer. DXF and STEP write 15 significant digits. 3MF writes 9 significant digits. Binary STL stores 32-bit numbers by its own definition. Splines go to DXF R12 as polylines whose chords stay within 0.01 of the curve.

Open item. Write 3MF coordinates with 15 or 17 significant digits, as the DXF and STEP writers do.

Calculations under this policy

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

export.js PartCraft 0.8.1 64-bit 9 calculations Reviewed

Tests bin/test-partcraft.mjs

Binary STL

64-bit

Writes each triangle's unit normal and three corners.

n = (v₁ − v₀) × (v₂ − v₀) ÷ |(v₁ − v₀) × (v₂ − v₀)|

The normal is computed in 64-bit after the exact Y up to Z up swap; a zero-length normal is divided by 1. Normals and coordinates are written with setFloat32 because binary STL holds 32-bit numbers, so the file keeps about 7 significant digits.

  • export.js
  • lines 20 to 40
  • PartCraft 0.8.1
export function stlBinary(mesh, name = 'part') {
  const nt = triCount(mesh);
  const buf = new ArrayBuffer(84 + nt * 50);
  const dv = new DataView(buf);
  const head = ('PartCraft binary STL ' + name).slice(0, 79);   // i18n-ignore: STL file header
  for (let i = 0; i < head.length; i++) dv.setUint8(i, head.charCodeAt(i) & 0x7f);
  dv.setUint32(80, nt, true);
  const P = mesh.positions, I = mesh.indices;
  let o = 84;
  for (let t = 0; t < nt; t++) {
    const v = [0, 1, 2].map((k) => zUp(P[I[t * 3 + k] * 3], P[I[t * 3 + k] * 3 + 1], P[I[t * 3 + k] * 3 + 2]));
    const u = [v[1][0] - v[0][0], v[1][1] - v[0][1], v[1][2] - v[0][2]], w = [v[2][0] - v[0][0], v[2][1] - v[0][1], v[2][2] - v[0][2]];
    let n = [u[1] * w[2] - u[2] * w[1], u[2] * w[0] - u[0] * w[2], u[0] * w[1] - u[1] * w[0]];
    const l = Math.hypot(...n) || 1;
    n = n.map((c) => c / l);
    for (const c of n) { dv.setFloat32(o, c, true); o += 4; }
    for (const p of v) for (const c of p) { dv.setFloat32(o, c, true); o += 4; }
    dv.setUint16(o, 0, true); o += 2;
  }
  return new Uint8Array(buf);
}

Numbers in a 3MF file

64-bit

Writes one vertex coordinate as text.

The coordinate is rounded with toPrecision(9) before it is written, so the file keeps 9 significant digits, not 15. A magnitude under 1e-12 is written as 0.

  • export.js
  • line 89
  • PartCraft 0.8.1
const num = (v) => { const r = Math.abs(v) < 1e-12 ? 0 : v; return Number(r.toPrecision(9)).toString(); };

3MF model

64-bit

Writes the mesh's vertices and triangles as a 3MF package in millimeters.

Vertices are rotated to Z up by an exact swap and sign change, then written through the 9-digit number writer. Triangle corner numbers are written as integers.

  • export.js
  • lines 91 to 105
  • PartCraft 0.8.1
export function threeMf(mesh, name = 'part') {
  const P = mesh.positions, I = mesh.indices;
  const nv = P.length / 3;
  const out = [];
  out.push('<?xml version="1.0" encoding="UTF-8"?>\n<model unit="millimeter" xml:lang="en-US" xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02">\n');
  out.push(` <metadata name="Title">${xmlEsc(name)}</metadata>\n <metadata name="Application">PartCraft</metadata>\n`);
  out.push(' <resources>\n  <object id="1" type="model" name="' + xmlEsc(name) + '">\n   <mesh>\n    <vertices>\n');
  for (let i = 0; i < nv; i++) { const [x, y, z] = zUp(P[i * 3], P[i * 3 + 1], P[i * 3 + 2]); out.push(`     <vertex x="${num(x)}" y="${num(y)}" z="${num(z)}"/>\n`); }
  out.push('    </vertices>\n    <triangles>\n');
  for (let t = 0; t < I.length / 3; t++) out.push(`     <triangle v1="${I[t * 3]}" v2="${I[t * 3 + 1]}" v3="${I[t * 3 + 2]}"/>\n`);
  out.push('    </triangles>\n   </mesh>\n  </object>\n </resources>\n <build>\n  <item objectid="1"/>\n </build>\n</model>\n');
  const types = '<?xml version="1.0" encoding="UTF-8"?>\n<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="model" ContentType="application/vnd.ms-package.3dmanufacturing-3dmodel+xml"/></Types>\n';
  const rels = '<?xml version="1.0" encoding="UTF-8"?>\n<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Target="/3D/3dmodel.model" Id="rel0" Type="http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel"/></Relationships>\n';
  return zipStored([{ name: '[Content_Types].xml', data: types }, { name: '_rels/.rels', data: rels }, { name: '3D/3dmodel.model', data: out.join('') }]);
}

Numbers in a DXF file

64-bit

Writes one coordinate, radius or angle as text.

Written with toPrecision(15) and trailing zeros removed. A magnitude under 1e-12 is written as 0.0.

  • export.js
  • lines 109 to 114
  • PartCraft 0.8.1
function dxfWriter() {
  const lines = [];
  const w = (code, v) => { lines.push(String(code).padStart(3, ' ')); lines.push(String(v)); };
  const f = (n) => (Math.abs(n) < 1e-12 ? '0.0' : Number(n.toPrecision(15)).toString());
  return { lines, w, f };
}

Sketch to DXF

64-bit

Writes a sketch's lines, circles, arcs, splines and points as DXF R12 entities.

angle in degrees = radians × 180 ÷ π, reduced to [0, 360)

Arc start and end angles are converted to degrees and reduced with the remainder operator, then written with 15 digits. A spline is not written exactly: it becomes a polyline whose chords are within 0.01 of the curve.

  • export.js
  • lines 143 to 175
  • PartCraft 0.8.1
export function dxfSketch(sk) {
  const { lines, w, f } = dxfWriter();
  dxfHead(w, f, [['0', 7], ['PROFILE', 7], ['CONSTRUCTION', 8], ['POINTS', 3]], null);
  const counts = { line: 0, arc: 0, circle: 0, point: 0, spline: 0 };
  const loose = new Set(loosePoints(sk).map((p) => p.id));
  for (const e of sk.entities) {
    const layer = e.construction ? 'CONSTRUCTION' : 'PROFILE';
    if (e.type === 'line') {
      const a = pointXY(sk, e.p1), b = pointXY(sk, e.p2);
      w(0, 'LINE'); w(8, layer); w(10, f(a[0])); w(20, f(a[1])); w(30, '0.0'); w(11, f(b[0])); w(21, f(b[1])); w(31, '0.0'); counts.line++;
    } else if (e.type === 'circle') {
      const c = pointXY(sk, e.c);
      w(0, 'CIRCLE'); w(8, layer); w(10, f(c[0])); w(20, f(c[1])); w(30, '0.0'); w(40, f(e.r)); counts.circle++;
    } else if (e.type === 'arc') {
      const { c, r, a0, sweep } = arcAngles(sk, e);
      const d0 = a0 * 180 / Math.PI, d1 = (a0 + sweep) * 180 / Math.PI;
      const norm = (d) => ((d % 360) + 360) % 360;
      w(0, 'ARC'); w(8, layer); w(10, f(c[0])); w(20, f(c[1])); w(30, '0.0'); w(40, f(r)); w(50, f(norm(d0))); w(51, f(norm(d1))); counts.arc++;
    } else if (e.type === 'spline') {
      let pts;
      try { pts = tessellateTol(splineCurve(sk, e), SPLINE_DXF_TOL); } catch { continue; }
      if (e.closed) pts.pop();
      w(0, 'POLYLINE'); w(8, layer); w(66, 1); w(10, '0.0'); w(20, '0.0'); w(30, '0.0'); w(70, e.closed ? 1 : 0);
      for (const [x, y] of pts) { w(0, 'VERTEX'); w(8, layer); w(10, f(x)); w(20, f(y)); w(30, '0.0'); }
      w(0, 'SEQEND'); w(8, layer);
      counts.spline++;
    } else if (e.type === 'point' && loose.has(e.id)) {
      w(0, 'POINT'); w(8, e.construction ? 'CONSTRUCTION' : 'POINTS'); w(10, f(e.x)); w(20, f(e.y)); w(30, '0.0'); counts.point++;
    }
  }
  w(0, 'ENDSEC'); w(0, 'EOF');
  return { text: lines.join('\r\n') + '\r\n', counts };
}

Flat pattern to DXF

64-bit

Writes a sheet metal flat pattern's outline, bend lines and bend notes.

Outline and bend line coordinates are written with 15 digits. The default bend note text shows the angle with 2 decimals and the radius with 3; the note's rotation comes from Math.atan2 of the bend line's direction.

  • export.js
  • lines 192 to 222
  • PartCraft 0.8.1
/**
 * A sheet metal flat pattern (sheet-flat.js) as DXF R12: the outline on layer OUTLINE as LINE / ARC / CIRCLE (other curves as
 * open 2D POLYLINEs), each bend's centre line on layer BEND (dashed) with a TEXT note above it on BEND_NOTES ("UP 90° R2").
 * Returns { text, counts }. labels(b) → the note (the caller's words; ASCII is safest in R12: ° is written as %%d).
 */
export function dxfFlat(flat, labels = (b) => `${b.up ? 'UP' : 'DOWN'} ${+b.angle.toFixed(2)}%%d R${+b.r.toFixed(3)}`) {   // i18n-ignore: the shop note's default wording
  const { lines, w, f } = dxfWriter();
  dxfHead(w, f, [['0', 7], ['OUTLINE', 7], ['BEND', 1, 'DASHED'], ['BEND_NOTES', 1]], flat.box, { dashed: true });
  const counts = { line: 0, arc: 0, circle: 0, polyline: 0, bend: 0, text: 0 };
  for (const s of flat.segs) {
    if (s.type === 'line') { w(0, 'LINE'); w(8, 'OUTLINE'); w(10, f(s.a[0])); w(20, f(s.a[1])); w(30, '0.0'); w(11, f(s.b[0])); w(21, f(s.b[1])); w(31, '0.0'); counts.line++; }
    else if (s.type === 'circle') { w(0, 'CIRCLE'); w(8, 'OUTLINE'); w(10, f(s.c[0])); w(20, f(s.c[1])); w(30, '0.0'); w(40, f(s.r)); counts.circle++; }
    else if (s.type === 'arc') { w(0, 'ARC'); w(8, 'OUTLINE'); w(10, f(s.c[0])); w(20, f(s.c[1])); w(30, '0.0'); w(40, f(s.r)); w(50, f(s.a0)); w(51, f(s.a1)); counts.arc++; }
    else if (s.type === 'poly') {
      w(0, 'POLYLINE'); w(8, 'OUTLINE'); w(66, 1); w(10, '0.0'); w(20, '0.0'); w(30, '0.0'); w(70, 0);
      for (const [x, y] of s.pts) { w(0, 'VERTEX'); w(8, 'OUTLINE'); w(10, f(x)); w(20, f(y)); w(30, '0.0'); }
      w(0, 'SEQEND'); w(8, 'OUTLINE'); counts.polyline++;
    }
  }
  const h = Math.max(1.5, Math.min(5, 0.02 * Math.max(flat.box.x1 - flat.box.x0, flat.box.y1 - flat.box.y0)));
  for (const b of flat.bends) {
    w(0, 'LINE'); w(8, 'BEND'); w(10, f(b.a[0])); w(20, f(b.a[1])); w(30, '0.0'); w(11, f(b.b[0])); w(21, f(b.b[1])); w(31, '0.0'); counts.bend++;
    // the note along the line, a little off it, reading left to right
    let a = Math.atan2(b.b[1] - b.a[1], b.b[0] - b.a[0]);
    if (a > Math.PI / 2 + 1e-9 || a <= -Math.PI / 2 + 1e-9) a += Math.PI;
    const m = [(b.a[0] + b.b[0]) / 2 - Math.sin(a) * h * 0.6, (b.a[1] + b.b[1]) / 2 + Math.cos(a) * h * 0.6];
    w(0, 'TEXT'); w(8, 'BEND_NOTES'); w(10, f(m[0])); w(20, f(m[1])); w(30, '0.0'); w(40, f(h)); w(1, String(labels(b)).replace(/[\r\n]/g, ' ')); w(50, f(a * 180 / Math.PI)); w(72, 1); w(11, f(m[0])); w(21, f(m[1])); w(31, '0.0'); counts.text++;
  }
  w(0, 'ENDSEC'); w(0, 'EOF');
  return { text: lines.join('\r\n') + '\r\n', counts };
}

Numbers in a STEP file

64-bit

Writes one real number in STEP's exponent form.

Rounded with toPrecision(15) and written as mantissa and exponent. A magnitude under 1e-15, or a value that is not finite, is written as 0.

  • export.js
  • lines 226 to 234
  • PartCraft 0.8.1
const stepReal = (v) => {
  if (!Number.isFinite(v) || Math.abs(v) < 1e-15) return '0.';
  let s = Number(v.toPrecision(15)).toExponential().toUpperCase();   // 1.2345E+2
  let [m, e] = s.split('E');
  if (!m.includes('.')) m += '.';
  const ex = parseInt(e, 10);
  if (ex === 0) return m;
  return m + 'E' + (ex < 0 ? '-' : '') + String(Math.abs(ex)).padStart(2, '0');
};

Outer loop of a planar STEP face

64-bit

Picks the outer boundary of a planar face as the loop with the largest signed area.

area = ½ n · Σ pᵢ × pᵢ₊₁

The area sum uses position vectors measured from the model origin, so for a face far from the origin the cross products are large and nearly cancel. The area is only used to order the loops of one face.

  • export.js
  • lines 281 to 290
  • PartCraft 0.8.1
const areaOf = (l) => {
  let s = [0, 0, 0];
  for (let i = 0; i < l.length; i++) {
    const a = l[i], b = l[(i + 1) % l.length];
    const ax = P[a * 3], ay = P[a * 3 + 1], az = P[a * 3 + 2], bx = P[b * 3], by = P[b * 3 + 1], bz = P[b * 3 + 2];
    s = [s[0] + ay * bz - az * by, s[1] + az * bx - ax * bz, s[2] + ax * by - ay * bx];
  }
  return (s[0] * g.n[0] + s[1] * g.n[1] + s[2] * g.n[2]) / 2;
};
loops.sort((a, b) => areaOf(b) - areaOf(a));

Plane placement of a STEP face

64-bit

The x direction of the plane a planar face lies on.

x = (e − (e · n) n) ÷ |e − (e · n) n|, e = (1,0,0) unless |n_x| ≥ 0.9, then (0,1,0)

One Gram-Schmidt step against the face normal, then a division by the length. The reference axis is switched when the normal is within about 26 degrees of X, so the length divided by is never near zero.

  • export.js
  • lines 295 to 299
  • PartCraft 0.8.1
const n = g.n;
let xd = Math.abs(n[0]) < 0.9 ? [1, 0, 0] : [0, 1, 0];
const dd = xd[0] * n[0] + xd[1] * n[1] + xd[2] * n[2];
xd = [xd[0] - dd * n[0], xd[1] - dd * n[1], xd[2] - dd * n[2]];
const xl = Math.hypot(...xd); xd = xd.map((c) => c / xl);

In detail

Method
Direct copies of the model's 64-bit coordinates through a per-format text or binary writer; the STL and 3MF writers rotate the model from Y up to Z up by swapping and negating coordinates, which is exact.
Number types
64-bit numbers; 32-bit floats in the STL file.
Tolerances
DXF and 3MF write a magnitude under 1e-12 as zero; STEP under 1e-15. Spline chord tolerance in DXF: 0.01 in sketch units. The STEP file declares a distance uncertainty of 1e-6 mm.
Limits
3MF coordinates keep 9 significant digits. STL coordinates are 32-bit. 15 significant digits do not always read back to the identical 64-bit number (17 are needed for that). STEP output is faceted: curved surfaces are their triangles.

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