@rulecad/pcb 0.1.2 → 0.1.3

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/README.md +13 -6
  2. package/circuit.mjs +277 -0
  3. package/package.json +11 -3
  4. package/pcb.mjs +38 -63
package/README.md CHANGED
@@ -3,10 +3,16 @@
3
3
  Keyboard circuit boards from rules, for `@rulecad/cad` designs with `"engine": "pcb"`. Footprints come from rule sets
4
4
  (datasheets, KiCad's library) rather than numbers in code. The keyboard router draws every matrix track by rule, with
5
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).
6
+ and KiCad files.
7
+
8
+ **No framework.** The board is written directly as [Circuit JSON](https://github.com/tscircuit/circuit-json), an open
9
+ data format, and the work is done by tscircuit's framework-free libraries: footprints (footprinter), the autorouter,
10
+ the design checks, and the Gerber, KiCad and drawing exporters. React, which tscircuit's own board-building layer uses,
11
+ is never loaded. Two of those libraries list tscircuit among their dependencies, so React may be installed alongside
12
+ them, unused; rulecad's tests fail if it's ever loaded.
7
13
 
8
14
  ```js
9
- // projects/macropad/objects/board.js: main(v) returns a tscircuit board, built with v.pcb
15
+ // projects/macropad/objects/board.js: main(v) returns a board, built with v.pcb
10
16
  export function main(v) {
11
17
  const keys = v.pcb.grid(v.rows, v.cols, v.mx.pitch);
12
18
  return v.pcb.keyboard(v, keys); // { board, matrix }
@@ -21,9 +27,10 @@ npx cad export macropad/board # <name>-fab.zip for JLCPCB, KiCad files, the
21
27
  ```
22
28
 
23
29
  - 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).
30
+ - A Pico under the board has its connections autorouted (that needs Node 22 or newer; boards drawn wholly by rule build on
31
+ Node 20 too). That takes minutes; then the build cache keeps it.
25
32
  Everything else is drawn by rule.
26
- - tscircuit is pinned (0.0.2895), so a board builds the same until rulecad moves it on; each move is checked against
27
- boards built before it (same tracks, same checks). Nothing to add to your package.json: 0.1.1 needed an override
28
- on a fresh install, 0.1.2 doesn't.
33
+ - The autorouter and footprint versions are pinned, so a board builds the same until rulecad moves them on; each move is
34
+ checked against boards built before it (same tracks, same checks, same fab files). Nothing to add to your
35
+ package.json.
29
36
  - Ordering is your click: `cad export` makes the fab zip and never places an order.
