#!/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 { fitGauge } = 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 ``;
}
/* 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 ``;
}
/* 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 };
}
/* 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`);
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;
}
const files = process.argv.slice(2);
if (!files.length) { console.error('usage: verify-stl.cjs ...'); 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`}`);
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: the generator will also build a band centred
// on a width a reader supplies, and that variant gets the same byte-level scrutiny.
console.log('');
try { bad += verifyRecentred(31.7); }
catch (e) { console.error(`re-centred gauge: ${e.message}`); bad++; }
if (cards.length) {
const html = `
${cards.map((c) => `${c.name} — ${c.size.map((n) => n.toFixed(1)).join(' x ')} mm, ${c.count} triangles