Generating 3D-Printable Parts From Code
A dependency-free Node geometry kernel and binary-STL writer that turns parameters into printable parts, guarded by a solid-interpenetration check, a caliper-style fit measurement, a re-parse of the written bytes by a separate reader, and four committed dankbuild gates — web-geometry-parity, web-projection-parity, web-tray-parity and web-edge-tally — that pin the browser copy of the geometry, its drawing, the tray and the edge tally against the CLI originals.
📅
Parametric geometry / mesh output
A part described as numbers in a file rather than clicks in a modeller. Change a parameter, re-run node, get a new STL.
- Kernel: CommonJS; every
requireis a Node builtin or a sibling file — no mesh library, no CAD kernel - Output: binary STL, exactly
84 + 50 × trianglesbytes, written withBufferprimitives - Verification: the three layers below, each able to fail on its own, plus four committed gates in dankbuild’s
test/directory —web-geometry-parity,web-projection-parity,web-tray-parityandweb-edge-tally— that fail the build when the copy of this code served to the browser stops matching the CLI original - Not printed: the kernel emits four parts — bench plate, fit gauge, jumper comb, bench tray — and none of the four has been printed by anyone
🌐 The page serving the models and sources · build.cjs · models.cjs · stl.cjs · test-fit.cjs · verify-stl.cjs
What you need
A Node runtime, and nothing else. There is no package.json beside these files, no npm install, no lockfile and no build step. Every require in the kernel resolves to a Node builtin or to a sibling file in the same directory — models.cjs requires exactly one thing, ./stl.cjs — so the dependency graph is the five files themselves. The runs quoted below were made on Node v23.11.1 on Linux; nothing in the code is newer than Buffer, fs and Math.
The sources, without a checkout. The parts page publishes the five command-line files verbatim as .txt mirrors, so five curl calls are a complete working copy:
mkdir -p esp32-parts/tools/print && cd esp32-parts
b=https://dankbuild.com/electronics/esp32-bench-parts
curl -fsS $b/stl.cjs.txt -o tools/print/stl.cjs
curl -fsS $b/models.cjs.txt -o tools/print/models.cjs
curl -fsS $b/build.cjs.txt -o tools/print/build.cjs
curl -fsS $b/test-fit.cjs.txt -o tools/print/test-fit.cjs
curl -fsS $b/verify-stl.cjs.txt -o tools/print/verify-stl.cjs
What comes out. Three binary STL files — esp32-bench-plate.stl (4,884 bytes), esp32-fit-gauge.stl (20,484) and jumper-wire-comb.stl (19,884), 45,252 bytes in total. The fourth part, the bench tray, has no committed STL on purpose, for the reason in the last section of this page. verify-stl.cjs additionally writes an isometric and a top-down .svg beside every .stl it is pointed at, so pointing it at a directory you care about will add files to it.
What you do not get. No slicer profile, no G-code, no print settings, no material advice, and no print-time evidence of any kind: nothing here has been printed, and the parts page says the same thing in the same words. Every check on this page is geometric or byte-level. Two of the seven files — make-web-geometry.cjs and make-web-projection.cjs, which emit the browser copy — are not among the .txt mirrors, so the last command block below needs the dankbuild checkout rather than a curl.
The commands that generate the parts
Write the STLs. build.cjs takes one optional argument, the directory to write into, and defaults to its own. It calls check() on each model first and writes nothing that fails, so a bad mesh is reported and skipped rather than emitted; the process exits non-zero if any model was skipped.
node tools/print/build.cjs out
esp32-bench-plate.stl - 96 triangles / 8 solids, no interpenetration
120.3 x 63.1 x 5.6 mm, 4.8 KB
breadboard bay 84.1 x 55.1 mm, devkit bay 26.2 mm wide, open-ended (board overhangs -0.1 mm total)
esp32-fit-gauge.stl - 408 triangles / 34 solids, no interpenetration
83.4 x 109.8 x 5.4 mm, 20.0 KB
channels 24.6 / 25 / 25.4 / 25.8 / 26.2 / 26.6 mm - 1 rib = narrowest
jumper-wire-comb.stl - 396 triangles / 33 solids, no interpenetration
84.0 x 9.1 x 5.6 mm, 19.4 KB
32 teeth / 31 slots, 1.27 mm teeth on 2.54 mm centres (max 32 in the plate's bay)
Confirm you built the file the site serves. The output is deterministic: no timestamp, no floating-point accumulation over an unordered set, no machine-specific path in the header. The bytes your run produced and the bytes the parts page has been serving are the same bytes, and a digest is the cheapest way to see it — which also means a source that had been tampered with in transit would not reproduce the published digest.
sha256sum out/esp32-bench-plate.stl
curl -fsS $b/esp32-bench-plate.stl | sha256sum
c4149eb76fbb12e1abbc959f26eac47f2dae0fed3b904782006e6bbb9bf22403 out/esp32-bench-plate.stl
c4149eb76fbb12e1abbc959f26eac47f2dae0fed3b904782006e6bbb9bf22403 -
Measure the finished solids. test-fit.cjs takes no arguments and reads nothing from disk: it rebuilds the models in memory and applies the caliper-style scan described below, printing one line per assertion. A failure does not stop the run — it prints, the remaining assertions still run, and the process ends with a non-zero status — so one broken dimension does not hide the state of the other twenty-five.
node tools/print/test-fit.cjs
jumper-wire comb, at a tooth count the caller chose
ok a shorter comb keeps the pitch and stays a valid mesh
ok the overall size is the tooth count and the pitch, and nothing else
ok counts that cannot work are refused, not quietly built
ok a comb of another length does not disturb the published one
26 assertion(s) passed
Read the written file back. verify-stl.cjs takes one or more .stl paths and parses each with its own reader, which shares no code with the writer. It also writes an isometric and a top-down .svg next to every file it reads, and after the files it prints three in-memory variants as a demonstration — the six lines below are the report for the file itself, and the run continues past them.
node tools/print/verify-stl.cjs out/jumper-wire-comb.stl
jumper-wire-comb.stl
parsed OK 396 triangles, 19884 bytes, header "dankbuild jumper-wire comb - teeth on 2.54 mm tie-point pitch"
bounds 84.01 x 9.08 x 5.60 mm, sits on Z=0.00
normals all agree with winding
edges edges shared by other than two triangles: 0
594 undirected edges, 0 used once (an open shell), 0 used more than twice
The edge tally on the last two lines is reported, not judged. Zero here; run the same command against the bench plate and it reports four, for the reason in “Layer three” below — with no boolean operations, a wall standing on a base is two boxes that touch, so four triangles legitimately meet along each shared corner edge. What the tally would catch either way is an edge used exactly once, which is a hole in a shell that was meant to be closed.
Change a parameter and get a different part. This is the point of the whole thing, and it is not a CLI flag: models.cjs exports the model functions, each takes an options object, and build.cjs is a thirty-one-line caller you are meant to replace. A twelve-tooth comb instead of the published thirty-two-tooth one:
node -e '
const fs = require("fs");
const { jumperComb } = require("./tools/print/models.cjs");
const { m, meta } = jumperComb({ teeth: 12 });
const v = m.check();
if (!v.ok) { console.error(v.problems.join("; ")); process.exit(1); }
fs.writeFileSync("out/comb-12.stl", m.stl("12-tooth comb"));
console.log(meta.teeth + " teeth, " + v.triangles + " triangles, "
+ m.bounds().size.map((n) => n.toFixed(2)).join(" x ") + " mm, "
+ fs.statSync("out/comb-12.stl").size + " bytes");
'
12 teeth, 156 triangles, 33.21 x 9.08 x 5.60 mm, 7884 bytes
The same reader takes the new file, and the arithmetic holds: 156 triangles is 84 + 50 × 156 = 7,884 bytes, the figure stat reported above. Twelve teeth make eleven slots, so 23 alternating members at 1.27 mm each are 29.21 mm, plus the 2 mm outer margin at each end — the 33.21 mm width. Again, only the first six lines are shown.
node tools/print/verify-stl.cjs out/comb-12.stl
comb-12.stl
parsed OK 156 triangles, 7884 bytes, header "12-tooth comb"
bounds 33.21 x 9.08 x 5.60 mm, sits on Z=0.00
normals all agree with winding
edges edges shared by other than two triangles: 0
234 undirected edges, 0 used once (an open shell), 0 used more than twice
What a refusal looks like. Parameters that cannot produce a part throw rather than building something degenerate, and the message says what is missing. Two of them:
node -e '
const { jumperComb, benchTray } = require("./tools/print/models.cjs");
try { jumperComb({ teeth: 1 }); } catch (e) { console.log("jumperComb({ teeth: 1 }): " + e.message); }
try { benchTray(); } catch (e) { console.log("benchTray(): " + e.message); }
'
jumperComb({ teeth: 1 }): jumperComb: teeth must be a whole number >= 2 (two teeth is one slot), got 1
benchTray(): benchTray: the inside length must be a positive number of millimetres, and it was not given. A tray is defined by the hole in the middle of it, so both inside dimensions have to come from you; this file has no default size for a pile of loose parts and will not invent one.
Regenerate the browser copy. These two commands need the dankbuild checkout, because the generators are not published as .txt mirrors. Each takes the output path as its only argument; run against a scratch path, they reproduce the committed modules byte for byte, which is the property the parity gates below assert on every build.
node tools/print/make-web-geometry.cjs scratch/geometry.js
node tools/print/make-web-projection.cjs scratch/projection.js
cmp scratch/geometry.js html/electronics/esp32-bench-parts/geometry.js
cmp scratch/projection.js html/electronics/esp32-bench-parts/projection.js
scratch/geometry.js — 34140 bytes from stl.cjs + models.cjs
scratch/projection.js — 8551 bytes from verify-stl.cjs
cmp printing nothing is the result: the served files are generated, not hand-edited, and not stale.
Boxes that touch, and never overlap
The kernel has one primitive: an axis-aligned box, emitted as twelve triangles wound counter-clockwise seen from outside. There are no boolean operations. Subtracting a pocket from a slab is where hobby mesh code dies — robust plane clipping, non-manifold edges along coincident faces, and a slicer that quietly “repairs” the result into something nobody designed. So a recess here is four walls standing on a plate, not a hole cut into a block.
That constraint buys exactness later. Model.add() keeps the box extents alongside the triangles, so geometric questions are answered on intervals rather than on a triangle soup. Walls come from wallX/wallY, which take gaps in absolute coordinates and split the run into segments — a notched wall is three boxes, produced by interval arithmetic that never has to decide what “inside” means.
Writing the file is the small half: an 80-byte ASCII header, a little-endian uint32 triangle count, then per triangle a facet normal and three vertices as 32-bit floats plus an unused 16-bit attribute word.
Layer one: geometry that can reject itself
check() walks the boxes pairwise and computes the overlap on each axis. Positive overlap on all three at once is interpenetration; face and edge contact are not, and a 1e-6 epsilon keeps floating-point coincidence from reading as volume. Because every solid is axis-aligned, the test is exact, not approximate.
The first version of this check was wrong. It demanded every edge be shared by exactly two triangles and then reported “solids interpenetrate” on a perfectly legal plate: where a wall stands on a base, the shared vertical edge belongs to four triangles, two from each box. It conflated “one closed surface” with “printable”. A check that rejects good work is worse than none: you learn to ignore it.
Layer two: measuring the finished solids
Valid geometry is not a part that fits. test-fit.cjs measures the emitted solids the way a caliper would: pick a scan line at a given height, collect the boxes that block it, and report the largest clear span left inside a window.
This caught a real defect. An extra gap term had been added to the plate width, and because nothing ever occupied it, all 4 mm landed in one bay — the board pocket offered 4.8 mm of slop where 0.8 mm was intended, enough to rattle. The arithmetic that produced the model looked fine; measuring the finished solids exposed it.
The measuring function had its own bug first: it did not clamp blockers to the window it was asked about, so every channel came back identical — the widest gap anywhere on the scan line won. Both are now assertions, alongside one that no solid starts anywhere but on the base.
Layer three: reading the bytes back
A generator validating its own in-memory geometry says nothing about the file on disk. verify-stl.cjs shares no code with the writer: it re-reads the STL, requires the byte length to equal what the header’s triangle count implies, re-derives the bounding box, recomputes each facet normal from the winding and flags disagreement, warns if the part does not sit on Z=0, and renders an isometric and a top-down SVG so a human can see the openings.
It also tallies edge sharing and prints the result: on the bench plate, 140 undirected edges, none used by a single triangle, four used by more than two. On one closed surface every edge would be used exactly twice, and this part is deliberately not one closed surface — with no boolean operations, a wall standing on a base is two boxes that touch, so the line where they meet at a shared corner is a real edge of both and each contributes two triangles to it. That is a property of how the part is built, so the count is reported rather than judged: the function that produces it cannot change the exit status, and a non-zero figure is a number to publish, not a failure. The same tally would still catch an edge used only once, which is a hole in a shell that was supposed to be closed.
The four gates that hold the browser copy to the CLI
The parts page offers a generator that runs in the visitor’s own tab, and that module is not written by hand: tools/print/make-web-geometry.cjs emits geometry.js from the same stl.cjs and models.cjs the command line uses. A generated part is the most convincing kind of wrong output there is — it downloads, it opens in a slicer, it looks like a plate — so if the two copies ever drifted, every visitor would get a part built by code that nothing checks while the page still pointed at test-fit.cjs and verify-stl.cjs as its evidence. Four separate files in dankbuild’s test/ directory hold them together. Any one of them going red is a red build.
web-geometry-parity.test.cjs asserts three things, and then asserts all three again for a second part. (a) Re-running the generator into a scratch path reproduces the committed geometry.js byte for byte, so the served file is generated, never hand-edited, never stale. (b) A plate built by importing geometry.js serialises to bytes identical to the committed esp32-bench-plate.stl — pinned at 4,884 bytes, 96 triangles, 120.30 × 63.10 × 5.60 mm, the figures the page prints. (c) That solid passes the same check() the Node build gates on. The jumper-wire comb gets the same three, pinned at 19,884 bytes, 396 triangles, 84.01 × 9.08 × 5.60 mm, because a family that is generated but unchecked is exactly the hole this file exists to close. It also asserts the module carries no Node-only surface, so it really runs in a tab. (b) is the load-bearing one: it is byte identity against a file the repository already shipped, not a re-derivation of the same arithmetic, so it catches a shim that rounds differently just as surely as a changed dimension.
web-projection-parity.test.cjs covers the drawing, which is the most persuasive thing on the page and the easiest thing to fake. An outline sketched from the width you typed would look right for every input, including the inputs where the mesh is wrong. So the module serialises the solid to STL bytes and projection.js parses those bytes back — and this file asserts that the generator reproduces the committed projection.js byte for byte; that the SVG returned for the committed plate STL is non-empty, carries a viewBox, and contains zero occurrences of <text (the published SVGs quote no dimension figure and neither may this one); that the SVG is identical to the committed esp32-bench-plate.svg and esp32-bench-plate-top.svg that verify-stl.cjs wrote under Node; and that the module has no Node-only surface. It also asserts that a truncated file is refused rather than drawn, because a picture of bytes that failed to parse is the one thing worse than no picture. Same bytes in, same drawing out, in two environments with different number plumbing — that is what makes the browser copy a copy rather than a lookalike.
web-tray-parity.test.cjs checks four properties at five sizes rather than one, from 1 × 1 mm to 200 × 200 mm. Parity: models.cjs’s benchTray() and the benchTray() reachable from the generated geometry.js must serialise to identical bytes. Closed mesh: every undirected edge used by exactly two triangles — none used once, which would be a hole in the shell, and none used by three or more — counted out of the finished STL bytes by edges.js, a second parser that knows nothing about the generator, rather than off the mesh object. Cavity: every point inside the requested inside length × inside width × wall height is in no solid, and a ring of points half a wall thickness outside that cavity, on all four sides and all four corners, is inside one — a tray with a gap at a corner would pass a bounding-box comparison and fails this. Refusal: missing, zero, negative, NaN and non-numeric inside dimensions must all throw.
web-edge-tally.test.cjs pins the browser’s edge tally to the command line’s. The two counts come from code that shares nothing: verify-stl.cjs is CommonJS reading floats with Buffer.readFloatLE from a terminal, edges.js is ESM reading them with DataView.getFloat32 in a tab. They could not share code without making verify-stl.cjs importable, which would rewrite the verbatim mirror published as verify-stl.cjs.txt — the whole point of which is that a reader can see the code that produced the published numbers. So for every solid the site ships, the number the browser path would print is compared against the number verify-stl.cjs actually prints, taken from its own stdout rather than from a copy of its arithmetic. The STLs are copied into a scratch directory first, because verify-stl.cjs writes an SVG beside every file it reads and the published SVGs must stay byte-identical.
What it will not do
Axis-aligned boxes only — no curves, no holes, no fillets, no rotation. It suits bays, plates and walls, nothing organic. One board dimension in the example model could not be sourced from an authority, so it is marked unverified in the parameters and a companion gauge exists to replace the guess with a measurement.
It also will not size the tray for you. Three of the four parts have a committed STL on the parts page; the bench tray has none, and that is a design decision rather than an omission — build.cjs, the script that writes the files, imports benchPlate, fitGauge and jumperComb and stops there. The tray is left out because it has no defensible default size: a tray is defined by the hole in the middle of it, so both inside dimensions have to come from the person who owns the bench, and inventing a pair would be inventing a fact about somebody’s desk. Called without them, benchTray() throws — “this file has no default size for a pile of loose parts and will not invent one” — so there is nothing for build.cjs to write. The tray therefore exists only as a browser generator: you type an inside length and an inside width on the parts page and the module in your tab builds it. That is why the STL count (three) and the part count (four) differ, and the difference is not an error. Losing the safety net every other part has — a committed file whose bytes can be compared — is precisely why web-tray-parity.test.cjs pins the tray’s bytes, its closed mesh, its cavity and its refusal to guess instead.