package/circuit.mjs ADDED
@@ -0,0 +1,277 @@
1
+ // Circuit JSON, the open data format tscircuit's tools read (github.com/tscircuit/circuit-json), built directly from a
2
+ // plain description of a board, with no React. The fab exporters, the design checks, the drawings and KiCad export all
3
+ // read the result. Only two tscircuit libraries are used, and neither needs a framework: footprinter (standard
4
+ // footprints such as SOD-123) and the capacity autorouter (connections a board leaves to it).
5
+ //
6
+ // board: { center: { x, y }, width, height, thickness, parts, traces, silk, holes, autoroute }
7
+ // part { name, ftype: 'simple_switch' | 'simple_chip' | 'simple_diode', mpn, x, y, layer: 'top' | 'bottom',
8
+ // pads: [{ pin, x, y, hole, outer }] (plated, relative to the part), holes: [{ x, y, d }],
9
+ // courtyard: { w, h } | null, footprint: 'sod123' (a footprinter string, instead of pads),
10
+ // sch: { x, y, symbol?, pinLabels? } }
11
+ // trace { from: [part, port], to: [part, port], width, path: [{ x, y }] (absolute, between the two ports) }; a trace
12
+ // without a path is left to the autorouter when board.autoroute is set
13
+ // silk { rect: { x, y, w, h }, layer } | { text, x, y, size, layer }
14
+ // holes [{ x, y, d }] in the board (mounting holes)
15
+ // All positions are in mm, in the board's own frame. A part on the bottom is seen from above: mirrored in x.
16
+ import { fp } from '@tscircuit/footprinter';
17
+ import { symbols } from 'schematic-symbols';
18
+
19
+ const SUB = 'subcircuit_source_group_0';
20
+ const PASTE = 0.7; // solder paste, as a share of an SMD pad (what tscircuit uses)
21
+ // The board's manufacturing minimums (mm): tscircuit's defaults, within what common fabs make. The router keeps to them.
22
+ const FAB_MINIMUMS = { min_trace_width: 0.1, min_via_hole_diameter: 0.2, min_via_pad_diameter: 0.3, min_via_hole_edge_to_via_hole_edge_clearance: 0.1,
23
+ min_trace_to_hole_edge_clearance: 0.2, min_trace_to_pad_edge_clearance: 0.1, min_pad_edge_to_pad_edge_clearance: 0.1,
24
+ min_plated_hole_drill_edge_to_drill_edge_clearance: 0.15, min_board_edge_clearance: 0.2 };
25
+
26
+ export async function circuitJson(board, { route = defaultRouter } = {}) {
27
+ const out = [], next = {}, id = (type) => `${type}_${(next[type] = (next[type] ?? -1) + 1)}`;
28
+ const add = (type, fields) => { const e = { type, [`${type}_id`]: id(type), ...fields }; out.push(e); return e; };
29
+
30
+ add('source_project_metadata', { software_used_string: '@rulecad/pcb' });
31
+ add('source_group', { is_subcircuit: true, was_automatically_named: true, subcircuit_id: SUB });
32
+ const sourceBoard = add('source_board', { source_group_id: 'source_group_0' });
33
+
34
+ // ---- parts: source records, then their footprints on the board ----
35
+ const ports = new Map(); // `${part}.${port name or hint}` -> { source, pcb, x, y, layers }
36
+ const placed = [];
37
+ for (const part of board.parts) {
38
+ const comp = add('source_component', { ftype: part.ftype, name: part.name, manufacturer_part_number: part.mpn,
39
+ are_pins_interchangeable: part.ftype === 'simple_switch', source_group_id: 'source_group_0', subcircuit_id: SUB });
40
+ const fpEls = part.footprint ? fp.string(part.footprint).circuitJson() : [];
41
+ const mirror = part.layer === 'bottom' ? -1 : 1;
42
+ const at = (x, y) => ({ x: part.x + mirror * x, y: part.y + y });
43
+ // Pads: plated holes from the description, or a footprint's SMD pads (pin n: hints ['n'])
44
+ const pads = part.footprint
45
+ ? fpEls.filter((e) => e.type === 'pcb_smtpad').map((e) => ({ smd: true, pin: `pin${e.port_hints[0]}`, hint: e.port_hints[0], ...at(e.x, e.y), w: e.width, h: e.height }))
46
+ : part.pads.map((p) => ({ ...p, ...at(p.x, p.y) }));
47
+ const names = portNames(part);
48
+ const sourcePorts = pads.map((p, i) => add('source_port', { name: names[i].name, pin_number: names[i].n, port_hints: names[i].hints,
49
+ source_component_id: comp.source_component_id, subcircuit_id: SUB }));
50
+ if (part.ftype === 'simple_chip') { // pins with the same label are joined inside the part (a Pico's GND pins)
51
+ const byName = new Map(); for (const sp of sourcePorts) byName.set(sp.name, [...(byName.get(sp.name) || []), sp]); // (Object.groupBy needs Node 21)
52
+ const groups = [...byName.values()].filter((g) => g.length > 1);
53
+ if (groups.length) comp.internally_connected_source_port_ids = groups.map((g) => g.map((s) => s.source_port_id));
54
+ for (const g of groups) add('source_component_internal_connection', { source_component_id: comp.source_component_id, subcircuit_id: SUB, source_port_ids: g.map((s) => s.source_port_id) });
55
+ }
56
+ placed.push({ part, comp, pads, sourcePorts, fpEls, at, mirror });
57
+ }
58
+ add('pcb_board', { source_board_id: sourceBoard.source_board_id, subcircuit_id: SUB, center: board.center, thickness: board.thickness, num_layers: 2,
59
+ width: board.width, height: board.height, material: 'fr4', ...FAB_MINIMUMS });
60
+
61
+ for (const { part, comp, pads, sourcePorts, fpEls, at, mirror } of placed) {
62
+ // The component sits at the middle of its pads and holes (as tscircuit places it: pick-and-place reads this)
63
+ const extent = [...pads.map((p) => (p.smd ? [p.x - p.w / 2, p.y - p.h / 2, p.x + p.w / 2, p.y + p.h / 2] : [p.x - p.outer / 2, p.y - p.outer / 2, p.x + p.outer / 2, p.y + p.outer / 2])),
64
+ ...(part.holes || []).map((h) => { const q = at(h.x, h.y); return [q.x - h.d / 2, q.y - h.d / 2, q.x + h.d / 2, q.y + h.d / 2]; })];
65
+ const box = [Math.min(...extent.map((e) => e[0])), Math.min(...extent.map((e) => e[1])), Math.max(...extent.map((e) => e[2])), Math.max(...extent.map((e) => e[3]))];
66
+ const pc = add('pcb_component', { center: { x: (box[0] + box[2]) / 2, y: (box[1] + box[3]) / 2 }, width: box[2] - box[0], height: box[3] - box[1],
67
+ layer: part.layer, rotation: 0, source_component_id: comp.source_component_id, subcircuit_id: SUB });
68
+ const own = { pcb_component_id: pc.pcb_component_id, subcircuit_id: SUB };
69
+ pads.forEach((p, i) => {
70
+ const layers = p.smd ? [part.layer] : ['top', 'bottom'];
71
+ const port = add('pcb_port', { pcb_component_id: pc.pcb_component_id, layers, subcircuit_id: SUB, x: p.x, y: p.y, source_port_id: sourcePorts[i].source_port_id });
72
+ if (p.smd) {
73
+ const pad = add('pcb_smtpad', { ...own, pcb_port_id: port.pcb_port_id, layer: part.layer, shape: 'rect', width: p.w, height: p.h, port_hints: [p.hint], is_covered_with_solder_mask: false, x: p.x, y: p.y });
74
+ add('pcb_solder_paste', { layer: part.layer, shape: 'rect', width: p.w * PASTE, height: p.h * PASTE, x: p.x, y: p.y, pcb_component_id: pc.pcb_component_id, pcb_smtpad_id: pad.pcb_smtpad_id, subcircuit_id: SUB });
75
+ } else {
76
+ add('pcb_plated_hole', { ...own, pcb_port_id: port.pcb_port_id, outer_diameter: p.outer, hole_diameter: p.hole, shape: 'circle',
77
+ port_hints: [`unnamed_platedhole${i + 1}`, p.pin], x: p.x, y: p.y, layers, is_covered_with_solder_mask: false });
78
+ }
79
+ Object.assign(sourcePorts[i], { _pcb: port });
80
+ for (const key of [sourcePorts[i].name, ...sourcePorts[i].port_hints]) ports.set(`${part.name}.${key}`, { source: sourcePorts[i], pcb: port, x: p.x, y: p.y, layers });
81
+ });
82
+ for (const h of part.holes || []) { const q = at(h.x, h.y); add('pcb_hole', { ...own, hole_shape: 'circle', hole_diameter: h.d, x: q.x, y: q.y, is_covered_with_solder_mask: false }); }
83
+ if (part.courtyard) add('pcb_courtyard_rect', { ...own, layer: part.layer, center: { x: part.x, y: part.y }, width: part.courtyard.w, height: part.courtyard.h });
84
+ // A footprint's own marks, mirrored with it on the bottom: fab notes, the reference, the courtyard
85
+ for (const e of fpEls) {
86
+ if (e.type === 'pcb_fabrication_note_path') add(e.type, { ...own, layer: part.layer, route: e.route.map((q) => at(q.x, q.y)), stroke_width: e.stroke_width });
87
+ if (e.type === 'pcb_fabrication_note_text' || e.type === 'pcb_silkscreen_text') add(e.type, { anchor_alignment: 'center', anchor_position: at(e.anchor_position.x, e.anchor_position.y),
88
+ font: e.font, font_size: e.type === 'pcb_silkscreen_text' ? 0.5 : e.font_size, layer: part.layer, ...(e.type === 'pcb_silkscreen_text' ? { text: e.text.replace('{REF}', part.name), ccw_rotation: 0 } : { ccw_rotation: mirror < 0 ? 180 : 0, text: e.text }), ...own });
89
+ if (e.type === 'pcb_courtyard_rect') add(e.type, { ...own, layer: part.layer, center: at(e.center.x, e.center.y), width: e.width, height: e.height });
90
+ }
91
+ }
92
+
93
+ // ---- traces: drawn by rule, or left to the autorouter ----
94
+ const port = (part, name) => { const p = ports.get(`${part}.${name}`); if (!p) throw new Error(`no port ${part}.${name}`); return p; };
95
+ const toRoute = [];
96
+ for (const t of board.traces) {
97
+ const a = port(...t.from), b = port(...t.to);
98
+ const st = add('source_trace', { connected_source_port_ids: [a.source.source_port_id, b.source.source_port_id], connected_source_net_ids: [], subcircuit_id: SUB,
99
+ display_name: `.${t.from.join(' > .')} to .${t.to.join(' > .')}`, min_trace_thickness: t.width });
100
+ if (!t.path) { toRoute.push({ t, a, b, id: st.source_trace_id }); continue; }
101
+ // A drawn track runs on the layer its first pad is on (a through-hole pad: the front)
102
+ const layer = a.layers.length === 1 ? a.layers[0] : 'top';
103
+ const pts = [{ x: a.x, y: a.y }, ...t.path, { x: b.x, y: b.y }];
104
+ add('pcb_trace', { route: pts.map((q, i) => ({ route_type: 'wire', x: q.x, y: q.y, width: t.width, layer,
105
+ ...(i === 0 ? { start_pcb_port_id: a.pcb.pcb_port_id } : {}), ...(i === pts.length - 1 ? { end_pcb_port_id: b.pcb.pcb_port_id } : {}) })), source_trace_id: st.source_trace_id, subcircuit_id: SUB });
106
+ }
107
+
108
+ // ---- silkscreen and mounting holes ----
109
+ for (const s of board.silk) {
110
+ if (s.rect) add('pcb_silkscreen_rect', { pcb_component_id: null, layer: s.layer, center: { x: s.rect.x, y: s.rect.y }, width: s.rect.w, height: s.rect.h, subcircuit_id: SUB, stroke_width: 0.1, is_filled: false });
111
+ else add('pcb_silkscreen_text', { anchor_alignment: 'center', anchor_position: { x: s.x, y: s.y }, font: 'tscircuit2024', font_size: s.size, layer: s.layer, text: s.text, ccw_rotation: 0, pcb_component_id: null, subcircuit_id: SUB });
112
+ }
113
+ for (const h of board.holes || []) add('pcb_hole', { hole_shape: 'circle', hole_diameter: h.d, x: h.x, y: h.y, is_covered_with_solder_mask: false, subcircuit_id: SUB });
114
+
115
+ // ---- the autorouter: what the rules don't draw ----
116
+ if (toRoute.length) {
117
+ if (!board.autoroute) throw new Error(`${toRoute.length} connections have no track and the board isn't autorouted`);
118
+ const routed = await route(out, toRoute, board);
119
+ for (const r of routed.traces) {
120
+ const t = add('pcb_trace', { route: r.route, source_trace_id: r.source_trace_id, subcircuit_id: SUB });
121
+ for (const v of r.vias) add('pcb_via', { pcb_trace_id: t.pcb_trace_id, ...v, subcircuit_id: SUB }); // whose via it is: the checks need it
122
+ }
123
+ }
124
+
125
+ schematic(out, add, placed, board, port);
126
+ return out;
127
+ }
128
+
129
+ // A part's port names, in pad order: a switch's pin1 and pin2, a diode's anode and cathode, a chip's pins by label
130
+ function portNames(part) {
131
+ if (part.ftype === 'simple_diode') return [{ name: 'pin1', n: 1, hints: ['pin1', 'pos', 'anode', 'left', '1'] }, { name: 'pin2', n: 2, hints: ['pin2', 'neg', 'cathode', 'right', '2'] }];
132
+ return part.pads.map((p) => {
133
+ const n = Number(p.pin.replace(/\D/g, '')), label = part.sch?.pinLabels?.[p.pin];
134
+ return { name: label || p.pin, n, hints: label ? [label, p.pin, String(n)] : [p.pin, String(n)] };
135
+ });
136
+ }
137
+
138
+ // ---- the schematic: each part at the place the board gives it, wires between neighbours, labels to the chip ----
139
+ function schematic(out, add, placed, board, port) {
140
+ const sch = new Map();
141
+ for (const { part, comp, sourcePorts } of placed) {
142
+ const s = part.sch || { x: 0, y: 0 };
143
+ if (part.ftype === 'simple_chip') { // a box: pins 1 to 20 down the left, 21 to 40 up the right, 0.2 apart
144
+ const half = Math.ceil(sourcePorts.length / 2), size = { width: 1.9, height: (half + 1) * 0.2 };
145
+ const c = add('schematic_component', { center: { x: s.x, y: s.y }, rotation: 0, size, pin_spacing: 0.2, port_labels: part.sch?.pinLabels || {},
146
+ source_component_id: comp.source_component_id, is_box_with_pins: true, schematic_group_id: 'schematic_group_0' });
147
+ add('schematic_text', { text: part.mpn, schematic_component_id: c.schematic_component_id, anchor: 'left', rotation: 0,
148
+ position: { x: s.x - size.width / 2, y: s.y - size.height / 2 - 0.23 }, color: '#006464', font_size: 0.18 });
149
+ sourcePorts.forEach((sp, i) => {
150
+ const left = i < half, k = left ? i : sourcePorts.length - 1 - i, side = left ? 'left' : 'right';
151
+ const center = { x: s.x + (left ? -1 : 1) * (size.width / 2 + 0.4), y: s.y + size.height / 2 - 0.2 * (k + 1) };
152
+ sch.set(sp.source_port_id, add('schematic_port', { schematic_component_id: c.schematic_component_id, center, source_port_id: sp.source_port_id,
153
+ facing_direction: side, distance_from_component_edge: 0.4, side_of_component: side, pin_number: sp.pin_number, true_ccw_index: i, display_pin_label: sp.name }));
154
+ });
155
+ continue;
156
+ }
157
+ const symbol = symbols[part.ftype === 'simple_diode' ? 'diode_down' : 'spst_switch_right'];
158
+ const c = add('schematic_component', { center: { x: s.x, y: s.y }, rotation: 0, size: symbol.size, source_component_id: comp.source_component_id,
159
+ is_box_with_pins: true, symbol_name: part.ftype === 'simple_diode' ? 'diode_down' : 'spst_switch_right', schematic_group_id: 'schematic_group_0' });
160
+ sourcePorts.forEach((sp, i) => {
161
+ const q = symbol.ports[i], center = { x: s.x + q.x - symbol.center.x, y: s.y + q.y - symbol.center.y };
162
+ sch.set(sp.source_port_id, add('schematic_port', { schematic_component_id: c.schematic_component_id, center, source_port_id: sp.source_port_id,
163
+ facing_direction: part.ftype === 'simple_diode' ? (i ? 'down' : 'up') : (i ? 'right' : 'left'), distance_from_component_edge: 0.4 }));
164
+ });
165
+ }
166
+ // A wire between two parts' pins (L-shaped), or for a connection to the chip, a label at each end naming the pin
167
+ for (const t of board.traces) {
168
+ const a = port(...t.from), b = port(...t.to), pa = sch.get(a.source.source_port_id), pb = sch.get(b.source.source_port_id);
169
+ const chip = [a, b].find((q) => placed.find((p) => p.sourcePorts.includes(q.source)).part.ftype === 'simple_chip');
170
+ if (chip) {
171
+ // At the chip, the label points out from its side of the box; at the matrix, it hangs below the pin
172
+ for (const [q, sp] of [[pa, a], [pb, b]]) add('schematic_net_label', { text: chip.source.name, anchor_position: q.center, center: q.center,
173
+ anchor_side: q.side_of_component === 'left' ? 'right' : q.side_of_component === 'right' ? 'left' : 'top', source_port_id: sp.source.source_port_id });
174
+ continue;
175
+ }
176
+ const corner = { x: pb.center.x, y: pa.center.y };
177
+ add('schematic_trace', { source_trace_id: `${pa.schematic_port_id}-${pb.schematic_port_id}`, junctions: [],
178
+ edges: [{ from: pa.center, to: corner }, { from: corner, to: pb.center }].filter((e) => e.from.x !== e.to.x || e.from.y !== e.to.y) });
179
+ }
180
+ add('schematic_group', { is_subcircuit: true, subcircuit_id: SUB, name: 'board', center: { x: 0, y: 0 }, width: 0, height: 0, schematic_component_ids: [], source_group_id: 'source_group_0' });
181
+ }
182
+
183
+ // ---- the autorouter ----
184
+ // The connections the rules don't draw, routed by tscircuit's capacity autorouter around everything already on the
185
+ // board. The board is described to it as tscircuit's own core describes it (pads, holes, the drawn tracks, and which
186
+ // of them are connected), and the same solver runs with the same settings, so a board routes as it did there.
187
+ // -> { traces: [{ route, source_trace_id, vias }] }
188
+ export async function routeInput(json, toRoute) {
189
+ const { getFullConnectivityMapFromCircuitJson } = await import('circuit-json-to-connectivity-map');
190
+ const conn = getFullConnectivityMapFromCircuitJson(json);
191
+ const linked = (id) => { const net = conn.getNetConnectedToId(id); return net ? [...new Set([id, net, ...conn.getIdsConnectedToNet(net)])] : [id]; };
192
+ const comp = Object.fromEntries(json.filter((e) => e.type === 'source_component').map((e) => [e.source_component_id, e.name]));
193
+ const src = Object.fromEntries(json.filter((e) => e.type === 'source_port').map((e) => [e.source_port_id, e]));
194
+ const pcbPort = Object.fromEntries(json.filter((e) => e.type === 'pcb_port').map((e) => [e.pcb_port_id, e]));
195
+ const meta = (e, idKey) => { const sp = src[pcbPort[e.pcb_port_id]?.source_port_id];
196
+ return { [idKey]: e[idKey], pcb_port_id: e.pcb_port_id, source_component_name: comp[sp?.source_component_id], source_port_name: sp?.name }; };
197
+ // Pads joined inside their part (a Pico's GND pins) say so: the router may join them on the board too
198
+ const inside = new Map();
199
+ for (const e of json.filter((x) => x.type === 'source_component_internal_connection')) for (const id of e.source_port_ids) inside.set(id, [...(inside.get(id) || []), e.source_component_internal_connection_id]);
200
+ const joined = (e) => { const ids = inside.get(pcbPort[e.pcb_port_id]?.source_port_id); return ids ? { offBoardConnectsTo: ids, netIsAssignable: true } : {}; };
201
+ const b = json.find((e) => e.type === 'pcb_board'), obstacles = [];
202
+ for (const kind of ['pcb_smtpad', 'pcb_plated_hole', 'pcb_hole']) for (const e of json.filter((x) => x.type === kind)) { // by kind, as core lists them
203
+ if (e.type === 'pcb_smtpad') obstacles.push({ circuitJsonMetadata: meta(e, 'pcb_smtpad_id'), componentId: e.pcb_component_id, type: 'rect', layers: [e.layer],
204
+ center: { x: e.x, y: e.y }, width: e.width, height: e.height, connectedTo: linked(e.pcb_smtpad_id), ...joined(e) });
205
+ if (e.type === 'pcb_plated_hole') obstacles.push({ circuitJsonMetadata: meta(e, 'pcb_plated_hole_id'), componentId: e.pcb_component_id, type: 'oval', layers: ['top', 'bottom'],
206
+ center: { x: e.x, y: e.y }, width: e.outer_diameter, height: e.outer_diameter, connectedTo: linked(e.pcb_plated_hole_id), ...joined(e) });
207
+ if (e.type === 'pcb_hole') obstacles.push({ componentId: e.pcb_component_id, obstacleId: e.pcb_hole_id, isNonPlatedHole: true, type: 'rect', shape: 'circle',
208
+ layers: ['top', 'bottom'], center: { x: e.x, y: e.y }, width: e.hole_diameter, height: e.hole_diameter, connectedTo: [] });
209
+ }
210
+ const traces = json.filter((e) => e.type === 'pcb_trace').map((e) => ({ type: 'pcb_trace', pcb_trace_id: e.pcb_trace_id, source_trace_id: e.source_trace_id,
211
+ connection_name: e.source_trace_id, connectsTo: [e.route[0].start_pcb_port_id, e.route.at(-1).end_pcb_port_id],
212
+ route: e.route.map((p) => ({ route_type: p.route_type, x: p.x, y: p.y, width: p.width, layer: p.layer })) }));
213
+ const connections = toRoute.map(({ t, a, b: z, id }) => ({ name: id, source_trace_id: id, nominalTraceWidth: t.width, width: t.width,
214
+ pointsToConnect: [a, z].map((q) => ({ x: q.x, y: q.y, layer: q.layers.length === 1 ? q.layers[0] : 'top', pointId: q.pcb.pcb_port_id, pcb_port_id: q.pcb.pcb_port_id,
215
+ port_selector: `${comp[q.source.source_component_id]}.${q.source.name}` })) }));
216
+ return fixedTraces({ bounds: { minX: b.center.x - b.width / 2, maxX: b.center.x + b.width / 2, minY: b.center.y - b.height / 2, maxY: b.center.y + b.height / 2 },
217
+ obstacles, connections, traces, layerCount: 2, allowBlindAndBuriedVias: false, minTraceWidth: 0.15, nominalTraceWidth: 0.15, // tscircuit's defaults
218
+ // the board's fab minimums, under the names the router reads
219
+ minViaDiameter: b.min_via_pad_diameter, minViaHoleDiameter: b.min_via_hole_diameter, minViaPadDiameter: b.min_via_pad_diameter,
220
+ min_via_hole_diameter: b.min_via_hole_diameter, min_via_pad_diameter: b.min_via_pad_diameter,
221
+ minTraceToPadEdgeClearance: b.min_trace_to_pad_edge_clearance, minTraceToHoleEdgeClearance: b.min_trace_to_hole_edge_clearance,
222
+ minViaHoleEdgeToViaHoleEdgeClearance: b.min_via_hole_edge_to_via_hole_edge_clearance, minPlatedHoleDrillEdgeToDrillEdgeClearance: b.min_plated_hole_drill_edge_to_drill_edge_clearance,
223
+ minPadEdgeToPadEdgeClearance: b.min_pad_edge_to_pad_edge_clearance, minBoardEdgeClearance: b.min_board_edge_clearance });
224
+ }
225
+
226
+ // The drawn tracks become fixed obstacles for the router: each segment a box a track wide (a diagonal one, a run of
227
+ // boxes about a track-width long), each via a square. Ported from tscircuit core's withFixedTraces (MIT licence,
228
+ // github.com/tscircuit/core), so a board is routed around them as it is there.
229
+ function fixedTraces(input) {
230
+ const obstacles = [];
231
+ for (const trace of input.traces) {
232
+ const connectedTo = [trace.connection_name ?? trace.pcb_trace_id, ...(trace.connectsTo ?? [])];
233
+ trace.route.forEach((p, i) => {
234
+ if (p.route_type === 'via') {
235
+ const d = p.via_diameter ?? input.minViaPadDiameter ?? input.minViaDiameter ?? 0.6;
236
+ obstacles.push({ type: 'rect', center: { x: p.x, y: p.y }, width: d, height: d, layers: ['top', 'bottom'], connectedTo });
237
+ }
238
+ const q = trace.route[i + 1];
239
+ if (!q || !['wire', 'via'].includes(p.route_type) || !['wire', 'via'].includes(q.route_type)) return;
240
+ const width = p.route_type === 'wire' ? p.width : q.route_type === 'wire' ? q.width : input.minTraceWidth;
241
+ const layer = p.route_type === 'wire' ? p.layer : p.to_layer;
242
+ const steps = p.x === q.x || p.y === q.y ? 1 : Math.max(1, Math.ceil(Math.hypot(q.x - p.x, q.y - p.y) / width));
243
+ for (let k = 0; k < steps; k++) {
244
+ const x1 = p.x + ((q.x - p.x) * k) / steps, y1 = p.y + ((q.y - p.y) * k) / steps;
245
+ const x2 = p.x + ((q.x - p.x) * (k + 1)) / steps, y2 = p.y + ((q.y - p.y) * (k + 1)) / steps;
246
+ obstacles.push({ type: 'rect', center: { x: (x1 + x2) / 2, y: (y1 + y2) / 2 }, width: Math.abs(x2 - x1) + width, height: Math.abs(y2 - y1) + width, layers: [layer], connectedTo });
247
+ }
248
+ });
249
+ }
250
+ return { ...input, traces: [], obstacles: [...input.obstacles, ...obstacles] };
251
+ }
252
+
253
+ async function defaultRouter(json, toRoute) {
254
+ // The autorouter uses JavaScript features from Node 22 (iterator helpers): say so, rather than fail inside it
255
+ if (Number(process.versions.node.split('.')[0]) < 22) throw new Error(`routing a board's remaining connections needs Node 22 or newer (this is Node ${process.versions.node}); boards drawn wholly by rule build on Node 20`);
256
+ const { AutoroutingPipelineSolver } = await import('@tscircuit/capacity-autorouter');
257
+ const srj = await routeInput(json, toRoute);
258
+ const solver = new AutoroutingPipelineSolver(srj, { cacheProvider: null });
259
+ solver.solve();
260
+ if (solver.failed) throw new Error(`the autorouter couldn't route every connection: ${solver.error || 'no reason given'}`);
261
+ // The routes for the connections asked for (the solver also hands back the fixed ones), each from its first pad to
262
+ // its second, as tscircuit records them
263
+ const traces = [], asked = new Map(srj.connections.map((c) => [c.name, c.pointsToConnect]));
264
+ const far = (p, q) => Math.hypot(p.x - q.x, p.y - q.y);
265
+ for (const t of solver.getOutputSimplifiedPcbTraces()) {
266
+ const ends = asked.get(t.connection_name);
267
+ if (!ends) continue;
268
+ let route = t.route.map((p) => ({ ...p }));
269
+ if (far(route[0], ends[0]) > far(route.at(-1), ends[0])) route = route.reverse(); // (a via keeps its layer labels, as in tscircuit)
270
+ route[0].start_pcb_port_id = ends[0].pcb_port_id;
271
+ route.at(-1).end_pcb_port_id = ends[1].pcb_port_id;
272
+ traces.push({ route, source_trace_id: t.connection_name, vias: route.filter((p) => p.route_type === 'via').map((p) => ({ x: p.x, y: p.y,
273
+ hole_diameter: srj.minViaHoleDiameter, outer_diameter: srj.minViaPadDiameter, layers: ['top', 'bottom'], from_layer: p.from_layer, to_layer: p.to_layer })) });
274
+ }
275
+ if (traces.length < srj.connections.length) throw new Error(`the autorouter routed ${traces.length} of ${srj.connections.length} connections`);
276
+ return { traces };
277
+ }
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@rulecad/pcb",
3
- "version": "0.1.2",
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.",
3
+ "version": "0.1.3",
4
+ "description": "Keyboard circuit boards from rules: every matrix track drawn by rule, footprints from datasheets, Gerbers, KiCad files and a JLCPCB BOM. Circuit JSON written directly: no React.",
5
5
  "type": "module",
