/* stl.cjs — a zero-dependency binary STL writer with additive box/prism primitives. * * WHY ADDITIVE, WITH NO BOOLEAN OPS. Constructive solid geometry (subtracting a pocket from * a slab) is where hobby mesh code goes to die: it needs robust plane clipping, it produces * non-manifold edges on coincident faces, and slicers then quietly "repair" the result into * something you did not design. Every model here is instead composed of axis-aligned boxes * that TOUCH but never interpenetrate, so a recess is four walls standing on a slab rather * than a hole cut into a block. Slicers handle that perfectly, and the geometry stays * verifiable: see check() below, which asserts every edge is shared by exactly two triangles. * * UNITS ARE MILLIMETRES, which is what every slicer assumes for STL. * Coordinates: X right, Y back, Z up. Z=0 is the build plate. */ 'use strict'; /* One axis-aligned box, as 12 triangles with outward normals. Vertices are wound counter-clockwise seen from outside, which is what STL requires. */ function box(x0, y0, z0, x1, y1, z1) { if (!(x1 > x0 && y1 > y0 && z1 > z0)) { throw new Error(`box() needs strictly increasing bounds, got [${x0},${y0},${z0}]..[${x1},${y1},${z1}]`); } const v = [ [x0, y0, z0], [x1, y0, z0], [x1, y1, z0], [x0, y1, z0], // 0-3 bottom [x0, y0, z1], [x1, y0, z1], [x1, y1, z1], [x0, y1, z1], // 4-7 top ]; const quad = (a, b, c, d) => [[v[a], v[b], v[c]], [v[a], v[c], v[d]]]; return [ ...quad(0, 3, 2, 1), // bottom (-Z) ...quad(4, 5, 6, 7), // top (+Z) ...quad(0, 1, 5, 4), // front (-Y) ...quad(2, 3, 7, 6), // back (+Y) ...quad(1, 2, 6, 5), // right (+X) ...quad(3, 0, 4, 7), // left (-X) ]; } /* A box given origin + size, which reads better for most call sites. */ const boxAt = (x, y, z, w, d, h) => box(x, y, z, x + w, y + d, z + h); /* A wall running along X, optionally broken by gaps (cable notches, finger reliefs). Gaps are [start, end] pairs in absolute X. Returns however many segments survive. */ function wallX(x0, x1, y, z0, z1, thickness, gaps = []) { const segs = []; let cursor = x0; const sorted = gaps.slice().sort((a, b) => a[0] - b[0]); for (const [gs, ge] of sorted) { if (ge <= cursor || gs >= x1) continue; if (gs > cursor) segs.push([cursor, Math.min(gs, x1)]); cursor = Math.max(cursor, ge); } if (cursor < x1) segs.push([cursor, x1]); return segs.filter(([a, b]) => b - a > 1e-9) .flatMap(([a, b]) => box(a, y, z0, b, y + thickness, z1)); } /* A wall running along Y. */ function wallY(y0, y1, x, z0, z1, thickness, gaps = []) { const segs = []; let cursor = y0; const sorted = gaps.slice().sort((a, b) => a[0] - b[0]); for (const [gs, ge] of sorted) { if (ge <= cursor || gs >= y1) continue; if (gs > cursor) segs.push([cursor, Math.min(gs, y1)]); cursor = Math.max(cursor, ge); } if (cursor < y1) segs.push([cursor, y1]); return segs.filter(([a, b]) => b - a > 1e-9) .flatMap(([a, b]) => box(x, a, z0, x + thickness, b, z1)); } /* Facet normal from the winding, normalised. STL stores it per triangle. */ function normal(t) { const [a, b, c] = t; const u = [b[0] - a[0], b[1] - a[1], b[2] - a[2]]; const w = [c[0] - a[0], c[1] - a[1], c[2] - a[2]]; const 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 len = Math.hypot(n[0], n[1], n[2]); return len > 0 ? [n[0] / len, n[1] / len, n[2] / len] : [0, 0, 0]; } /* Serialise to binary STL: 80-byte header, uint32 count, then 50 bytes per triangle. */ function toBinarySTL(tris, header = 'generated by dankbuild stl.cjs') { const buf = Buffer.alloc(84 + tris.length * 50); buf.write(header.slice(0, 79).padEnd(80, ' '), 0, 80, 'ascii'); buf.writeUInt32LE(tris.length, 80); let o = 84; for (const t of tris) { const n = normal(t); buf.writeFloatLE(n[0], o); buf.writeFloatLE(n[1], o + 4); buf.writeFloatLE(n[2], o + 8); o += 12; for (const p of t) { buf.writeFloatLE(p[0], o); buf.writeFloatLE(p[1], o + 4); buf.writeFloatLE(p[2], o + 8); o += 12; } buf.writeUInt16LE(0, o); o += 2; // attribute byte count, unused } return buf; } /* GEOMETRY CHECK. A model that slices wrong wastes an hour of the owner's filament, and a mesh error is invisible in a screenshot — so assert it in code instead. WHAT IS AND IS NOT AN ERROR — because the first version of this function got it wrong and rejected a perfectly printable plate. It demanded every edge be used by exactly two triangles, then reported "solids interpenetrate". They did not. A plate with walls standing on it is a legal MULTI-SOLID STL: where a wall meets the base, or two walls meet at a corner, the boxes merely TOUCH, and the shared vertical edge is then legitimately used by four triangles — two from each box. Slicers union such solids correctly and always have. The old rule conflated "one closed surface" with "printable", which is a different claim. So this asserts the two things that genuinely ruin a print: 1. every box is closed and non-degenerate, with finite coordinates; 2. no two boxes INTERPENETRATE — share positive volume. Face and edge contact are fine; overlapping interiors are what produce the self-intersecting shells that slicers silently "repair" into something you did not design. Every primitive here is an axis-aligned box, so (2) is exact rather than approximate: positive overlap on all three axes at once. */ function check(tris, boxes = null) { const problems = []; let degenerate = 0, nan = 0; for (const t of tris) { for (const p of t) if (p.some((n) => !Number.isFinite(n))) nan++; const n = normal(t); if (n.every((c) => c === 0)) degenerate++; } if (nan) problems.push(`${nan} non-finite coordinate(s)`); if (degenerate) problems.push(`${degenerate} degenerate triangle(s)`); if (tris.length % 12 !== 0) problems.push(`${tris.length} triangles is not a whole number of boxes`); const EPS = 1e-6; // keeps floating-point face contact from reading as overlap if (boxes) { const overlaps = []; const ov = (a0, a1, b0, b1) => Math.min(a1, b1) - Math.max(a0, b0); for (let i = 0; i < boxes.length; i++) { for (let j = i + 1; j < boxes.length; j++) { const a = boxes[i], b = boxes[j]; const dx = ov(a[0], a[3], b[0], b[3]); const dy = ov(a[1], a[4], b[1], b[4]); const dz = ov(a[2], a[5], b[2], b[5]); if (dx > EPS && dy > EPS && dz > EPS) { overlaps.push(`#${i}/#${j} by ${dx.toFixed(2)}x${dy.toFixed(2)}x${dz.toFixed(2)}mm`); } } } if (overlaps.length) problems.push(`${overlaps.length} interpenetrating box pair(s): ${overlaps.slice(0, 4).join(', ')}`); } return { ok: problems.length === 0, triangles: tris.length, solids: tris.length / 12, problems }; } function bounds(tris) { const lo = [Infinity, Infinity, Infinity], hi = [-Infinity, -Infinity, -Infinity]; for (const t of tris) for (const p of t) for (let i = 0; i < 3; i++) { if (p[i] < lo[i]) lo[i] = p[i]; if (p[i] > hi[i]) hi[i] = p[i]; } return { lo, hi, size: [hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]] }; } /* A collector that keeps the triangles AND the source boxes, so check() can run its exact interpenetration test. Building through this rather than concatenating raw triangle arrays is what makes the validation possible at all — once geometry is a triangle soup, "do these two solids overlap?" is no longer a cheap question. */ class Model { constructor() { this.tris = []; this.boxes = []; } add(x0, y0, z0, x1, y1, z1) { this.tris.push(...box(x0, y0, z0, x1, y1, z1)); this.boxes.push([x0, y0, z0, x1, y1, z1]); return this; } addAt(x, y, z, w, d, h) { return this.add(x, y, z, x + w, y + d, z + h); } /* Wall along X at depth y, broken by [start,end] gaps in absolute X. */ wallX(x0, x1, y, z0, z1, t, gaps = []) { for (const [a, b] of segments(x0, x1, gaps)) this.add(a, y, z0, b, y + t, z1); return this; } /* Wall along Y at position x. */ wallY(y0, y1, x, z0, z1, t, gaps = []) { for (const [a, b] of segments(y0, y1, gaps)) this.add(x, a, z0, x + t, b, z1); return this; } check() { return check(this.tris, this.boxes); } bounds() { return bounds(this.tris); } stl(header) { return toBinarySTL(this.tris, header); } } /* Split [lo,hi] by the given gaps, returning the surviving spans. */ function segments(lo, hi, gaps) { const out = []; let cursor = lo; for (const [gs, ge] of gaps.slice().sort((a, b) => a[0] - b[0])) { if (ge <= cursor || gs >= hi) continue; if (gs > cursor) out.push([cursor, Math.min(gs, hi)]); cursor = Math.max(cursor, ge); } if (cursor < hi) out.push([cursor, hi]); return out.filter(([a, b]) => b - a > 1e-9); } module.exports = { box, boxAt, wallX, wallY, toBinarySTL, check, bounds, normal, Model, segments };