@rulecad/pcb 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +27 -2
  3. package/package.json +37 -4
  4. package/pcb.mjs +408 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 JoniBach and contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,28 @@
1
- # Temporary Holding Version
1
+ # @rulecad/pcb
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Keyboard circuit boards from rules, for `@rulecad/cad` designs with `"engine": "pcb"`. Footprints come from rule sets
4
+ (datasheets, KiCad's library) rather than numbers in code. The keyboard router draws every matrix track by rule, with
5
+ one diode per key on the back. The output is what a fab needs: Gerbers, drill files, a JLCPCB BOM and placement file,
6
+ and KiCad files. Built on [tscircuit](https://github.com/tscircuit/tscircuit).
7
+
8
+ ```js
9
+ // projects/macropad/objects/board.js: main(v) returns a tscircuit board, built with v.pcb
10
+ export function main(v) {
11
+ const keys = v.pcb.grid(v.rows, v.cols, v.mx.pitch);
12
+ return v.pcb.keyboard(v, keys); // { board, matrix }
13
+ }
14
+ ```
15
+
16
+ ```sh
17
+ npm install @rulecad/pcb
18
+ npx cad check macropad/board # tscircuit's checks, JLCPCB's limits, the matrix
19
+ npx cad preview macropad/board # copper from above, the schematic, and the board in 3D
20
+ npx cad export macropad/board # <name>-fab.zip for JLCPCB, KiCad files, the matrix for your firmware
21
+ ```
22
+
23
+ - The board is also a solid, so it takes part in fit checks: switches on the board, plate over it, board in the case.
24
+ - A Pico under the board has its connections autorouted (that takes minutes, then the build cache keeps it).
25
+ Everything else is drawn by rule.
26
+ - tscircuit is pinned (0.0.2885). Until its next release, add `"overrides": { "@tscircuit/math-utils": "0.0.36" }` to
27
+ your package.json.
28
+ - Ordering is your click: `cad export` makes the fab zip and never places an order.
package/package.json CHANGED
@@ -1,6 +1,39 @@
1
1
  {
2
2
  "name": "@rulecad/pcb",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Keyboard circuit boards from rules: every matrix track drawn by rule, footprints from datasheets, Gerbers, KiCad files and a JLCPCB BOM. Built on tscircuit.",
5
+ "type": "module",
6
+ "exports": "./pcb.mjs",
7
+ "files": [
8
+ "pcb.mjs",
9
+ "README.md",
10
+ "LICENSE"
11
+ ],
12
+ "license": "MIT",
13
+ "keywords": [
14
+ "pcb",
15
+ "keyboard",
16
+ "tscircuit",
17
+ "gerber",
18
+ "jlcpcb",
19
+ "kicad"
20
+ ],
21
+ "engines": {
22
+ "node": ">=20.6"
23
+ },
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/JoniBach/rulecad.git",
27
+ "directory": "packages/pcb"
28
+ },
29
+ "dependencies": {
30
+ "@rulecad/cad": "^0.1.0",
31
+ "@jscad/modeling": "^2.12.0",
32
+ "@resvg/resvg-js": "2.6.2",
33
+ "circuit-json-to-gerber": "0.0.113",
34
+ "circuit-json-to-kicad": "0.0.231",
35
+ "circuit-json-to-pnp-csv": "0.0.20",
36
+ "circuit-to-svg": "0.0.450",
37
+ "tscircuit": "0.0.2885"
38
+ }
39
+ }
package/pcb.mjs ADDED
@@ -0,0 +1,408 @@
1
+ // Boards: a design whose .json says "engine": "pcb". Its main(v) returns a tscircuit board (circuits as code, from npm:
2
+ // github.com/tscircuit/tscircuit), which this file builds into Circuit JSON, and from that a solid (previews, fits, the
3
+ // on-screen copy), checks and the files a fab needs. Designs get it as v.pcb.
4
+ //
5
+ // The keyboard: keys in a matrix, one diode each, and a Pico beside them. Nothing is autorouted: the keyboard router
6
+ // draws every track by rule, from the switch's footprint (mx rules) and our margins (the board's track and clear):
7
+ // columns on the front copper: down each column from pin 2 to pin 2, jogging between the centre post and the left
8
+ // peg; then below the keys, right, and up a lane beside the Pico to its pin
9
+ // diodes SOD-123s on the back, under each switch below the centre post; pin 1 runs to the anode up a lane right
10
+ // of the right peg
11
+ // rows on the back copper: cathode to cathode under each row of switches, then right and up or down a lane
12
+ // beside the Pico to its pin
13
+ // Diodes point from the switch to the row (COL2ROW in QMK's terms).
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+ import { Circuit, createElement as h } from 'tscircuit';
17
+ import { loadRuleSet } from '@rulecad/cad/rules';
18
+ import jscad from '@jscad/modeling';
19
+
20
+ export { h };
21
+
22
+ // A rows × cols grid of keys at the key pitch, centred on the origin: [{ r, c, x, y, n }], n counting from 1, row 0
23
+ // at the back (+y)
24
+ export function grid(rows, cols, pitch) {
25
+ const keys = [];
26
+ for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++) keys.push({ r, c, n: r * cols + c + 1, x: (c - (cols - 1) / 2) * pitch, y: ((rows - 1) / 2 - r) * pitch });
27
+ return keys;
28
+ }
29
+
30
+ // ---------- footprints, from rules ----------
31
+ const pad = (pin, x, y, drill, size) => h('platedhole', { portHints: [pin], pcbX: x, pcbY: y, shape: 'circle', holeDiameter: drill, outerDiameter: size });
32
+ const hole = (x, y, d) => h('hole', { pcbX: x, pcbY: y, diameter: d });
33
+
34
+ // An MX switch, PCB mount: two pins, the centre post and the two fixing pegs
35
+ export function mx(name, x, y, m, sch = {}) {
36
+ return h('switch', { name, pcbX: x, pcbY: y, ...sch, manufacturerPartNumber: 'Cherry MX (PCB mount)', footprint: h('footprint', {},
37
+ pad('pin1', m.pcb_pin1_x, m.pcb_pin1_y, m.pcb_pin_drill, m.pcb_pin_pad), pad('pin2', m.pcb_pin2_x, m.pcb_pin2_y, m.pcb_pin_drill, m.pcb_pin_pad),
38
+ hole(0, 0, m.pcb_centre_hole), hole(-m.pcb_peg_x, 0, m.pcb_peg_hole), hole(m.pcb_peg_x, 0, m.pcb_peg_hole),
39
+ h('courtyardrect', { pcbX: 0, pcbY: 0, width: m.pcb_body, height: m.pcb_body })) }); // the housing's footprint on the board
40
+ }
41
+
42
+ // A Raspberry Pi Pico on header pins, USB at the back (+y): pins 1 to 20 down its left edge, 21 to 40 up its right
43
+ export function pico(name, x, y, p, sch = {}) {
44
+ const pins = [];
45
+ for (let n = 1; n <= 40; n++) {
46
+ const left = n <= 20, i = left ? n - 1 : 40 - n;
47
+ pins.push(pad(`pin${n}`, (left ? -1 : 1) * p.pin_rows / 2, (19 / 2 - i) * p.pin_pitch, p.pin_hole, p.pin_pad));
48
+ }
49
+ return h('chip', { name, pcbX: x, pcbY: y, ...sch, manufacturerPartNumber: 'Raspberry Pi Pico', footprint: h('footprint', {}, ...pins, h('courtyardrect', { pcbX: 0, pcbY: 0, width: p.width, height: p.length })) });
50
+ }
51
+ // Where a Pico pin is, from its centre
52
+ export const picoPin = (n, p) => { const left = n <= 20, i = left ? n - 1 : 40 - n; return { x: (left ? -1 : 1) * p.pin_rows / 2, y: (19 / 2 - i) * p.pin_pitch }; };
53
+ // The Pico's GPIO pins down one edge: [{ pin, gpio }]
54
+ export const picoGpio = (side) => Object.entries(loadRuleSet('pico').rules[`gpio_${side}`].pins).map(([pin, gpio]) => ({ pin: Number(pin), gpio }));
55
+
56
+ // ---------- the keyboard ----------
57
+ // Keys from a layout file (lib/layout.mjs keys, in key units) -> [{ r, c, x, y, u, n, label }] in mm. The matrix row is
58
+ // the layout row; the matrix column comes from lining every row up with the widest one: each row's keys take, in order,
59
+ // the columns whose positions they are closest to (so a row with fewer keys leaves the columns it has no key for).
60
+ export function fromLayout(list, pitch) {
61
+ const ks = list.map((k, i) => ({ label: k.label, u: k.u, n: i + 1, r: k.index, x: k.x * pitch, y: k.y * pitch }));
62
+ const rows = [...new Set(ks.map((k) => k.r))].sort((a, b) => a - b), inRow = (r) => ks.filter((k) => k.r === r).sort((a, b) => a.x - b.x);
63
+ const anchors = inRow(rows.reduce((best, r) => (inRow(r).length > inRow(best).length ? r : best), rows[0])).map((k) => k.x);
64
+ for (const r of rows) {
65
+ const row = inRow(r), n = row.length, M = anchors.length, f = [], from = [];
66
+ // f[i][j]: the least total offset placing the first i + 1 keys with key i on column j (columns rising left to right)
67
+ for (let i = 0; i < n; i++) {
68
+ f[i] = []; from[i] = [];
69
+ for (let j = 0; j < M; j++) {
70
+ const cost = Math.abs(row[i].x - anchors[j]);
71
+ if (i === 0) { f[i][j] = cost; continue; }
72
+ let best = Infinity, at = -1;
73
+ for (let q = 0; q < j; q++) if (f[i - 1][q] < best) { best = f[i - 1][q]; at = q; }
74
+ f[i][j] = best + cost; from[i][j] = at;
75
+ }
76
+ }
77
+ let j = f[n - 1].indexOf(Math.min(...f[n - 1]));
78
+ for (let i = n - 1; i >= 0; i--) { row[i].c = j; j = from[i][j]; }
79
+ }
80
+ return ks;
81
+ }
82
+
83
+ // keyboard(v, keys) -> { board (a tscircuit <board>), matrix }; v holds the mx, pico, sod123, m2 and jlcpcb rules and
84
+ // the board's variables:
85
+ // track, clear our track width and the gap we keep to anything
86
+ // margin board edge beyond the keys (and the Pico, beside them)
87
+ // controller beside: the Pico on the front, right of the keys, every track drawn by rule (the macropad)
88
+ // under: the Pico on header pins under the board, components down, turned so its two rows of pins
89
+ // lie in the gaps either side of key row mcu_row + 1, its USB out the mcu_side edge; the matrix is
90
+ // drawn by rule and the connections to the Pico autorouted
91
+ // mcu_gap, mcu_side, mcu_row, mount_holes (1: an M2 hole in each corner), thickness
92
+ // matrix: each key's row and column and the GPIO each row and column uses, for checks and firmware.
93
+ export function keyboard(v, keys) {
94
+ const m = v.mx, p = v.pico, d = v.sod123, lane = v.track + v.clear, half = m.pitch / 2, under = v.controller === 'under';
95
+ const rows = [...new Set(keys.map((k) => k.r))].sort((a, b) => a - b), cols = [...new Set(keys.map((k) => k.c))].sort((a, b) => a - b);
96
+ const keyBox = { left: Math.min(...keys.map((k) => k.x - (k.u || 1) * half)), right: Math.max(...keys.map((k) => k.x + (k.u || 1) * half)),
97
+ top: Math.max(...keys.map((k) => k.y)) + half, bottom: Math.min(...keys.map((k) => k.y)) - half };
98
+ const rowY = (r) => keys.find((k) => k.r === r).y;
99
+
100
+ // Around each switch (from its centre): the diode on the back below the post, its anode lane right of the right peg,
101
+ // the row line below the diodes; the column lane on the front between the post and the left peg
102
+ const anodeX = m.pcb_peg_x + m.pcb_peg_hole / 2 + v.clear + v.track / 2;
103
+ const diode = { x: anodeX - d.pad_x, y: -(m.pcb_centre_hole / 2 + v.clear + d.pad_h / 2) }; // on the back, the anode is at +pad_x
104
+ const cathodeX = anodeX - 2 * d.pad_x, lineY = diode.y - d.pad_h / 2 - v.clear - v.track / 2;
105
+ const jogX = -(m.pcb_centre_hole / 2 + (m.pcb_peg_x - m.pcb_peg_hole / 2 - m.pcb_centre_hole / 2) / 2), slant = Math.abs(m.pcb_pin2_x) - Math.abs(jogX);
106
+ const pegEdge = m.pcb_peg_x + m.pcb_peg_hole / 2 + v.clear + v.track / 2; // a track this far from a key's centre clears its pegs
107
+ // Between key rows r and r + 1 the front copper is free from below row r's posts to above row r + 1's pins
108
+ const bandTop = (r) => rowY(r) - m.pcb_centre_hole / 2 - v.clear - v.track / 2;
109
+ const bandBottom = (r) => rowY(r + 1) + m.pcb_pin1_y + m.pcb_pin_pad / 2 + v.clear + v.track / 2;
110
+
111
+ // ---- the Pico ----
112
+ const SX = 3, SY = 2.5; // schematic grid
113
+ const other = Object.entries(loadRuleSet('pico').rules.other_pins.pins).map(([pin, name]) => ({ pin: Number(pin), gpio: name }));
114
+ const gpio = [...picoGpio('left'), ...picoGpio('right')];
115
+ const pinLabels = Object.fromEntries([...gpio, ...other].map((g) => [`pin${g.pin}`, g.gpio]));
116
+ const sch = { schX: (cols.length + 1.5) * SX, schY: -(rows.length - 1) * SY / 2, pinLabels };
117
+ let left = keyBox.left - v.margin, right = keyBox.right + v.margin, pico0, pinAt, pinRows = [];
118
+ if (under) {
119
+ // Its pin rows in the gaps below key rows mcu_row and mcu_row + 1: clear of the row line above (back copper), the
120
+ // next row's pins below and the switch housings either side, and 0.7 in apart
121
+ const r0 = rows[v.mcu_row], r1 = rows[v.mcu_row + 1], r2 = rows[v.mcu_row + 2];
122
+ if (r2 == null) throw new Error(`mcu_row ${v.mcu_row}: the Pico's pins need the two gaps below it, and there are ${rows.length} rows`);
123
+ const keep = p.pin_pad / 2 + v.clear + v.track / 2;
124
+ const bodyKeep = m.pcb_body / 2 + v.clear + p.pin_pad / 2; // header joints stay out from under the switch housings
125
+ const hi = (r) => Math.min(rowY(r) + lineY - keep, rowY(r) - bodyKeep), lo = (r) => Math.max(rowY(r + 1) + m.pcb_pin1_y + m.pcb_pin_pad / 2 + v.clear + p.pin_pad / 2, rowY(r + 1) + bodyKeep);
126
+ const top = Math.min(hi(r0), hi(r1) + p.pin_rows), bottom = Math.max(lo(r0), lo(r1) + p.pin_rows);
127
+ if (top < bottom) throw new Error('the Pico\'s pin rows don\'t fit between the key rows');
128
+ const ya = (top + bottom) / 2, cy = ya - p.pin_rows / 2, toLeft = v.mcu_side !== 'right';
129
+ const cx = toLeft ? left + p.usb_overhang + p.length / 2 : right - p.usb_overhang - p.length / 2;
130
+ // Seen from above the board the Pico is upside down (mirrored), USB towards mcu_side
131
+ pinAt = (n) => { const q = picoPin(n, p), mx_ = -q.x, my = q.y; return toLeft ? { x: cx - my, y: cy + mx_ } : { x: cx + my, y: cy - mx_ }; };
132
+ pico0 = { x: cx, y: cy, toLeft };
133
+ for (const [gap, ys] of [[r0, ya], [r1, ya - p.pin_rows]]) {
134
+ const xs = Array.from({ length: 40 }, (_, i) => pinAt(i + 1)).filter((q) => Math.abs(q.y - ys) < p.pin_pitch / 2).map((q) => q.x).sort((a, b) => a - b);
135
+ pinRows.push({ gap, y: ys, xs, keep });
136
+ }
137
+ } else {
138
+ const cx = keyBox.right + v.mcu_gap + p.width / 2, cy = (keyBox.top + keyBox.bottom) / 2;
139
+ pinAt = (n) => { const q = picoPin(n, p); return { x: cx + q.x, y: cy + q.y }; };
140
+ pico0 = { x: cx, y: cy };
141
+ right = cx + p.width / 2 + v.margin;
142
+ }
143
+ const picoPart = h('chip', { name: 'U1', pcbX: pico0.x, pcbY: pico0.y, ...sch, manufacturerPartNumber: 'Raspberry Pi Pico',
144
+ footprint: h('footprint', {}, ...Array.from({ length: 40 }, (_, i) => { const q = pinAt(i + 1); return pad(`pin${i + 1}`, q.x - pico0.x, q.y - pico0.y, p.pin_hole, p.pin_pad); }),
145
+ ...(under ? [] : [h('courtyardrect', { pcbX: 0, pcbY: 0, width: p.width, height: p.length })])) });
146
+
147
+ const parts = [picoPart], traces = [];
148
+ const rel = (pts, ox, oy) => pts.map(([x, y]) => ({ x: x - ox, y: y - oy })); // tscircuit paths are relative to the part they start from
149
+ for (const k of keys) {
150
+ parts.push(mx(`K${k.n}`, k.x, k.y, m, { schX: k.c * SX, schY: -k.r * SY }), h('diode', { name: `D${k.n}`, footprint: 'sod123', schX: k.c * SX + 1, schY: -k.r * SY - 0.8, schRotation: 270, pcbX: k.x + diode.x, pcbY: k.y + diode.y, layer: 'bottom', manufacturerPartNumber: '1N4148W' }));
151
+ traces.push(h('trace', { from: `.D${k.n} > .anode`, to: `.K${k.n} > .pin1`, thickness: v.track, pcbPath: rel([[k.x + anodeX, k.y + m.pcb_pin1_y]], k.x + diode.x, k.y + diode.y) }));
152
+ }
153
+
154
+ // ---- rows: cathode to cathode along the back ----
155
+ for (const r of rows) {
156
+ const ks = keys.filter((k) => k.r === r).sort((a, b) => a.x - b.x);
157
+ for (let i = 1; i < ks.length; i++) {
158
+ const a = ks[i - 1], b = ks[i];
159
+ traces.push(h('trace', { from: `.D${a.n} > .cathode`, to: `.D${b.n} > .cathode`, thickness: v.track, pcbPath: rel([[a.x + cathodeX, a.y + lineY], [b.x + cathodeX, b.y + lineY]], a.x + diode.x, a.y + diode.y) }));
160
+ }
161
+ }
162
+
163
+ // ---- columns: pin 2 to pin 2 down the front ----
164
+ // Down the lane between post and left peg; between rows, across the free band to the next key's lane (a key of
165
+ // another column in the way: through the gap between two keys of that row); past the Pico's pins, between two of them
166
+ const corridor = (r, x) => {
167
+ const ks = keys.filter((k) => k.r === r).sort((a, b) => a.x - b.x), gaps = [];
168
+ for (let i = 1; i < ks.length; i++) gaps.push([ks[i - 1].x + pegEdge, ks[i].x - pegEdge]);
169
+ gaps.push([-Infinity, ks[0].x - pegEdge], [ks.at(-1).x + pegEdge, Infinity]);
170
+ const ok = gaps.filter(([a, b]) => b - a > 0).map(([a, b]) => Math.min(Math.max(x, a + v.track), b - v.track));
171
+ return ok.sort((a, b) => Math.abs(a - x) - Math.abs(b - x))[0];
172
+ };
173
+ const throughPins = (pr, a, b, t) => {
174
+ if (Math.max(a, b) < pr.xs[0] - p.pin_pitch / 2 || Math.min(a, b) > pr.xs.at(-1) + p.pin_pitch / 2) return null;
175
+ const mids = pr.xs.slice(1).map((x, i) => (x + pr.xs[i]) / 2);
176
+ return mids.sort((u, w) => Math.abs(u - t) - Math.abs(w - t))[0];
177
+ };
178
+ const columnPath = (A, B) => {
179
+ let x = A.x + jogX;
180
+ const pts = [[x, A.y + m.pcb_pin2_y - slant]];
181
+ for (let r = A.r; r < B.r; r++) {
182
+ const next = rows[rows.indexOf(r) + 1], target = next === B.r ? B.x + jogX : corridor(next, x);
183
+ pts.push([x, bandTop(r)]);
184
+ const pr = pinRows.find((q) => q.gap === r), xg = pr && throughPins(pr, x, target, target);
185
+ if (xg != null) pts.push([xg, pr.y + pr.keep], [xg, pr.y - pr.keep]);
186
+ pts.push([target, bandBottom(r)]);
187
+ x = target;
188
+ if (next === B.r) break;
189
+ r = next - 1;
190
+ }
191
+ pts.push([x, B.y + m.pcb_pin2_y + slant]);
192
+ return pts;
193
+ };
194
+ for (const c of cols) {
195
+ const ks = keys.filter((k) => k.c === c).sort((a, b) => a.r - b.r);
196
+ for (let j = 1; j < ks.length; j++) {
197
+ const a = ks[j - 1], b = ks[j];
198
+ traces.push(h('trace', { from: `.K${a.n} > .pin2`, to: `.K${b.n} > .pin2`, thickness: v.track, pcbPath: rel(columnPath(a, b), a.x, a.y) }));
199
+ }
200
+ }
201
+
202
+ // ---- the Pico's connections ----
203
+ const rowPin = {}, colPin = {};
204
+ if (under) {
205
+ // Each row and column takes the free GPIO nearest it, nearest pairs first; then a track the autorouter finds
206
+ const ends = [...rows.map((r) => ({ kind: 'row', id: r, at: keys.filter((k) => k.r === r).map((k) => ({ x: k.x + cathodeX, y: k.y + diode.y, sel: `.D${k.n} > .cathode` })) })),
207
+ ...cols.map((c) => ({ kind: 'col', id: c, at: keys.filter((k) => k.c === c).map((k) => ({ x: k.x + m.pcb_pin2_x, y: k.y + m.pcb_pin2_y, sel: `.K${k.n} > .pin2` })) }))];
208
+ const free = [...gpio];
209
+ const dist = (e, g) => Math.min(...e.at.map((q) => Math.hypot(q.x - pinAt(g.pin).x, q.y - pinAt(g.pin).y)));
210
+ const left_ = [...ends];
211
+ while (left_.length) {
212
+ if (!free.length) throw new Error(`the Pico has ${gpio.length} GPIO; this matrix needs ${ends.length}`);
213
+ let best = null;
214
+ for (const e of left_) for (const g of free) { const dd = dist(e, g); if (!best || dd < best.dd) best = { e, g, dd }; }
215
+ left_.splice(left_.indexOf(best.e), 1); free.splice(free.indexOf(best.g), 1);
216
+ (best.e.kind === 'row' ? rowPin : colPin)[best.e.id] = best.g;
217
+ const q = pinAt(best.g.pin), near = [...best.e.at].sort((a, b) => Math.hypot(a.x - q.x, a.y - q.y) - Math.hypot(b.x - q.x, b.y - q.y))[0];
218
+ traces.push(h('trace', { from: near.sel, to: `.U1 > .pin${best.g.pin}`, thickness: v.track }));
219
+ }
220
+ } else {
221
+ // Beside: rows take the free left-edge pins nearest their row line, columns the lowest of the rest; every track
222
+ // drawn: rows right then up or down a lane beside the Pico (back), columns below the keys and up a lane (front),
223
+ // the leftmost column lowest and nearest the Pico, so no two cross
224
+ const nearPins = pinAt(1).x - p.pin_pad / 2 - v.clear - v.track / 2, free = picoGpio('left'), take = (pin) => free.splice(free.findIndex((g) => g.pin === pin), 1)[0];
225
+ for (const r of rows) {
226
+ const y = rowY(r) + lineY, best = [...free].sort((a, b) => Math.abs(pinAt(a.pin).y - y) - Math.abs(pinAt(b.pin).y - y))[0];
227
+ if (!best) throw new Error(`the Pico's left edge has no GPIO left for row ${r}`);
228
+ rowPin[r] = take(best.pin);
229
+ }
230
+ const lowest = [...free].sort((a, b) => pinAt(a.pin).y - pinAt(b.pin).y);
231
+ if (lowest.length < cols.length) throw new Error(`${cols.length} columns need ${cols.length} more GPIO on the Pico's left edge; ${lowest.length} are left`);
232
+ cols.forEach((c, i) => { colPin[c] = lowest[i]; });
233
+ for (const r of rows) {
234
+ const last = keys.filter((k) => k.r === r).sort((a, b) => a.x - b.x).at(-1), pinY = pinAt(rowPin[r].pin).y;
235
+ traces.push(h('trace', { from: `.D${last.n} > .cathode`, to: `.U1 > .pin${rowPin[r].pin}`, thickness: v.track,
236
+ pcbPath: rel([[last.x + cathodeX, last.y + lineY], [nearPins - lane, last.y + lineY], [nearPins - lane, pinY]], last.x + diode.x, last.y + diode.y) }));
237
+ }
238
+ cols.forEach((c, i) => {
239
+ const last = keys.filter((k) => k.c === c).sort((a, b) => b.r - a.r)[0], depth = keyBox.bottom - (cols.length - i) * lane, laneX = nearPins - i * lane;
240
+ traces.push(h('trace', { from: `.K${last.n} > .pin2`, to: `.U1 > .pin${colPin[c].pin}`, thickness: v.track,
241
+ pcbPath: rel([[last.x + jogX, last.y + m.pcb_pin2_y - slant], [last.x + jogX, depth], [laneX, depth], [laneX, pinAt(colPin[c].pin).y]], last.x, last.y) }));
242
+ });
243
+ }
244
+
245
+ // ---- the board ----
246
+ const lanesBelow = under ? 0 : cols.length * lane;
247
+ const top = Math.max(keyBox.top, under ? -Infinity : pico0.y + p.length / 2) + v.margin;
248
+ const bottom = Math.min(keyBox.bottom - lanesBelow, under ? Infinity : pico0.y - p.length / 2) - v.margin;
249
+ const cx = (left + right) / 2, cy = (top + bottom) / 2, inset = v.margin / 2;
250
+ const corners = v.mount_holes ? [[left + inset, top - inset], [right - inset, top - inset], [left + inset, bottom + inset], [right - inset, bottom + inset]] : [];
251
+ // Silkscreen for whoever solders it: the Pico's outline (on the side it's fitted) with USB marked, each switch's
252
+ // outline, and a bar at each diode's cathode (on the back, between its pads, next to the cathode)
253
+ const silk = v.jlcpcb.silk_line * 2, text = v.jlcpcb.silk_text * 2;
254
+ const outline = (x, y, w, ht, layer = 'top') => [h('silkscreenrect', { pcbX: x, pcbY: y + ht / 2, width: w, height: silk, layer }), h('silkscreenrect', { pcbX: x, pcbY: y - ht / 2, width: w, height: silk, layer }),
255
+ h('silkscreenrect', { pcbX: x - w / 2, pcbY: y, width: silk, height: ht, layer }), h('silkscreenrect', { pcbX: x + w / 2, pcbY: y, width: silk, height: ht, layer })];
256
+ const usbAt = under ? { x: pico0.x + (pico0.toLeft ? -1 : 1) * (p.length / 2 - text * 1.5), y: pico0.y } : { x: pico0.x, y: pico0.y + p.length / 2 - text };
257
+ const marks = [...(under ? outline(pico0.x, pico0.y, p.length, p.width, 'bottom') : outline(pico0.x, pico0.y, p.width, p.length)),
258
+ h('silkscreentext', { text: 'USB', pcbX: usbAt.x, pcbY: usbAt.y, fontSize: text, layer: under ? 'bottom' : 'top' }),
259
+ ...keys.flatMap((k) => outline(k.x, k.y, m.pcb_body, m.pcb_body)),
260
+ ...keys.map((k) => h('silkscreenrect', { pcbX: k.x + cathodeX + d.pad_w / 2 + silk * 2, pcbY: k.y + diode.y, width: silk, height: d.pad_h, layer: 'bottom' })),
261
+ ...(v.margin >= text * 2 ? [h('silkscreentext', { text: 'rulecad: diodes point to the rows', pcbX: (keyBox.left + keyBox.right) / 2, pcbY: bottom + v.margin / 2, fontSize: text, layer: 'bottom' })] : [])];
262
+ const board = h('board', { width: right - left, height: top - bottom, pcbX: cx, pcbY: cy, thickness: v.thickness, ...(under ? {} : { autorouter: 'none' }) },
263
+ ...parts, ...traces, ...marks, ...corners.map(([x, y]) => hole(x, y, v.m2.clearance)));
264
+ const matrix = {
265
+ keys: keys.map((k) => ({ key: `K${k.n}`, ...(k.label ? { label: k.label } : {}), row: k.r, col: k.c })),
266
+ rows: Object.fromEntries(rows.map((r) => [r, rowPin[r].gpio])), cols: Object.fromEntries(cols.map((c) => [c, colPin[c].gpio])),
267
+ ...(under ? { pico: { x: pico0.x, y: pico0.y, usb: pico0.toLeft ? 'left' : 'right' } } : {}),
268
+ };
269
+ return { board, matrix };
270
+ }
271
+
272
+ // ---------- building ----------
273
+ // render(board) -> Circuit JSON. A board element's own positions are relative to the board's centre in tscircuit, so
274
+ // the board is wrapped at the origin and its parts placed absolutely.
275
+ export async function render(board) {
276
+ const circuit = new Circuit();
277
+ const { pcbX = 0, pcbY = 0, ...props } = board.props, kids = [board.props.children].flat(Infinity).filter(Boolean);
278
+ // tscircuit places children relative to the board's centre: move them so the numbers given stay absolute
279
+ const moved = kids.map((k) => h(k.type, { ...k.props, ...(k.props.pcbX != null ? { pcbX: k.props.pcbX - pcbX } : {}), ...(k.props.pcbY != null ? { pcbY: k.props.pcbY - pcbY } : {}) }));
280
+ const { children, ...rest } = props;
281
+ circuit.add(h('board', rest, ...moved));
282
+ await circuit.renderUntilSettled();
283
+ const json = circuit.getCircuitJson();
284
+ // Put it back where it was drawn, so the board sits around the numbers in the design
285
+ return pcbX || pcbY ? json.map((e) => shiftElement(e, pcbX, pcbY)) : json;
286
+ }
287
+
288
+ // Every x/y in a Circuit JSON element moved by (dx, dy)
289
+ function shiftElement(e, dx, dy) {
290
+ const move = (o) => {
291
+ if (Array.isArray(o)) return o.map(move);
292
+ if (!o || typeof o !== 'object') return o;
293
+ const out = {};
294
+ for (const [k, v] of Object.entries(o)) out[k] = (k === 'x' && typeof v === 'number') ? v + dx : (k === 'y' && typeof v === 'number') ? v + dy : move(v);
295
+ return out;
296
+ };
297
+ return e.type?.startsWith('schematic_') ? e : move(e);
298
+ }
299
+
300
+ // The board as a solid (previews, fits, the on-screen copy, the model page): the bare board, drilled, bottom at z = 0. Its
301
+ // copper is drawn in the 2D views (views), not in 3D. The board is a 2D outline with its holes, extruded: its faces
302
+ // triangulate cleanly (cylinders subtracted from a block leave long slivers).
303
+ export function solids(json, { thickness, smooth }) {
304
+ const { rectangle, circle } = jscad.primitives, { subtract } = jscad.booleans, { extrudeLinear } = jscad.extrusions;
305
+ const b = json.find((e) => e.type === 'pcb_board');
306
+ const drills = json.filter((e) => e.type === 'pcb_hole' || e.type === 'pcb_plated_hole' || e.type === 'pcb_via')
307
+ .map((e) => circle({ center: [e.x, e.y], radius: (e.hole_diameter ?? e.hole_width) / 2, segments: smooth }));
308
+ const outline = rectangle({ center: [b.center.x, b.center.y], size: [b.width, b.height] });
309
+ return { board: extrudeLinear({ height: thickness }, drills.length ? subtract(outline, ...drills) : outline) };
310
+ }
311
+
312
+ // What a built board tells the parts around it, as numbers an assembly can use (board.pico_y): its outline (centre and
313
+ // size, mm, in the layout's frame) and, with the Pico under it, the Pico's centre
314
+ export function outputs(json, matrix) {
315
+ const b = json.find((e) => e.type === 'pcb_board');
316
+ return { board_x: b.center.x, board_y: b.center.y, board_w: b.width, board_d: b.height, ...(matrix?.pico ? { pico_x: matrix.pico.x, pico_y: matrix.pico.y } : {}) };
317
+ }
318
+
319
+ // ---------- checks ----------
320
+ // checks(json, v) -> findings ({ level, text }): tscircuit's own design checks, the fab's limits, and the matrix
321
+ export async function checks(json, v, matrix) {
322
+ const { runAllChecks } = await import('@tscircuit/checks');
323
+ const out = [], j = v.jlcpcb;
324
+ const drc = await runAllChecks(json), problems = [...json, ...drc].filter((e) => /_error$/.test(e.type));
325
+ const seen = new Set(), errors = problems.filter((e) => !seen.has(e.message) && seen.add(e.message));
326
+ out.push(errors.length ? { level: 'fail', text: `${errors.length} design-rule errors: ${errors.slice(0, 3).map((e) => e.message).join('; ')}` } : { level: 'pass', text: 'tscircuit\'s design checks: no errors (traces, clearances, holes, board edge)' });
327
+ const traces = json.filter((e) => e.type === 'pcb_trace'), unrouted = json.filter((e) => e.type === 'source_trace').length - traces.length;
328
+ out.push(unrouted > 0 ? { level: 'fail', text: `${unrouted} connections have no track` } : { level: 'pass', text: `every connection is routed (${traces.length} tracks, ${json.filter((e) => e.type === 'pcb_via').length} vias)` });
329
+ const thin = traces.flatMap((t) => t.route).filter((p) => p.width != null && p.width < j.track_min);
330
+ out.push(thin.length ? { level: 'fail', text: `tracks under JLCPCB's ${j.track_min} mm` } : { level: 'pass', text: `tracks ${v.track} mm, gaps ${v.clear} mm: over JLCPCB's ${j.track_min} / ${j.gap_min} mm` });
331
+ const drills = json.filter((e) => e.type === 'pcb_hole' || e.type === 'pcb_plated_hole').map((e) => e.hole_diameter ?? e.hole_width);
332
+ out.push(Math.min(...drills) < j.drill_min ? { level: 'fail', text: `a hole under JLCPCB's ${j.drill_min} mm` } : { level: 'pass', text: `${drills.length} holes, smallest ${Math.min(...drills)} mm (JLCPCB: ${j.drill_min} mm)` });
333
+ const b = json.find((e) => e.type === 'pcb_board'), size = `${b.width.toFixed(1)} × ${b.height.toFixed(1)} mm`;
334
+ out.push(Math.max(b.width, b.height) <= j.cheap_size ? { level: 'pass', text: `${size}: within JLCPCB's lowest price (${j.cheap_size} × ${j.cheap_size} mm)` } : { level: 'info', text: `${size}: over ${j.cheap_size} × ${j.cheap_size} mm, priced by quote` });
335
+ if (matrix) {
336
+ const cells = new Set(matrix.keys.map((k) => `${k.row},${k.col}`)), gpio = [...Object.values(matrix.rows), ...Object.values(matrix.cols)];
337
+ out.push(cells.size === matrix.keys.length && new Set(gpio).size === gpio.length
338
+ ? { level: 'pass', text: `matrix: ${matrix.keys.length} keys on ${Object.keys(matrix.rows).length} rows × ${Object.keys(matrix.cols).length} columns, ${gpio.length} GPIO (rows ${Object.values(matrix.rows).join(' ')}; columns ${Object.values(matrix.cols).join(' ')})` }
339
+ : { level: 'fail', text: 'matrix: two keys share a row and column, or a GPIO is used twice' });
340
+ }
341
+ const notes = new Set([...json, ...drc].filter((e) => /_warning$/.test(e.type)).map((e) => e.type.replace(/^(source|pcb)_|_warning$/g, '').replace(/_/g, ' ')));
342
+ if (notes.size) out.push({ level: 'info', text: `tscircuit notes, none about the copper: ${[...notes].join(', ')}` });
343
+ return out;
344
+ }
345
+
346
+ // ---------- files ----------
347
+ // The fab files: Gerbers (KiCad's Protel names, which JLCPCB reads), drill files, BOM and placement -> { name: text }
348
+ const PROTEL = { F_Cu: 'GTL', B_Cu: 'GBL', F_Mask: 'GTS', B_Mask: 'GBS', F_SilkScreen: 'GTO', B_SilkScreen: 'GBO', F_Paste: 'GTP', B_Paste: 'GBP', Edge_Cuts: 'GKO' };
349
+ export async function fabFiles(json, name) {
350
+ const { convertSoupToGerberCommands, stringifyGerberCommandLayers, convertSoupToExcellonDrillCommands, stringifyExcellonDrill } = await import('circuit-json-to-gerber');
351
+ const { convertCircuitJsonToPickAndPlaceCsv } = await import('circuit-json-to-pnp-csv');
352
+ const out = {};
353
+ for (const [layer, text] of Object.entries(stringifyGerberCommandLayers(convertSoupToGerberCommands(json)))) if (PROTEL[layer]) out[`${name}.${PROTEL[layer]}`] = text; // fab notes (B_Fab) stay out
354
+ out[`${name}-PTH.DRL`] = stringifyExcellonDrill(convertSoupToExcellonDrillCommands({ circuitJson: json, is_plated: true }));
355
+ out[`${name}-NPTH.DRL`] = stringifyExcellonDrill(convertSoupToExcellonDrillCommands({ circuitJson: json, is_plated: false }));
356
+ out[`${name}-bom.csv`] = bom(json);
357
+ out[`${name}-cpl.csv`] = convertCircuitJsonToPickAndPlaceCsv(json);
358
+ return out;
359
+ }
360
+
361
+ // The BOM in JLCPCB's columns (Comment, Designator, Footprint, LCSC Part #), one line per kind of part. The LCSC
362
+ // numbers are blank until parts are chosen for assembly; a hand-soldered board doesn't need them.
363
+ const FOOTPRINT = { K: 'Cherry MX, PCB mount', U: 'Raspberry Pi Pico, 0.1 in header pins', D: 'SOD-123' };
364
+ function bom(json) {
365
+ const lines = new Map(), q = (x) => `"${String(x).replace(/"/g, '""')}"`;
366
+ for (const c of json.filter((e) => e.type === 'source_component')) {
367
+ const comment = c.manufacturer_part_number || c.name, footprint = FOOTPRINT[c.name[0]] || '', key = `${comment}|${footprint}`;
368
+ if (!lines.has(key)) lines.set(key, { comment, footprint, refs: [] });
369
+ lines.get(key).refs.push(c.name);
370
+ }
371
+ return ['Comment,Designator,Footprint,LCSC Part #', ...[...lines.values()].map((l) => [l.comment, l.refs.join(','), l.footprint, ''].map(q).join(','))].join('\n') + '\n';
372
+ }
373
+
374
+ // KiCad's own files, to check with kicad-cli or finish by hand: { 'name.kicad_pcb': text, 'name.kicad_sch': text }
375
+ export async function kicadFiles(json, name) {
376
+ const { CircuitJsonToKicadPcbConverter, CircuitJsonToKicadSchConverter } = await import('circuit-json-to-kicad');
377
+ const pcb = new CircuitJsonToKicadPcbConverter(json); pcb.runUntilFinished();
378
+ const sch = new CircuitJsonToKicadSchConverter(json); sch.runUntilFinished();
379
+ return { [`${name}.kicad_pcb`]: pcb.getOutputString(), [`${name}.kicad_sch`]: sch.getOutputString() };
380
+ }
381
+
382
+ // The 2D views for the model page, as SVG drawings: [[name, svg]]. "Group: view" names group into one tab with a
383
+ // choice inside it (Board: both sides, front, back).
384
+ export async function views(json) {
385
+ const { convertCircuitJsonToPcbSvg, convertCircuitJsonToSchematicSvg } = await import('circuit-to-svg');
386
+ return [
387
+ ['Board: both sides', convertCircuitJsonToPcbSvg(json)],
388
+ ['Board: front', convertCircuitJsonToPcbSvg(json, { layer: 'top' })],
389
+ ['Board: back', convertCircuitJsonToPcbSvg(json, { layer: 'bottom' })],
390
+ ['Schematic', convertCircuitJsonToSchematicSvg(json)],
391
+ ];
392
+ }
393
+
394
+ // The schematic as a PNG
395
+ export async function schematicPng(json, out, width = 1600) {
396
+ const { convertCircuitJsonToSchematicSvg } = await import('circuit-to-svg'), { Resvg } = await import('@resvg/resvg-js');
397
+ fs.mkdirSync(path.dirname(out), { recursive: true });
398
+ fs.writeFileSync(out, new Resvg(convertCircuitJsonToSchematicSvg(json), { fitTo: { mode: 'width', value: width } }).render().asPng());
399
+ return out;
400
+ }
401
+
402
+ // The board drawn from above as a PNG (front copper, back copper, silkscreen, holes)
403
+ export async function png(json, out, width = 1600) {
404
+ const { convertCircuitJsonToPcbSvg } = await import('circuit-to-svg'), { Resvg } = await import('@resvg/resvg-js');
405
+ fs.mkdirSync(path.dirname(out), { recursive: true });
406
+ fs.writeFileSync(out, new Resvg(convertCircuitJsonToPcbSvg(json), { fitTo: { mode: 'width', value: width } }).render().asPng());
407
+ return out;
408
+ }