6
6
  "exports": "./pcb.mjs",
7
7
  "files": [
8
8
  "pcb.mjs",
9
+ "circuit.mjs",
9
10
  "README.md",
10
11
  "LICENSE"
11
12
  ],
@@ -27,14 +28,21 @@
27
28
  "directory": "packages/pcb"
28
29
  },
29
30
  "dependencies": {
31
+ "@flatten-js/core": "1.6.14",
30
32
  "@jscad/modeling": "^2.12.0",
31
33
  "@resvg/resvg-js": "2.6.2",
32
34
  "@rulecad/cad": "^0.1.0",
35
+ "@tscircuit/capacity-autorouter": "0.0.982",
36
+ "@tscircuit/checks": "0.0.253",
37
+ "@tscircuit/circuit-json-util": "0.0.122",
38
+ "@tscircuit/footprinter": "0.0.431",
33
39
  "@tscircuit/math-utils": "0.0.36",
40
+ "circuit-json": "0.0.525",
41
+ "circuit-json-to-connectivity-map": "0.0.34",
34
42
  "circuit-json-to-gerber": "0.0.113",
35
43
  "circuit-json-to-kicad": "0.0.231",
36
44
  "circuit-json-to-pnp-csv": "0.0.20",
37
45
  "circuit-to-svg": "0.0.450",
38
- "tscircuit": "0.0.2895"
46
+ "schematic-symbols": "0.0.248"
39
47
  }
