/* bench-plate.cjs — parametric bench plate for an ESP32-S3-DevKitC-1 + half-size breadboard, * and the fit gauge that makes the next revision exact. * * THE HONEST PROBLEM THIS DESIGN IS SHAPED AROUND * ----------------------------------------------- * CORRECTED 2026-08-08. This note used to say Espressif's board page is JS-rendered and its * linked dimensions file 404s. Both halves were wrong, and saying so here matters more than * the original note did. What actually happened is link rot: the unversioned user_guide.html * 404s, but the board's index page is plain server-rendered HTML and links the guide under a * versioned name, user_guide_v1.1.html, which serves 200 — and that guide does publish a * mechanical drawing, * * dl.espressif.com/dl/schematics/esp_idf/DXF_ESP32-S3-DevKitC-1_V1.1_20220429.dxf * 200, image/vnd.dxf, 743,753 bytes, re-checked 2026-08-08. * * So the vendor DOES ship an outline. What it does not ship is a NUMBER. That DXF is drawing * geometry carrying no annotation layer — an entity census of it, * * curl -sL | tr -d '\r' \ * | awk '{sub(/^ +/,"")} p=="0"{c[$0]++} {p=$0} END{for(k in c) print c[k], k}' \ * | sort -rn | head * * returns 2640 LWPOLYLINE, 226 SOLID, 113 TEXT and ZERO DIMENSION entities, and those TEXT * strings are reference designators (R12, U4, J2) plus title-block fields, not callouts. * Getting a width out of that file means loading the outline into CAD and measuring it, which * is a different job from reading a figure off a drawing, and it has not been done here. * dankbuild's hardest rule is that no hardware fact may be invented, so boardW/boardL below * are still declared UNVERIFIED, and the design is deliberately arranged so that being wrong * about them is CHEAP: * * - The board bay is OPEN AT BOTH ENDS. Get the length wrong and the board simply * overhangs; it does not fail to fit. Only the WIDTH is constrained, and it carries * 0.8 mm of clearance, so a full millimetre of error still assembles. * - The breadboard bay uses the standardised half-size (BB400) footprint, which is fixed * by 2.54 mm tie-point pitch rather than by any single vendor's tooling. * - esp32-fit-gauge.stl exists so the guess can be replaced by a MEASUREMENT for about * fifteen minutes of filament, after which this file regenerates an exact plate. * * All units mm. Z=0 is the build plate. Flat bottom, no overhang beyond 3.2 mm walls: * prints without supports, without a raft, and without a brim on any reasonable surface. */ 'use strict'; const { Model } = require('./stl.cjs'); /* ─── PARAMETERS ──────────────────────────────────────────────────────────────── Change these and re-run. Provenance is stated; anything unverified says so. */ const P = { // Half-size (BB400) solderless breadboard. Footprint follows from 2.54 mm pitch and is // consistent across vendors to a few tenths. Clearance is generous: moulded parts have draft. bbL: 83.5, bbW: 54.5, bbClear: 0.6, // THE TIE-POINT PITCH ITSELF, stated once instead of being spelled out in prose three times. // 0.1 inch = 2.54 mm exactly, the through-hole lead spacing every solderless breadboard, // DIP package and 0.1" header is built on. It is the one dimension on this page that is a // published standard rather than a vendor's measurement, which is why the BB400 footprint // above is stated at all — and it is the ONLY input jumperComb() below is allowed to use. pitch: 2.54, // ESP32-S3-DevKitC-1. UNVERIFIED — see the header. The bay is open-ended, so only width // is load-bearing, and width is exactly what the fit gauge measures. boardW: 25.4, boardL: 63.0, boardClear: 0.8, baseT: 2.4, // 6 layers at 0.4 mm — stiff enough not to cup wallT: 2.0, // 5 perimeters — no gap-fill artefacts wallH: 3.2, // retains parts without burying the pin rows margin: 2.0, // outer margin notchW: 12.0, // cable notch in the rear wall // THE TRAY'S WALL HEIGHT — THE ONLY NUMBER IN benchTray() BELOW THAT CAME FROM NOWHERE. // Every other dimension of the tray is either reused from the four lines above (the same // floor, the same wall, the same margin) or typed in by whoever calls it. This one is a // choice and is stated as one: 12 mm. It is not derived from anything, nothing has been // measured against it — no screw, no header strip, no jumper end — and no tray has been // printed to find out whether 12 mm is a good depth to reach into. trayH: 12.0, }; /* ─── THE BENCH PLATE ─────────────────────────────────────────────────────────── Seen from above (+Y is back): ┌──────────────────────────┬─────────────┐ │ │ │ rear wall carries two cable │ breadboard bay │ devkit bay │ notches so jumper wires leave │ │ │ the plate without being pinched └──────────────────────────┴─────────────┘ */ function benchPlate(p = P) { const bbBayW = p.bbL + p.bbClear; const bbBayD = p.bbW + p.bbClear; const dkBayW = p.boardW + p.boardClear; // PLATE DEPTH IS SET BY THE BREADBOARD, NOT THE BOARD. Sizing it to the longer of the two // (the first attempt) gave the breadboard a 63 mm bay for a 54.5 mm part — 8 mm of slop in // the one dimension I actually know. The devkit is instead handled by leaving the front and // rear walls OPEN across its bay, so a board longer than the plate simply overhangs at both // ends and is retained by width alone. That is what makes an unverified length harmless: // there is no wall for it to collide with. // NO SEPARATE `gap` TERM. It used to be added here, and because nothing ever occupied it, // all 4 mm fell into the devkit bay: the board got 4.8 mm of slop instead of 0.8 and would // have rattled. The divider IS the separation between the bays; there is nothing else to // budget for. Caught by test-fit.cjs measuring the finished solids rather than trusting // the arithmetic that produced them. const innerW = bbBayW + p.wallT + dkBayW; const W = innerW + 2 * p.wallT + 2 * p.margin; const D = bbBayD + 2 * p.wallT + 2 * p.margin; const m = new Model(); m.addAt(0, 0, 0, W, D, p.baseT); // base slab const z0 = p.baseT, z1 = p.baseT + p.wallH; const x0 = p.margin, x1 = W - p.margin; const y0 = p.margin, y1 = D - p.margin; const divX = x0 + p.wallT + bbBayW; // divider between the bays const dkOpen = [divX, x1]; // X span left open in the front and rear walls // Cable notches sit over the BREADBOARD half only — the devkit half is already open. const n1 = x0 + bbBayW * 0.22, n2 = x0 + bbBayW * 0.62; m.wallX(x0, x1, y0, z0, z1, p.wallT, [dkOpen]); m.wallX(x0, x1, y1 - p.wallT, z0, z1, p.wallT, [[n1, n1 + p.notchW], [n2, n2 + p.notchW], dkOpen]); // Side walls sit BETWEEN the end walls so no two boxes ever share volume; they meet // face-to-face at the corners, which is legal and prints solid. The right-hand wall must // run the FULL depth, because with the end walls open there is nothing else holding the // devkit bay closed on that side. m.wallY(y0 + p.wallT, y1 - p.wallT, x0, z0, z1, p.wallT); m.wallY(y0, y1, x1 - p.wallT, z0, z1, p.wallT); m.wallY(y0, y1, divX, z0, z1, p.wallT); return { m, meta: { bbBayW, bbBayD, dkBayW, W, D, overhang: p.boardL - D } }; } /* ─── THE FIT GAUGE ───────────────────────────────────────────────────────────── Six U-channels of increasing width. Lower the DevKitC-1 into each from above; the narrowest channel it settles into without forcing IS the board width, to within the 0.4 mm step. A rib count beside each channel identifies it (1 rib = narrowest), so the reading survives the part being picked up and put down. Channels are open at both ends and only 16 mm long, so a short section of the PCB edge is engaged — you measure the board outline rather than fighting the pin headers. RE-CENTRING THE LADDER. The ladder is only useful if it straddles the width you are hunting, and the published band straddles a DevKitC-1-sized board. A caller who is chasing a different board can pass a rough estimate as `centre` and get the same six channels re-centred on it: offsets of ±0.5, ±1.5 and ±2.5 steps, so three channels fall below the estimate and three above and none of them lands exactly on it. That invents no hardware fact — the estimate is the caller's, and the gauge's whole job is to disagree with it if it is wrong. WHY THE DEFAULT IS A LITERAL AND NOT A COMPUTED CENTRE. The published STL must not move. Writing the default band as the literal array it has always been, rather than as a computed centre-and-step, means no rounding path runs at all in the default case and the shipped bytes cannot drift. The channel COUNT is deliberately not a parameter: ribBandW below is sized for exactly six ribs, so a seventh channel would put its rib band under the neighbouring channel. */ function fitGauge(opts = {}) { const step = opts.step == null ? 0.4 : opts.step; // 2 dp keeps binary-float dust out of the mesh: a caller passing 31.7 would otherwise get // 31.7 - 1.5*0.4 = 31.099999999999998 as a channel width. const widths = opts.centre == null ? [24.6, 25.0, 25.4, 25.8, 26.2, 26.6] : [-2.5, -1.5, -0.5, 0.5, 1.5, 2.5].map((k) => Math.round((opts.centre + k * step) * 100) / 100); const chanLen = 16, wallT = 2.0, baseT = 2.4, wallH = 3.0; const ribW = 1.6, ribGap = 1.6, ribLen = 5.0, ribH = 1.2; const pitchPad = 6.0, margin = 3.0; const ribBandW = 6 * (ribW + ribGap) + 2; // room for up to six ribs const cellW = ribBandW + chanLen + margin; // TWO COLUMNS, NOT ONE. Stacking all six channels in a line made a 213 mm strip — it fits a // 220 mm bed only just, wastes most of the plate, and is an awkward thing to handle. A 2x3 // grid is the same six measurements in roughly a third of the footprint. const COLS = 2, ROWS = Math.ceil(widths.length / COLS); const rowPitch = Math.max(...widths) + 2 * wallT + pitchPad; const D = ROWS * rowPitch - pitchPad + 2 * margin; const W = COLS * cellW + margin; const m = new Model(); m.addAt(0, 0, 0, W, D, baseT); const z0 = baseT, z1 = baseT + wallH; const rows = widths.map((w, i) => { const col = i % COLS, row = Math.floor(i / COLS); const cx = margin + col * cellW; const y = margin + row * rowPitch; const chanX0 = cx + ribBandW; m.wallX(chanX0, chanX0 + chanLen, y, z0, z1, wallT); m.wallX(chanX0, chanX0 + chanLen, y + wallT + w, z0, z1, wallT); for (let k = 0; k <= i; k++) m.addAt(cx + k * (ribW + ribGap), y + wallT, z0, ribW, ribLen, ribH); return { w, y, cx, chanX0, chanLen, wallT, ribs: i + 1 }; }); return { m, meta: { widths, rows, W, D, baseT, wallH } }; } /* ─── THE JUMPER-WIRE COMB ────────────────────────────────────────────────────── A flat bar with a row of teeth standing on it. The teeth are on 2.54 mm centres — the tie-point pitch in P above — so wires dropped into consecutive slots come out of the comb at exactly the spacing of consecutive breadboard columns, and stay there while you move the bundle. WHY THIS PART CAN EXIST AT ALL, GIVEN THIS FILE'S RULE ABOUT INVENTED FACTS. Everything in the comb is derived from P.pitch, from the BB400 footprint already in P, from the plate's own base and wall thicknesses, or from the tooth COUNT the caller passes. There is no hardware measurement anywhere in it: tooth thickness = slot width = pitch / 2 = 1.27 mm The pitch has to be split between one tooth and one slot. Splitting it evenly is the WIDEST slot you can cut before the tooth becomes the thinner of the two members, and it needs no fact about any wire to justify. A jacket fatter than 1.27 mm will not enter the slot, and nothing here has measured a jacket — see the parts page, which says so in the same words. tooth depth = 2 * pitch = 5.08 mm (how much of each wire is gripped, along Y) tooth height = P.wallH = 3.2 mm (the plate's wall height, reused) base = P.baseT = 2.4 mm (the plate's base, reused) end pads = P.margin = 2.0 mm each (the plate's margin, reused) THE DEFAULT TOOTH COUNT IS DERIVED, NOT PICKED. Calling jumperComb() with no argument returns the LONGEST comb that still lies inside the breadboard bay of benchPlate() above: the bay is P.bbL + P.bbClear = 84.1 mm, the comb is (2*teeth - 1) * pitch/2 + 2*margin, and 32 teeth is the largest count that fits, at 84.01 mm. So the published STL is not a number somebody liked; it is "as many slots as the plate on this page has room for", and test-fit.cjs asserts that containment against the plate's own bay rather than against a literal. Ask for more teeth than that and the function throws instead of quietly emitting a comb that will not sit in the part it was designed around. */ function jumperComb(opts = {}, p = P) { const half = p.pitch / 2; // tooth thickness AND slot width const maxTeeth = Math.floor(((p.bbL + p.bbClear - 2 * p.margin) / half + 1) / 2); const teeth = opts.teeth == null ? maxTeeth : opts.teeth; if (!Number.isInteger(teeth) || teeth < 2) { throw new Error(`jumperComb: teeth must be a whole number >= 2 (two teeth is one slot), got ${teeth}`); } if (teeth > maxTeeth) { throw new Error(`jumperComb: ${teeth} teeth spans more than the ${(p.bbL + p.bbClear).toFixed(2)} mm ` + `breadboard bay of benchPlate(); the most that fits is ${maxTeeth}`); } const toothD = 2 * p.pitch; const span = (2 * teeth - 1) * half; // first tooth's left face to the last one's right const W = span + 2 * p.margin; const D = toothD + 2 * p.margin; const H = p.baseT + p.wallH; const m = new Model(); m.addAt(0, 0, 0, W, D, p.baseT); // Teeth on exact pitch centres. Neighbours are half a pitch apart, so no two boxes ever // share volume and check()'s interpenetration test stays exact. for (let i = 0; i < teeth; i++) { m.addAt(p.margin + i * p.pitch, p.margin, p.baseT, half, toothD, p.wallH); } return { m, meta: { teeth, maxTeeth, slots: teeth - 1, pitch: p.pitch, toothT: half, slotW: half, toothD, W, D, H, firstToothX: p.margin, toothPitch: p.pitch, }, }; } /* ─── THE BENCH TRAY ──────────────────────────────────────────────────────────────────── An open-top rectangular box for the loose small parts a bench accumulates — cut header strips, jumper ends, M3 screws. Both inside dimensions are the caller's, because how much loose stuff is on your bench is not a fact this repository has or could get. WHY THIS PART IS ALLOWED TO EXIST UNDER THIS FILE'S RULE. It contains no hardware measurement at all. The cavity is exactly the two numbers passed in; the floor, the wall and the outer margin are the plate's own parameters reused rather than re-picked; and the one free number, the wall height, is declared in P above as a choice with no provenance. Nothing here has been printed, test-fitted, or held against a real part, and the page that offers it says so in those words. THE STEPPED CORNER IS THE POINT, NOT STYLING. stl.cjs has no boolean union, so a part is a set of boxes that touch but never share volume, and wherever two boxes meet at a SHARED corner the vertical line at that corner is a real edge of both of them: four triangles along one line. That is why the bench plate above comes out with four four-triangle edges and is not one closed surface. The tray is arranged so that no two of its five boxes share an edge at all. Seen from above (█ is wall, the outer rectangle is the base slab): ┌─────────────────────────────────────────┐ │ │ the two END walls run the FULL width of │█████████████████████████████████████████│ the base, flush with its left and right │ ██ ██ │ edges │ ██ ██ │ │ ██ cavity: innerL × innerW ██ │ the two SIDE walls sit between them and │ ██ ██ │ are inset by `margin`, so each lands on │ ██ ██ │ a face wider than itself — a T-junction, │█████████████████████████████████████████│ not a corner-to-corner butt joint │ │ └─────────────────────────────────────────┘ `margin` of bare base all the way round Every wall box's footprint is strictly inside the base's top face in at least one axis, and each side wall's face where it lands on an end wall is strictly inside that end wall's face in X. No two boxes therefore have a pair of vertices in common, so every undirected edge in the finished mesh belongs to exactly one box and is used by exactly two triangles. The tray is a closed mesh at every size it will build, and test/web-tray-parity.test.cjs counts the edges rather than taking that paragraph's word for it. What it costs is visible on the part: a `margin`-wide ledge of bare base right round the outside, and end walls that reach a `margin` further out in X than the side walls do. Both are real features of the solid. Five boxes, 60 triangles, 90 distinct edges, at every pair of inside dimensions. */ function benchTray(opts = {}, p = P) { const dim = (key, what) => { const v = opts[key]; if (typeof v !== 'number' || !Number.isFinite(v) || v <= 0) { throw new Error(`benchTray: ${what} must be a positive number of millimetres, and it was ` + `${v === undefined ? 'not given' : JSON.stringify(v)}. 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.`); } return v; }; const innerL = dim('innerL', 'the inside length'); const innerW = dim('innerW', 'the inside width'); const t = p.wallT, f = p.baseT, m = p.margin, h = p.trayH; const W = innerL + 2 * t + 2 * m; const D = innerW + 2 * t + 2 * m; const H = f + h; const mo = new Model(); mo.addAt(0, 0, 0, W, D, f); // the floor, one slab, full footprint const z0 = f, z1 = f + h; // End walls: full width, so a side wall landing on one lands strictly inside its face. mo.wallX(0, W, m, z0, z1, t); mo.wallX(0, W, D - m - t, z0, z1, t); // Side walls: inset by the margin, spanning only the gap between the two end walls. mo.wallY(m + t, D - m - t, m, z0, z1, t); mo.wallY(m + t, D - m - t, W - m - t, z0, z1, t); return { m: mo, meta: { innerL, innerW, W, D, H, floorT: f, wallT: t, wallH: h, margin: m, // The cavity as built, in the part's own coordinates, so a caller can measure the hole // rather than re-derive it from the arithmetic above. cavity: { x0: m + t, x1: W - m - t, y0: m + t, y1: D - m - t, z0: f, z1: f + h }, }, }; } /* THE BROWSER'S DOOR, AND WHY IT IS A PROPERTY RATHER THAN A FOURTH EXPORT. tools/print/make-web-geometry.cjs ends the module it generates for the page with a literal `export { P, benchPlate, fitGauge, jumperComb, check, bounds, Model };` line — a fixed list, written before this part existed, that this file cannot add a name to from here. The alternative was for html/electronics/esp32-bench-parts/plate-tool.js to carry its own second copy of the tray, which is precisely the drift that generator exists to prevent: a browser tray that quietly stopped matching this one would still download and still look like a tray. So the tray is hung off P, which IS exported, and the page reaches the same function this file's own tests call. P is a plain bag of parameters that nothing enumerates; benchPlate() and fitGauge() take a copy of it as their parameter block and read only the numbers they name, so an extra function on it is inert everywhere else. */ P.benchTray = benchTray; module.exports = { P, benchPlate, fitGauge, jumperComb, benchTray };