#!/usr/bin/env node /* verify-stl.cjs — read the WRITTEN FILE back with an independent parser and render it. * * The generator validating its own in-memory geometry proves nothing about the bytes on disk: * a wrong triangle count in the header, a float written at the wrong offset, or a truncated * write all produce a file that every slicer rejects while the generator reports success. * So this parses the file from scratch, re-derives the bounds, and draws an isometric * projection to SVG — because a model nobody has looked at is not finished. */ 'use strict'; const fs = require('fs'); const path = require('path'); const { benchPlate, fitGauge, jumperComb, P } = require('./models.cjs'); function readSTL(file) { return parseSTL(fs.readFileSync(file)); } function parseSTL(buf) { if (buf.length < 84) throw new Error('shorter than an STL header'); const header = buf.toString('ascii', 0, 80).trim(); const count = buf.readUInt32LE(80); const expect = 84 + count * 50; if (buf.length !== expect) throw new Error(`header claims ${count} triangles = ${expect} bytes, file is ${buf.length}`); const tris = []; let o = 84; for (let i = 0; i < count; i++) { const n = [buf.readFloatLE(o), buf.readFloatLE(o + 4), buf.readFloatLE(o + 8)]; o += 12; const v = []; for (let k = 0; k < 3; k++) { v.push([buf.readFloatLE(o), buf.readFloatLE(o + 4), buf.readFloatLE(o + 8)]); o += 12; } o += 2; tris.push({ n, v }); } return { header, count, tris, bytes: buf.length }; } /* Isometric projection. Right-handed, Z up, viewed from (+X, -Y, +Z). */ const ISO = (p, s = 1) => { const a = Math.PI / 6; return [ (p[0] - p[1]) * Math.cos(a) * s, (-(p[0] + p[1]) * Math.sin(a) - p[2]) * s ]; }; function toSVG(tris, title) { const pts = tris.flatMap((t) => t.v.map((p) => ISO(p))); const xs = pts.map((p) => p[0]), ys = pts.map((p) => p[1]); const minX = Math.min(...xs), maxX = Math.max(...xs), minY = Math.min(...ys), maxY = Math.max(...ys); const pad = 8, w = maxX - minX + 2 * pad, h = maxY - minY + 2 * pad; // Painter's algorithm: draw far triangles first. Depth = distance along the view direction. const depth = (t) => t.v.reduce((s, p) => s + p[0] - p[1] + p[2], 0) / 3; const sorted = tris.slice().sort((a, b) => depth(a) - depth(b)); // Shade by facet normal so the form reads: top faces bright, sides darker. const shade = (n) => { const L = [0.4, -0.6, 0.7], d = Math.max(0, n[0] * L[0] + n[1] * L[1] + n[2] * L[2]); const c = Math.round(70 + 150 * d); return `rgb(${c},${Math.round(c * 0.93)},${Math.round(c * 0.86)})`; }; const body = sorted.map((t) => { const p = t.v.map((q) => { const [x, y] = ISO(q); return `${(x - minX + pad).toFixed(2)},${(y - minY + pad).toFixed(2)}`; }); return ``; }).join('\n'); return ` ${title} ${body} `; } /* Straight-down orthographic view with a millimetre grid. Every upward-facing facet is drawn, shaded by its height, so walls read as dark bars against the pale base and an opening in a wall is visible as a gap rather than something you have to infer from a silhouette. */ function toTopSVG(tris, lo, hi, title) { const pad = 6; const w = (hi[0] - lo[0]) + 2 * pad, h = (hi[1] - lo[1]) + 2 * pad; const X = (x) => (x - lo[0] + pad).toFixed(2); // SVG y grows downward; flip so +Y (back) is up, matching how the part sits on the bed. const Y = (y) => (hi[1] - y + pad).toFixed(2); const up = tris.filter((t) => t.n[2] > 0.5).sort((a, b) => zOf(a) - zOf(b)); function zOf(t) { return (t.v[0][2] + t.v[1][2] + t.v[2][2]) / 3; } const zMax = hi[2] || 1; const faces = up.map((t) => { const z = zOf(t); const k = Math.min(1, z / zMax); const c = Math.round(214 - 120 * k); // taller = darker const p = t.v.map((q) => `${X(q[0])},${Y(q[1])}`).join(' '); return ``; }).join('\n'); let grid = ''; for (let x = Math.ceil(lo[0] / 10) * 10; x <= hi[0]; x += 10) grid += ``; for (let y = Math.ceil(lo[1] / 10) * 10; y <= hi[1]; y += 10) grid += ``; return ` ${title} (top) ${faces} ${grid} `; } /* Bounds and winding, re-derived from parsed triangles alone — no help from the generator. A normal that disagrees with its winding means the facet will light/print inside-out. */ function audit(tris) { const all = tris.flatMap((t) => t.v); const lo = [0, 1, 2].map((i) => Math.min(...all.map((p) => p[i]))); const hi = [0, 1, 2].map((i) => Math.max(...all.map((p) => p[i]))); const size = [0, 1, 2].map((i) => hi[i] - lo[i]); let flipped = 0; for (const t of tris) { const [a, b, c] = t.v; const u = [b[0] - a[0], b[1] - a[1], b[2] - a[2]], w2 = [c[0] - a[0], c[1] - a[1], c[2] - a[2]]; const cr = [u[1] * w2[2] - u[2] * w2[1], u[2] * w2[0] - u[0] * w2[2], u[0] * w2[1] - u[1] * w2[0]]; if (cr[0] * t.n[0] + cr[1] * t.n[1] + cr[2] * t.n[2] < 0) flipped++; } return { lo, hi, size, flipped }; } /* UNDIRECTED EDGE SHARING, COUNTED OUT OF THE PARSED TRIANGLES. For every edge of every triangle, how many triangles use that same undirected edge? On ONE closed surface the answer is exactly two, everywhere: a count of one is a hole in the shell, and a count above two is several sheets meeting along one line. These parts are not one closed surface and were never built to be. stl.cjs has no boolean operations, so a part is a set of axis-aligned boxes that touch but never share volume (read its header comment). Where two of those boxes meet at a SHARED CORNER, the line at that corner is a real edge of both boxes and each contributes two triangles to it — four in total. That is a property of the construction, so it is REPORTED, never asserted: this function is incapable of changing the exit status, and a non-zero count is a number to publish rather than a gate to relax. Vertices are quantised to 1e-4 mm before they are compared — the same rounding the tooth pitch measurement further down uses, far tighter than any difference that could matter to a part and far looser than the float32 encoding's own noise. Edges are keyed on the unordered pair, so a triangle winding one way and its neighbour winding the other still match. */ function edgeShare(tris) { const q = (n) => Math.round(n * 1e4) / 1e4; const key = (p) => `${q(p[0])},${q(p[1])},${q(p[2])}`; const used = new Map(); for (const t of tris) { const v = t.v || t; for (let i = 0; i < 3; i++) { const a = key(v[i]), b = key(v[(i + 1) % 3]); const k = a < b ? `${a}|${b}` : `${b}|${a}`; used.set(k, (used.get(k) || 0) + 1); } } const odd = [...used].filter(([, n]) => n !== 2).sort((x, y) => (x[0] < y[0] ? -1 : x[0] > y[0] ? 1 : 0)); return { edges: used.size, odd: odd.length, open: odd.filter(([, n]) => n < 2).length, over: odd.filter(([, n]) => n > 2).length, detail: odd.map(([k, n]) => `${k} (${n} triangles)`), }; } /* Prints the tally in the same two-column shape as the rest of this file's cards. The first line is deliberately greppable and its wording is fixed. */ function printEdges(tris) { const e = edgeShare(tris); console.log(` edges edges shared by other than two triangles: ${e.odd}`); let second = ` ${e.edges} undirected edges, ${e.open} used once (an open shell), ${e.over} used more than twice`; if (e.detail.length) second += ` — ${e.detail.slice(0, 8).join('; ')}`; console.log(second); return e; } /* A BENCH PLATE AT A SIZE NOBODY PUBLISHED, ROUND-TRIPPED THROUGH BYTES. The page serves one plate, but benchPlate() takes the board width and the breadboard footprint as parameters and a reader can ask it for any combination, so a variant gets the same byte-level scrutiny the published file gets. The numbers passed in below are ARBITRARY. They are picked to be nowhere near the published plate, so that a stale default could not make this pass, and they are not a claim about any board, any breadboard or any hardware that exists. What is asserted is only what the geometry itself can support — both bays come out at exactly the requested part size plus the requested clearance, the solid is a valid non-interpenetrating mesh, it sits on Z=0, and every normal agrees with its winding. Nothing is written to disk. */ function verifyPlate(over) { const p = { ...P, ...over }; const { m, meta } = benchPlate(p); const problems = []; const v = m.check(); if (!v.ok) problems.push(`mesh invalid — ${v.problems.join('; ')}`); const near = (got, want, what) => { if (Math.abs(got - want) > 1e-9) problems.push(`${what} is ${got} mm, not the requested ${want}`); }; near(meta.bbBayW, p.bbL + p.bbClear, 'breadboard bay width'); near(meta.bbBayD, p.bbW + p.bbClear, 'breadboard bay depth'); near(meta.dkBayW, p.boardW + p.boardClear, 'devkit bay width'); const s = parseSTL(m.stl(`dankbuild bench plate - parameterised variant, not published`)); if (s.count !== v.triangles) problems.push(`header says ${s.count} triangles, model built ${v.triangles}`); const a = audit(s.tris); if (a.flipped) problems.push(`${a.flipped} facet normals disagree with their winding`); if (Math.abs(a.lo[2]) > 1e-6) problems.push(`does not sit on the build plate (Z=${a.lo[2]})`); console.log(`benchPlate({ boardW: ${p.boardW}, bbL: ${p.bbL}, bbW: ${p.bbW} }) (built in memory, nothing written)`); console.log(` bays breadboard ${meta.bbBayW.toFixed(2)} x ${meta.bbBayD.toFixed(2)} mm` + `, devkit ${meta.dkBayW.toFixed(2)} mm wide and open at both ends`); console.log(` round-trip ${s.count} triangles, ${s.bytes} bytes, bounds ${a.size.map((n) => n.toFixed(2)).join(' x ')} mm`); printEdges(s.tris); for (const pr of problems) console.log(` PROBLEM ${pr}`); if (!problems.length) console.log(' ok bays as requested, valid mesh, on the plate, normals agree with winding'); return problems.length ? 1 : 0; } /* A RE-CENTRED GAUGE, ROUND-TRIPPED THROUGH BYTES. fitGauge() can be asked for a band centred on a width the caller supplies, and nobody will ever run this file over such a variant before printing it — so it is checked here, on every run, from the serialised bytes rather than from the in-memory model. Nothing is written to disk: the STL is built, encoded, and parsed back by the same independent reader used on the published files above. The estimate below is an arbitrary number chosen to be nowhere near the published band; it is not a claim about any board. What is asserted is only what the geometry itself can support — the six channels straddle the estimate in both directions, the mesh is valid and non-interpenetrating, and the part still sits on Z=0 with every normal agreeing with its winding. */ function verifyRecentred(centre) { const { m, meta } = fitGauge({ centre }); const problems = []; const v = m.check(); if (!v.ok) problems.push(`mesh invalid — ${v.problems.join('; ')}`); if (meta.widths.length !== 6) problems.push(`${meta.widths.length} channels, not six`); if (!(Math.min(...meta.widths) < centre)) problems.push(`no channel narrower than ${centre}`); if (!(Math.max(...meta.widths) > centre)) problems.push(`no channel wider than ${centre}`); const s = parseSTL(m.stl(`dankbuild fit gauge - re-centred on ${centre} mm`)); if (s.count !== v.triangles) problems.push(`header says ${s.count} triangles, model built ${v.triangles}`); const a = audit(s.tris); if (a.flipped) problems.push(`${a.flipped} facet normals disagree with their winding`); if (Math.abs(a.lo[2]) > 1e-6) problems.push(`does not sit on the build plate (Z=${a.lo[2]})`); console.log(`fitGauge({ centre: ${centre} }) (built in memory, nothing written)`); console.log(` channels ${meta.widths.join(' / ')} mm — straddles ${centre} mm`); console.log(` round-trip ${s.count} triangles, ${s.bytes} bytes, bounds ${a.size.map((n) => n.toFixed(2)).join(' x ')} mm`); printEdges(s.tris); for (const p of problems) console.log(` PROBLEM ${p}`); if (!problems.length) console.log(' ok valid mesh, on the plate, normals agree with winding'); return problems.length ? 1 : 0; } /* A JUMPER-WIRE COMB, MEASURED OUT OF ITS OWN BYTES. The comb makes exactly one promise — that * its teeth stand on the 2.54 mm tie-point pitch — and the generator asserting that about its * own variables would be circular. So the pitch is re-derived HERE from float32 vertices that * have been through the serialiser and back: take every vertex sitting on the topmost Z plane * (only the tooth tops reach it; the base slab stops 3.2 mm lower), collect the distinct X * values, and read the tooth faces straight off that list. If a tooth were misplaced, or the * float32 round trip moved one, the spacing measured here would disagree with the standard. * Nothing is written to disk. The tooth count is the caller's; it asserts nothing about any * hardware, because the pitch it is checked against is the published standard, not a * measurement. */ function verifyComb(teeth) { const { m, meta } = jumperComb({ teeth }); const problems = []; const v = m.check(); if (!v.ok) problems.push(`mesh invalid — ${v.problems.join('; ')}`); const s = parseSTL(m.stl(`dankbuild jumper-wire comb - ${teeth} teeth on 2.54 mm pitch`)); if (s.count !== v.triangles) problems.push(`header says ${s.count} triangles, model built ${v.triangles}`); const a = audit(s.tris); if (a.flipped) problems.push(`${a.flipped} facet normals disagree with their winding`); if (Math.abs(a.lo[2]) > 1e-6) problems.push(`does not sit on the build plate (Z=${a.lo[2]})`); // float32 keeps roughly seven significant digits, so 1e-3 mm is far tighter than any // difference that could matter and far looser than the encoding's own noise. const TOL = 1e-3; const top = a.hi[2]; const xs = [...new Set(s.tris.flatMap((t) => t.v) .filter((p) => Math.abs(p[2] - top) < TOL) .map((p) => Math.round(p[0] * 1e4) / 1e4))].sort((x, y) => x - y); if (xs.length !== 2 * teeth) { problems.push(`${xs.length / 2} tooth faces on the top plane, expected ${teeth} teeth`); } else { let worstPitch = 0, worstWidth = 0; for (let i = 0; i < teeth; i++) { worstWidth = Math.max(worstWidth, Math.abs((xs[2 * i + 1] - xs[2 * i]) - P.pitch / 2)); if (i > 0) worstPitch = Math.max(worstPitch, Math.abs((xs[2 * i] - xs[2 * i - 2]) - P.pitch)); } if (worstPitch > TOL) problems.push(`tooth centres are off pitch by up to ${worstPitch.toFixed(4)} mm`); if (worstWidth > TOL) problems.push(`tooth thickness is off half-pitch by up to ${worstWidth.toFixed(4)} mm`); console.log(`jumperComb({ teeth: ${teeth} }) (built in memory, nothing written)`); console.log(` pitch re-read from the bytes: ${teeth} teeth, centres ${(xs[2] - xs[0]).toFixed(4)} mm apart` + ` (standard ${P.pitch}), teeth ${(xs[1] - xs[0]).toFixed(4)} mm thick`); console.log(` round-trip ${s.count} triangles, ${s.bytes} bytes, bounds ${a.size.map((n) => n.toFixed(2)).join(' x ')} mm`); } printEdges(s.tris); for (const p of problems) console.log(` PROBLEM ${p}`); if (!problems.length) console.log(' ok on pitch, valid mesh, on the plate, normals agree with winding'); return problems.length ? 1 : 0; } /* CLI. THE PREVIEW PAGE IS OPT-IN, AND THAT IS DELIBERATE. This used to end by writing preview.html next to the first STL it was handed. The STLs it is normally pointed at live under html/, so every run quietly dropped a chrome-less 120 kB debug dump into the published webroot — a page that answered 200 to anyone who guessed the address and that nobody had decided to publish. Opt-in beats picking a different hard-coded directory: a tool that writes no HTML unless asked cannot publish anything by accident, whichever directory it is pointed at. --preview additionally refuses a path inside html/, so the one mistake that caused this cannot be made again by typing it out by hand. */ const WEBROOT = path.resolve(__dirname, '..', '..', 'html'); const argv = process.argv.slice(2); const files = []; let previewOut = null; for (let i = 0; i < argv.length; i++) { if (argv[i] === '--preview') { previewOut = argv[++i]; if (!previewOut) { console.error('--preview needs a path to write the preview HTML to'); process.exit(2); } continue; } files.push(argv[i]); } if (!files.length) { console.error('usage: verify-stl.cjs [--preview ] ...'); process.exit(2); } if (previewOut) { const abs = path.resolve(previewOut); if (abs === WEBROOT || abs.startsWith(WEBROOT + path.sep)) { console.error(`refusing --preview ${previewOut}: that is inside the published webroot ${WEBROOT}.`); console.error('The preview is a debug dump, not a page — write it somewhere that does not get served.'); process.exit(2); } } let bad = 0; const cards = []; for (const f of files) { try { const s = readSTL(f); const { lo, hi, size, flipped } = audit(s.tris); const name = path.basename(f); console.log(`${name}`); console.log(` parsed OK ${s.count} triangles, ${s.bytes} bytes, header "${s.header}"`); console.log(` bounds ${size.map((n) => n.toFixed(2)).join(' x ')} mm, sits on Z=${lo[2].toFixed(2)}`); console.log(` normals ${flipped === 0 ? 'all agree with winding' : `${flipped} DISAGREE with winding`}`); printEdges(s.tris); if (flipped) bad++; if (Math.abs(lo[2]) > 1e-6) { console.log(' WARNING does not sit on the build plate'); bad++; } const svg = toSVG(s.tris, name); fs.writeFileSync(f.replace(/\.stl$/, '.svg'), svg); // TOP VIEW TOO. The isometric render is pretty but genuinely ambiguous about which walls // exist and where the openings are — the one thing that decides whether a part fits. // Looking straight down, with anything above the base drawn dark, makes the bay layout // unmistakable, and that is the check worth having. const topSvg = toTopSVG(s.tris, lo, hi, name); fs.writeFileSync(f.replace(/\.stl$/, '-top.svg'), topSvg); cards.push({ name, svg, topSvg, size, count: s.count }); } catch (e) { console.error(`${f}: ${e.message}`); bad++; } } // The published files are only half the surface: all three generators take parameters, and a // variant nobody has ever run this tool over is exactly the one that could be wrong. Each gets // the same byte-level scrutiny — parsed back out of its own encoded bytes, edges included. console.log(''); try { bad += verifyPlate({ boardW: 26.4, bbL: 165, bbW: 54.5 }); } catch (e) { console.error(`bench plate variant: ${e.message}`); bad++; } console.log(''); try { bad += verifyRecentred(31.7); } catch (e) { console.error(`re-centred gauge: ${e.message}`); bad++; } console.log(''); try { bad += verifyComb(12); } catch (e) { console.error(`jumper-wire comb: ${e.message}`); bad++; } if (cards.length && !previewOut) { console.log('\nno preview written — pass --preview if you want the HTML contact sheet'); } if (cards.length && previewOut) { const html = ` ${cards.map((c) => `
${c.name} — ${c.size.map((n) => n.toFixed(1)).join(' x ')} mm, ${c.count} triangles
TOP (10 mm grid, +Y up, darker = taller)
${c.topSvg}
ISO
${c.svg}
`).join('\n')}`; const out = path.resolve(previewOut); fs.mkdirSync(path.dirname(out), { recursive: true }); fs.writeFileSync(out, html); console.log(`\npreview -> ${out}`); } process.exit(bad ? 1 : 0);