40
48
  }
package/pcb.mjs CHANGED
@@ -1,6 +1,6 @@
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.
1
+ // Boards: a design whose .json says "engine": "pcb". Its main(v) returns a board, described as plain data (parts, the
2
+ // tracks the rules draw, silkscreen), which circuit.mjs builds into Circuit JSON (tscircuit's open format, no React);
3
+ // from that come a solid (previews, fits, the on-screen copy), checks and the files a fab needs. Designs get it as v.pcb.
4
4
  //
5
5
  // The keyboard: keys in a matrix, one diode each, and a Pico beside them. Nothing is autorouted: the keyboard router
6
6
  // draws every track by rule, from the switch's footprint (mx rules) and our margins (the board's track and clear):
@@ -13,12 +13,10 @@
13
13
  // Diodes point from the switch to the row (COL2ROW in QMK's terms).
14
14
  import fs from 'node:fs';
15
15
  import path from 'node:path';
16
- import { Circuit, createElement as h } from 'tscircuit';
16
+ import { circuitJson } from './circuit.mjs';
17
17
  import { loadRuleSet } from '@rulecad/cad/rules';
18
18
  import jscad from '@jscad/modeling';
19
19
 
20
- export { h };
21
-
22
20
  // 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
21
  // at the back (+y)
24
22
  export function grid(rows, cols, pitch) {
@@ -28,15 +26,15 @@ export function grid(rows, cols, pitch) {
28
26
  }
29
27
 
30
28
  // ---------- 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 });
29
+ const pad = (pin, x, y, hole, outer) => ({ pin, x, y, hole, outer }); // plated, round, relative to the part
30
+ const hole = (x, y, d) => ({ x, y, d });
33
31
 
34
32
  // An MX switch, PCB mount: two pins, the centre post and the two fixing pegs
35
33
  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
34
+ return { name, ftype: 'simple_switch', mpn: 'Cherry MX (PCB mount)', x, y, layer: 'top', sch,
35
+ pads: [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)],
36
+ holes: [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)],
37
+ courtyard: { w: m.pcb_body, h: m.pcb_body } }; // the housing's footprint on the board
40
38
  }
41
39
 
42
40
  // 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
@@ -46,7 +44,7 @@ export function pico(name, x, y, p, sch = {}) {
46
44
  const left = n <= 20, i = left ? n - 1 : 40 - n;
47
45
  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
46
  }
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 })) });
47
+ return { name, ftype: 'simple_chip', mpn: 'Raspberry Pi Pico', x, y, layer: 'top', sch, pads: pins, courtyard: { w: p.width, h: p.length } };
50
48
  }
51
49
  // Where a Pico pin is, from its centre
52
50
  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 }; };
@@ -80,7 +78,7 @@ export function fromLayout(list, pitch) {
80
78
  return ks;
81
79
  }
82
80
 
83
- // keyboard(v, keys) -> { board (a tscircuit <board>), matrix }; v holds the mx, pico, sod123, m2 and jlcpcb rules and
81
+ // keyboard(v, keys) -> { board (as circuit.mjs describes it), matrix }; v holds the mx, pico, sod123, m2 and jlcpcb rules and
84
82
  // the board's variables:
85
83
  // track, clear our track width and the gap we keep to anything
86
84
  // margin board edge beyond the keys (and the Pico, beside them)
@@ -113,7 +111,7 @@ export function keyboard(v, keys) {
113
111
  const other = Object.entries(loadRuleSet('pico').rules.other_pins.pins).map(([pin, name]) => ({ pin: Number(pin), gpio: name }));
114
112
  const gpio = [...picoGpio('left'), ...picoGpio('right')];
115
113
  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 };
114
+ const sch = { x: (cols.length + 1.5) * SX, y: -(rows.length - 1) * SY / 2, pinLabels };
117
115
  let left = keyBox.left - v.margin, right = keyBox.right + v.margin, pico0, pinAt, pinRows = [];
118
116
  if (under) {
119
117
  // Its pin rows in the gaps below key rows mcu_row and mcu_row + 1: clear of the row line above (back copper), the
@@ -140,15 +138,16 @@ export function keyboard(v, keys) {
140
138
  pico0 = { x: cx, y: cy };
141
139
  right = cx + p.width / 2 + v.margin;
142
140
  }
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 })])) });
141
+ const picoPart = { name: 'U1', ftype: 'simple_chip', mpn: 'Raspberry Pi Pico', x: pico0.x, y: pico0.y, layer: 'top', sch,
142
+ pads: 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); }),
143
+ courtyard: under ? null : { w: p.width, h: p.length } };
146
144
 
147
145
  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
146
+ const pts = (list) => list.map(([x, y]) => ({ x, y })); // a track's corners between its two pads, on the board
149
147
  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) }));
148
+ parts.push(mx(`K${k.n}`, k.x, k.y, m, { x: k.c * SX, y: -k.r * SY }),
149
+ { name: `D${k.n}`, ftype: 'simple_diode', footprint: 'sod123', mpn: '1N4148W', x: k.x + diode.x, y: k.y + diode.y, layer: 'bottom', sch: { x: k.c * SX + 1, y: -k.r * SY - 0.8 } });
150
+ traces.push({ from: [`D${k.n}`, 'anode'], to: [`K${k.n}`, 'pin1'], width: v.track, path: pts([[k.x + anodeX, k.y + m.pcb_pin1_y]]) });
152
151
  }
153
152
 
154
153
  // ---- rows: cathode to cathode along the back ----
@@ -156,7 +155,7 @@ export function keyboard(v, keys) {
156
155
  const ks = keys.filter((k) => k.r === r).sort((a, b) => a.x - b.x);
157
156
  for (let i = 1; i < ks.length; i++) {
158
157
  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) }));
158
+ traces.push({ from: [`D${a.n}`, 'cathode'], to: [`D${b.n}`, 'cathode'], width: v.track, path: pts([[a.x + cathodeX, a.y + lineY], [b.x + cathodeX, b.y + lineY]]) });
160
159
  }
161
160
  }
162
161
 
@@ -195,7 +194,7 @@ export function keyboard(v, keys) {
195
194
  const ks = keys.filter((k) => k.c === c).sort((a, b) => a.r - b.r);
196
195
  for (let j = 1; j < ks.length; j++) {
197
196
  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) }));
197
+ traces.push({ from: [`K${a.n}`, 'pin2'], to: [`K${b.n}`, 'pin2'], width: v.track, path: pts(columnPath(a, b)) });
199
198
  }
200
199
  }
201
200
 
@@ -203,8 +202,8 @@ export function keyboard(v, keys) {
203
202
  const rowPin = {}, colPin = {};
204
203
  if (under) {
205
204
  // 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` })) }))];
205
+ 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'] })) })),
206
+ ...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
207
  const free = [...gpio];
209
208
  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
209
  const left_ = [...ends];
@@ -215,7 +214,7 @@ export function keyboard(v, keys) {
215
214
  left_.splice(left_.indexOf(best.e), 1); free.splice(free.indexOf(best.g), 1);
216
215
  (best.e.kind === 'row' ? rowPin : colPin)[best.e.id] = best.g;
217
216
  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 }));
217
+ traces.push({ from: near.sel, to: ['U1', `pin${best.g.pin}`], width: v.track }); // no path: the autorouter's
219
218
  }
220
219
  } else {
221
220
  // Beside: rows take the free left-edge pins nearest their row line, columns the lowest of the rest; every track
@@ -232,13 +231,13 @@ export function keyboard(v, keys) {
232
231
  cols.forEach((c, i) => { colPin[c] = lowest[i]; });
233
232
  for (const r of rows) {
234
233
  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) }));
234
+ traces.push({ from: [`D${last.n}`, 'cathode'], to: ['U1', `pin${rowPin[r].pin}`], width: v.track,
235
+ path: pts([[last.x + cathodeX, last.y + lineY], [nearPins - lane, last.y + lineY], [nearPins - lane, pinY]]) });
237
236
  }
238
237
  cols.forEach((c, i) => {
239
238
  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) }));
239
+ traces.push({ from: [`K${last.n}`, 'pin2'], to: ['U1', `pin${colPin[c].pin}`], width: v.track,
240
+ path: pts([[last.x + jogX, last.y + m.pcb_pin2_y - slant], [last.x + jogX, depth], [laneX, depth], [laneX, pinAt(colPin[c].pin).y]]) });
242
241
  });
243
242
  }
244
243
 
@@ -251,16 +250,16 @@ export function keyboard(v, keys) {
251
250
  // Silkscreen for whoever solders it: the Pico's outline (on the side it's fitted) with USB marked, each switch's
252
251
  // outline, and a bar at each diode's cathode (on the back, between its pads, next to the cathode)
253
252
  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 })];
253
+ const rect = (x, y, w, ht, layer) => ({ rect: { x, y, w, h: ht }, layer });
254
+ const outline = (x, y, w, ht, layer = 'top') => [rect(x, y + ht / 2, w, silk, layer), rect(x, y - ht / 2, w, silk, layer), rect(x - w / 2, y, silk, ht, layer), rect(x + w / 2, y, silk, ht, layer)];
256
255
  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
256
  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' }),
257
+ { text: 'USB', x: usbAt.x, y: usbAt.y, size: text, layer: under ? 'bottom' : 'top' },
259
258
  ...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)));
259
+ ...keys.map((k) => rect(k.x + cathodeX + d.pad_w / 2 + silk * 2, k.y + diode.y, silk, d.pad_h, 'bottom')),
260
+ ...(v.margin >= text * 2 ? [{ text: 'rulecad: diodes point to the rows', x: (keyBox.left + keyBox.right) / 2, y: bottom + v.margin / 2, size: text, layer: 'bottom' }] : [])];
261
+ const board = { center: { x: cx, y: cy }, width: right - left, height: top - bottom, thickness: v.thickness, autoroute: under,
262
+ parts, traces, silk: marks, holes: corners.map(([x, y]) => hole(x, y, v.m2.clearance)) };
264
263
  const matrix = {
265
264
  keys: keys.map((k) => ({ key: `K${k.n}`, ...(k.label ? { label: k.label } : {}), row: k.r, col: k.c })),
266
265
  rows: Object.fromEntries(rows.map((r) => [r, rowPin[r].gpio])), cols: Object.fromEntries(cols.map((c) => [c, colPin[c].gpio])),
@@ -270,32 +269,8 @@ export function keyboard(v, keys) {
270
269
  }
271
270
 
272
271
  // ---------- 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
- }
272
+ // render(board) -> Circuit JSON (circuit.mjs)
273
+ export const render = (board) => circuitJson(board);
299
274
 
300
275
  // The board as a solid (previews, fits, the on-screen copy, the model page): the bare board, drilled, bottom at z = 0. Its
301
276
  // copper is drawn in the 2D views (views), not in 3D. The board is a 2D outline with its holes, extruded: its faces