@rulecad/pcb 0.1.2 → 0.2.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.
- package/README.md +47 -7
- package/circuit.mjs +315 -0
- package/package.json +15 -5
- package/pcb.mjs +96 -65
package/README.md
CHANGED
|
@@ -1,12 +1,51 @@
|
|
|
1
1
|
# @rulecad/pcb
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
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.
|
|
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.
|
|
13
|
+
|
|
14
|
+
**Any board.** Place parts from rule sets or any standard footprint, turn them in quarter turns, join their pins with
|
|
15
|
+
nets, and let the autorouter draw the tracks:
|
|
16
|
+
|
|
17
|
+
```js
|
|
18
|
+
// projects/node/objects/carrier.js: a radio module, two Qwiic sensor sockets, a battery socket and its diode
|
|
19
|
+
export function main(v) {
|
|
20
|
+
const { xiao, qwiic, jstph, part } = v.pcb;
|
|
21
|
+
return {
|
|
22
|
+
center: { x: 7.5, y: -2.2 }, width: 35.5, height: 25.4, thickness: v.thickness, track: v.track, autoroute: true,
|
|
23
|
+
parts: [
|
|
24
|
+
xiao('U1', 0, 0, v.xiao), // pins labelled D0..D10, 3V3, GND, 5V from the xiao rules
|
|
25
|
+
qwiic('J1', v.qwiic_x, v.qwiic_y, v.qwiic), // plug in from the top edge
|
|
26
|
+
qwiic('J2', v.j2_x, 0, v.qwiic, { rotation: v.j2_turn }), // turned to face the right edge
|
|
27
|
+
jstph('J3', v.qwiic_x, v.bat_y, v.jstph, { labels: { pin1: 'BAT', pin2: 'BATGND' } }),
|
|
28
|
+
part('D1', 'sod123', v.qwiic_x, v.diode_y, { mpn: 'B5819W' })
|
|
29
|
+
],
|
|
30
|
+
nets: { GND: ['U1.GND', 'J1.GND', 'J2.GND', 'J3.BATGND'], SDA: ['U1.D4', 'J1.SDA', 'J2.SDA'], /* ... */ },
|
|
31
|
+
traces: [], silk: [], holes: []
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
- **Parts:** `xiao`, `qwiic`, `jstph` and `header` take their sizes and pinouts from rule sets in @rulecad/rules (each
|
|
37
|
+
value with its source); `part(name, footprint, x, y)` takes any footprinter name: `soic8`, `0603`, `sot23`,
|
|
38
|
+
`jst4_sh`, `pinrow4` (SMD or through-hole). Options: `rotation` (0, 90, 180, 270), `layer`, `mpn`, `labels`.
|
|
39
|
+
- **Nets:** each net's pins are joined by the shortest set of links between them, at `board.track` (or the net's own
|
|
40
|
+
`{ pins, width }`); pins are named `part.pin` by label, name or number. The schematic labels them by net.
|
|
41
|
+
- **What a board tells the parts around it:** `board_x`, `board_y`, `board_w`, `board_d` and its mounting holes, so
|
|
42
|
+
a case can read it (`"uses": ["node/carrier"]` in @rulecad/cad) and a holes fit can check the screws find their inserts.
|
|
43
|
+
- **Keyboards** are one recipe built on the same parts: every matrix track drawn by rule, one diode per key.
|
|
44
|
+
|
|
45
|
+
A keyboard:
|
|
7
46
|
|
|
8
47
|
```js
|
|
9
|
-
// projects/macropad/objects/board.js: main(v) returns a
|
|
48
|
+
// projects/macropad/objects/board.js: main(v) returns a board, built with v.pcb
|
|
10
49
|
export function main(v) {
|
|
11
50
|
const keys = v.pcb.grid(v.rows, v.cols, v.mx.pitch);
|
|
12
51
|
return v.pcb.keyboard(v, keys); // { board, matrix }
|
|
@@ -21,9 +60,10 @@ npx cad export macropad/board # <name>-fab.zip for JLCPCB, KiCad files, the
|
|
|
21
60
|
```
|
|
22
61
|
|
|
23
62
|
- 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
|
|
63
|
+
- A Pico under the board has its connections autorouted (that needs Node 22 or newer; boards drawn wholly by rule build on
|
|
64
|
+
Node 20 too). That takes minutes; then the build cache keeps it.
|
|
25
65
|
Everything else is drawn by rule.
|
|
26
|
-
-
|
|
27
|
-
boards built before it (same tracks, same checks). Nothing to add to your
|
|
28
|
-
|
|
66
|
+
- The autorouter and footprint versions are pinned, so a board builds the same until rulecad moves them on; each move is
|
|
67
|
+
checked against boards built before it (same tracks, same checks, same fab files). Nothing to add to your
|
|
68
|
+
package.json.
|
|
29
69
|
- Ordering is your click: `cad export` makes the fab zip and never places an order.
|
package/circuit.mjs
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
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, nets, track, silk, holes, autoroute }
|
|
7
|
+
// part { name, ftype: 'simple_switch' | 'simple_chip' | 'simple_diode', mpn, x, y, layer: 'top' | 'bottom',
|
|
8
|
+
// rotation: 0 | 90 | 180 | 270 (counter-clockwise, seen from the side the part is on; default 0),
|
|
9
|
+
// pads: [{ pin, x, y, hole, outer }] (plated, relative to the part) or [{ pin, smd: true, hint, x, y, w, h }],
|
|
10
|
+
// holes: [{ x, y, d }], courtyard: { w, h } | null,
|
|
11
|
+
// footprint: 'sod123' | 'jst4_sh' | 'pinrow4' ... (a footprinter string, SMD or through-hole, instead of pads),
|
|
12
|
+
// sch: { x, y, symbol?, pinLabels? } }
|
|
13
|
+
// trace { from: [part, port], to: [part, port], width, path: [{ x, y }] (absolute, between the two ports) }; a trace
|
|
14
|
+
// without a path is left to the autorouter when board.autoroute is set
|
|
15
|
+
// nets { GND: ['U1.GND', 'J1.1', 'J2.1'] } or { GND: { pins: [...], width } }: each net's pins are joined by the
|
|
16
|
+
// shortest set of connections between them (a minimum spanning tree), left to the autorouter, at the net's
|
|
17
|
+
// width or board.track; pins are named part.pin, by the pin's label, its name or its number
|
|
18
|
+
// silk { rect: { x, y, w, h }, layer } | { text, x, y, size, layer }
|
|
19
|
+
// holes [{ x, y, d }] in the board (mounting holes)
|
|
20
|
+
// All positions are in mm, in the board's own frame. A part on the bottom is seen from above: mirrored in x.
|
|
21
|
+
import { fp } from '@tscircuit/footprinter';
|
|
22
|
+
import { symbols } from 'schematic-symbols';
|
|
23
|
+
|
|
24
|
+
const SUB = 'subcircuit_source_group_0';
|
|
25
|
+
const PASTE = 0.7;
|
|
26
|
+
// Quarter turns, counter-clockwise, as exact cosines and sines (no rounding error in pad positions)
|
|
27
|
+
const QUARTER = { 0: [1, 0], 90: [0, 1], 180: [-1, 0], 270: [0, -1] };
|
|
28
|
+
function quarter(r = 0) {
|
|
29
|
+
const t = QUARTER[((r % 360) + 360) % 360];
|
|
30
|
+
if (!t) throw new Error(`a part's rotation is ${r}°: boards take 0, 90, 180 or 270`);
|
|
31
|
+
return t;
|
|
32
|
+
} // solder paste, as a share of an SMD pad (what tscircuit uses)
|
|
33
|
+
// The board's manufacturing minimums (mm): tscircuit's defaults, within what common fabs make. The router keeps to them.
|
|
34
|
+
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,
|
|
35
|
+
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,
|
|
36
|
+
min_plated_hole_drill_edge_to_drill_edge_clearance: 0.15, min_board_edge_clearance: 0.2 };
|
|
37
|
+
|
|
38
|
+
export async function circuitJson(board, { route = defaultRouter } = {}) {
|
|
39
|
+
const out = [], next = {}, id = (type) => `${type}_${(next[type] = (next[type] ?? -1) + 1)}`;
|
|
40
|
+
const add = (type, fields) => { const e = { type, [`${type}_id`]: id(type), ...fields }; out.push(e); return e; };
|
|
41
|
+
|
|
42
|
+
add('source_project_metadata', { software_used_string: '@rulecad/pcb' });
|
|
43
|
+
add('source_group', { is_subcircuit: true, was_automatically_named: true, subcircuit_id: SUB });
|
|
44
|
+
const sourceBoard = add('source_board', { source_group_id: 'source_group_0' });
|
|
45
|
+
|
|
46
|
+
// ---- parts: source records, then their footprints on the board ----
|
|
47
|
+
const ports = new Map(); // `${part}.${port name or hint}` -> { source, pcb, x, y, layers }
|
|
48
|
+
const placed = [];
|
|
49
|
+
for (const part of board.parts) {
|
|
50
|
+
const comp = add('source_component', { ftype: part.ftype, name: part.name, manufacturer_part_number: part.mpn,
|
|
51
|
+
are_pins_interchangeable: part.ftype === 'simple_switch', source_group_id: 'source_group_0', subcircuit_id: SUB });
|
|
52
|
+
const fpEls = part.footprint ? fp.string(part.footprint).circuitJson() : [];
|
|
53
|
+
const mirror = part.layer === 'bottom' ? -1 : 1;
|
|
54
|
+
const [cos, sin] = quarter(part.rotation), swap = sin !== 0;
|
|
55
|
+
const at = (x, y) => ({ x: part.x + mirror * (cos * x - sin * y), y: part.y + (sin * x + cos * y) });
|
|
56
|
+
const size = (w, h) => (swap ? { w: h, h: w } : { w, h }); // a rectangle turned a quarter swaps its sides
|
|
57
|
+
// Pads: from the description, or a footprint's SMD pads and plated holes (pin n: hints ['n'])
|
|
58
|
+
const pads = part.footprint
|
|
59
|
+
? fpEls.filter((e) => e.type === 'pcb_smtpad' || e.type === 'pcb_plated_hole').map((e) => e.type === 'pcb_smtpad'
|
|
60
|
+
? { smd: true, pin: `pin${e.port_hints[0]}`, hint: e.port_hints[0], ...at(e.x, e.y), ...size(e.width, e.height) }
|
|
61
|
+
: { pin: `pin${e.port_hints[0]}`, hint: e.port_hints[0], ...at(e.x, e.y), hole: e.hole_diameter ?? Math.min(e.hole_width, e.hole_height),
|
|
62
|
+
outer: e.outer_diameter ?? Math.max(e.rect_pad_width ?? e.outer_width ?? 0, e.rect_pad_height ?? e.outer_height ?? 0) })
|
|
63
|
+
: part.pads.map((p) => ({ ...p, ...at(p.x, p.y), ...(p.smd ? size(p.w, p.h) : {}) }));
|
|
64
|
+
const names = portNames(part, pads);
|
|
65
|
+
const sourcePorts = pads.map((p, i) => add('source_port', { name: names[i].name, pin_number: names[i].n, port_hints: names[i].hints,
|
|
66
|
+
source_component_id: comp.source_component_id, subcircuit_id: SUB }));
|
|
67
|
+
if (part.ftype === 'simple_chip') { // pins with the same label are joined inside the part (a Pico's GND pins)
|
|
68
|
+
const byName = new Map(); for (const sp of sourcePorts) byName.set(sp.name, [...(byName.get(sp.name) || []), sp]); // (Object.groupBy needs Node 21)
|
|
69
|
+
const groups = [...byName.values()].filter((g) => g.length > 1);
|
|
70
|
+
if (groups.length) comp.internally_connected_source_port_ids = groups.map((g) => g.map((s) => s.source_port_id));
|
|
71
|
+
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) });
|
|
72
|
+
}
|
|
73
|
+
placed.push({ part, comp, pads, sourcePorts, fpEls, at, mirror, size });
|
|
74
|
+
}
|
|
75
|
+
add('pcb_board', { source_board_id: sourceBoard.source_board_id, subcircuit_id: SUB, center: board.center, thickness: board.thickness, num_layers: 2,
|
|
76
|
+
width: board.width, height: board.height, material: 'fr4', ...FAB_MINIMUMS });
|
|
77
|
+
|
|
78
|
+
for (const { part, comp, pads, sourcePorts, fpEls, at, mirror, size } of placed) {
|
|
79
|
+
// The component sits at the middle of its pads and holes (as tscircuit places it: pick-and-place reads this)
|
|
80
|
+
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])),
|
|
81
|
+
...(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]; })];
|
|
82
|
+
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]))];
|
|
83
|
+
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],
|
|
84
|
+
layer: part.layer, rotation: part.rotation ?? 0, source_component_id: comp.source_component_id, subcircuit_id: SUB });
|
|
85
|
+
const own = { pcb_component_id: pc.pcb_component_id, subcircuit_id: SUB };
|
|
86
|
+
pads.forEach((p, i) => {
|
|
87
|
+
const layers = p.smd ? [part.layer] : ['top', 'bottom'];
|
|
88
|
+
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 });
|
|
89
|
+
if (p.smd) {
|
|
90
|
+
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 });
|
|
91
|
+
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 });
|
|
92
|
+
} else {
|
|
93
|
+
add('pcb_plated_hole', { ...own, pcb_port_id: port.pcb_port_id, outer_diameter: p.outer, hole_diameter: p.hole, shape: 'circle',
|
|
94
|
+
port_hints: [`unnamed_platedhole${i + 1}`, p.pin], x: p.x, y: p.y, layers, is_covered_with_solder_mask: false });
|
|
95
|
+
}
|
|
96
|
+
Object.assign(sourcePorts[i], { _pcb: port });
|
|
97
|
+
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 });
|
|
98
|
+
});
|
|
99
|
+
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 }); }
|
|
100
|
+
if (part.courtyard) { const c = size(part.courtyard.w, part.courtyard.h); add('pcb_courtyard_rect', { ...own, layer: part.layer, center: { x: part.x, y: part.y }, width: c.w, height: c.h }); }
|
|
101
|
+
// A footprint's own marks, mirrored with it on the bottom: fab notes, the reference, the courtyard
|
|
102
|
+
for (const e of fpEls) {
|
|
103
|
+
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 });
|
|
104
|
+
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),
|
|
105
|
+
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 });
|
|
106
|
+
if (e.type === 'pcb_courtyard_rect') { const c = size(e.width, e.height); add(e.type, { ...own, layer: part.layer, center: at(e.center.x, e.center.y), width: c.w, height: c.h }); }
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ---- traces: drawn by rule, or left to the autorouter ----
|
|
111
|
+
const port = (part, name) => { const p = ports.get(`${part}.${name}`); if (!p) throw new Error(`no port ${part}.${name}`); return p; };
|
|
112
|
+
const toRoute = [], traces = [...(board.traces || []), ...netTraces(board, port)];
|
|
113
|
+
for (const t of traces) {
|
|
114
|
+
const a = port(...t.from), b = port(...t.to);
|
|
115
|
+
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,
|
|
116
|
+
display_name: `.${t.from.join(' > .')} to .${t.to.join(' > .')}`, min_trace_thickness: t.width });
|
|
117
|
+
if (!t.path) { toRoute.push({ t, a, b, id: st.source_trace_id }); continue; }
|
|
118
|
+
// A drawn track runs on the layer its first pad is on (a through-hole pad: the front)
|
|
119
|
+
const layer = a.layers.length === 1 ? a.layers[0] : 'top';
|
|
120
|
+
const pts = [{ x: a.x, y: a.y }, ...t.path, { x: b.x, y: b.y }];
|
|
121
|
+
add('pcb_trace', { route: pts.map((q, i) => ({ route_type: 'wire', x: q.x, y: q.y, width: t.width, layer,
|
|
122
|
+
...(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 });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ---- silkscreen and mounting holes ----
|
|
126
|
+
for (const s of board.silk) {
|
|
127
|
+
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 });
|
|
128
|
+
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 });
|
|
129
|
+
}
|
|
130
|
+
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 });
|
|
131
|
+
|
|
132
|
+
// ---- the autorouter: what the rules don't draw ----
|
|
133
|
+
if (toRoute.length) {
|
|
134
|
+
if (!board.autoroute) throw new Error(`${toRoute.length} connections have no track and the board isn't autorouted`);
|
|
135
|
+
const routed = await route(out, toRoute, board);
|
|
136
|
+
for (const r of routed.traces) {
|
|
137
|
+
const t = add('pcb_trace', { route: r.route, source_trace_id: r.source_trace_id, subcircuit_id: SUB });
|
|
138
|
+
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
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
schematic(out, add, placed, { ...board, traces }, port);
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// 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
|
|
147
|
+
function portNames(part, pads) {
|
|
148
|
+
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'] }];
|
|
149
|
+
return (part.pads || pads).map((p) => {
|
|
150
|
+
const n = Number(p.pin.replace(/\D/g, '')), label = part.sch?.pinLabels?.[p.pin];
|
|
151
|
+
return { name: label || p.pin, n, hints: label ? [label, p.pin, String(n)] : [p.pin, String(n)] };
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// A net's connections: its pins joined by the shortest set of straight links between them (Prim's minimum spanning
|
|
156
|
+
// tree, ties to the earlier pin), so the router gets short, sensible connections to route rather than a long chain
|
|
157
|
+
function netTraces(board, port) {
|
|
158
|
+
return Object.entries(board.nets || {}).flatMap(([net, spec]) => {
|
|
159
|
+
const list = Array.isArray(spec) ? spec : spec.pins, width = (Array.isArray(spec) ? undefined : spec.width) ?? board.track;
|
|
160
|
+
if (!width) throw new Error(`net ${net}: no track width. Give the board a track width (board.track) or the net one ({ pins, width })`);
|
|
161
|
+
const pins = list.map((p) => { const i = String(p).indexOf('.'); if (i < 1) throw new Error(`net ${net}: "${p}" should name a part and a pin, like U1.GND`); return [p.slice(0, i), p.slice(i + 1)]; });
|
|
162
|
+
const at = pins.map((p) => port(...p)), linked = [0], links = [];
|
|
163
|
+
while (linked.length < pins.length) {
|
|
164
|
+
let best = null;
|
|
165
|
+
for (const a of linked) for (let b = 0; b < pins.length; b++) {
|
|
166
|
+
if (linked.includes(b)) continue;
|
|
167
|
+
const d = Math.hypot(at[a].x - at[b].x, at[a].y - at[b].y);
|
|
168
|
+
if (!best || d < best.d) best = { a, b, d };
|
|
169
|
+
}
|
|
170
|
+
linked.push(best.b); links.push({ from: pins[best.a], to: pins[best.b], width, net });
|
|
171
|
+
}
|
|
172
|
+
return links;
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ---- the schematic: each part at the place the board gives it, wires between neighbours, labels to the chip ----
|
|
177
|
+
function schematic(out, add, placed, board, port) {
|
|
178
|
+
const sch = new Map();
|
|
179
|
+
for (const { part, comp, sourcePorts } of placed) {
|
|
180
|
+
const s = part.sch || { x: 0, y: 0 };
|
|
181
|
+
if (part.ftype === 'simple_chip') { // a box: pins 1 to 20 down the left, 21 to 40 up the right, 0.2 apart
|
|
182
|
+
const half = Math.ceil(sourcePorts.length / 2), size = { width: 1.9, height: (half + 1) * 0.2 };
|
|
183
|
+
const c = add('schematic_component', { center: { x: s.x, y: s.y }, rotation: 0, size, pin_spacing: 0.2, port_labels: part.sch?.pinLabels || {},
|
|
184
|
+
source_component_id: comp.source_component_id, is_box_with_pins: true, schematic_group_id: 'schematic_group_0' });
|
|
185
|
+
add('schematic_text', { text: part.mpn, schematic_component_id: c.schematic_component_id, anchor: 'left', rotation: 0,
|
|
186
|
+
position: { x: s.x - size.width / 2, y: s.y - size.height / 2 - 0.23 }, color: '#006464', font_size: 0.18 });
|
|
187
|
+
sourcePorts.forEach((sp, i) => {
|
|
188
|
+
const left = i < half, k = left ? i : sourcePorts.length - 1 - i, side = left ? 'left' : 'right';
|
|
189
|
+
const center = { x: s.x + (left ? -1 : 1) * (size.width / 2 + 0.4), y: s.y + size.height / 2 - 0.2 * (k + 1) };
|
|
190
|
+
sch.set(sp.source_port_id, add('schematic_port', { schematic_component_id: c.schematic_component_id, center, source_port_id: sp.source_port_id,
|
|
191
|
+
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 }));
|
|
192
|
+
});
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
const symbol = symbols[part.ftype === 'simple_diode' ? 'diode_down' : 'spst_switch_right'];
|
|
196
|
+
const c = add('schematic_component', { center: { x: s.x, y: s.y }, rotation: 0, size: symbol.size, source_component_id: comp.source_component_id,
|
|
197
|
+
is_box_with_pins: true, symbol_name: part.ftype === 'simple_diode' ? 'diode_down' : 'spst_switch_right', schematic_group_id: 'schematic_group_0' });
|
|
198
|
+
sourcePorts.forEach((sp, i) => {
|
|
199
|
+
const q = symbol.ports[i], center = { x: s.x + q.x - symbol.center.x, y: s.y + q.y - symbol.center.y };
|
|
200
|
+
sch.set(sp.source_port_id, add('schematic_port', { schematic_component_id: c.schematic_component_id, center, source_port_id: sp.source_port_id,
|
|
201
|
+
facing_direction: part.ftype === 'simple_diode' ? (i ? 'down' : 'up') : (i ? 'right' : 'left'), distance_from_component_edge: 0.4 }));
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
// A wire between two parts' pins (L-shaped), or for a connection to the chip, a label at each end naming the pin
|
|
205
|
+
for (const t of board.traces) {
|
|
206
|
+
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);
|
|
207
|
+
const chip = [a, b].find((q) => placed.find((p) => p.sourcePorts.includes(q.source)).part.ftype === 'simple_chip');
|
|
208
|
+
if (chip || t.net) { // a net's ends are labelled with the net's name
|
|
209
|
+
// At the chip, the label points out from its side of the box; at the matrix, it hangs below the pin
|
|
210
|
+
for (const [q, sp] of [[pa, a], [pb, b]]) add('schematic_net_label', { text: t.net ?? chip.source.name, anchor_position: q.center, center: q.center,
|
|
211
|
+
anchor_side: q.side_of_component === 'left' ? 'right' : q.side_of_component === 'right' ? 'left' : 'top', source_port_id: sp.source.source_port_id });
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
const corner = { x: pb.center.x, y: pa.center.y };
|
|
215
|
+
add('schematic_trace', { source_trace_id: `${pa.schematic_port_id}-${pb.schematic_port_id}`, junctions: [],
|
|
216
|
+
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) });
|
|
217
|
+
}
|
|
218
|
+
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' });
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ---- the autorouter ----
|
|
222
|
+
// The connections the rules don't draw, routed by tscircuit's capacity autorouter around everything already on the
|
|
223
|
+
// board. The board is described to it as tscircuit's own core describes it (pads, holes, the drawn tracks, and which
|
|
224
|
+
// of them are connected), and the same solver runs with the same settings, so a board routes as it did there.
|
|
225
|
+
// -> { traces: [{ route, source_trace_id, vias }] }
|
|
226
|
+
export async function routeInput(json, toRoute) {
|
|
227
|
+
const { getFullConnectivityMapFromCircuitJson } = await import('circuit-json-to-connectivity-map');
|
|
228
|
+
const conn = getFullConnectivityMapFromCircuitJson(json);
|
|
229
|
+
const linked = (id) => { const net = conn.getNetConnectedToId(id); return net ? [...new Set([id, net, ...conn.getIdsConnectedToNet(net)])] : [id]; };
|
|
230
|
+
const comp = Object.fromEntries(json.filter((e) => e.type === 'source_component').map((e) => [e.source_component_id, e.name]));
|
|
231
|
+
const src = Object.fromEntries(json.filter((e) => e.type === 'source_port').map((e) => [e.source_port_id, e]));
|
|
232
|
+
const pcbPort = Object.fromEntries(json.filter((e) => e.type === 'pcb_port').map((e) => [e.pcb_port_id, e]));
|
|
233
|
+
const meta = (e, idKey) => { const sp = src[pcbPort[e.pcb_port_id]?.source_port_id];
|
|
234
|
+
return { [idKey]: e[idKey], pcb_port_id: e.pcb_port_id, source_component_name: comp[sp?.source_component_id], source_port_name: sp?.name }; };
|
|
235
|
+
// Pads joined inside their part (a Pico's GND pins) say so: the router may join them on the board too
|
|
236
|
+
const inside = new Map();
|
|
237
|
+
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]);
|
|
238
|
+
const joined = (e) => { const ids = inside.get(pcbPort[e.pcb_port_id]?.source_port_id); return ids ? { offBoardConnectsTo: ids, netIsAssignable: true } : {}; };
|
|
239
|
+
const b = json.find((e) => e.type === 'pcb_board'), obstacles = [];
|
|
240
|
+
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
|
|
241
|
+
if (e.type === 'pcb_smtpad') obstacles.push({ circuitJsonMetadata: meta(e, 'pcb_smtpad_id'), componentId: e.pcb_component_id, type: 'rect', layers: [e.layer],
|
|
242
|
+
center: { x: e.x, y: e.y }, width: e.width, height: e.height, connectedTo: linked(e.pcb_smtpad_id), ...joined(e) });
|
|
243
|
+
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'],
|
|
244
|
+
center: { x: e.x, y: e.y }, width: e.outer_diameter, height: e.outer_diameter, connectedTo: linked(e.pcb_plated_hole_id), ...joined(e) });
|
|
245
|
+
if (e.type === 'pcb_hole') obstacles.push({ componentId: e.pcb_component_id, obstacleId: e.pcb_hole_id, isNonPlatedHole: true, type: 'rect', shape: 'circle',
|
|
246
|
+
layers: ['top', 'bottom'], center: { x: e.x, y: e.y }, width: e.hole_diameter, height: e.hole_diameter, connectedTo: [] });
|
|
247
|
+
}
|
|
248
|
+
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,
|
|
249
|
+
connection_name: e.source_trace_id, connectsTo: [e.route[0].start_pcb_port_id, e.route.at(-1).end_pcb_port_id],
|
|
250
|
+
route: e.route.map((p) => ({ route_type: p.route_type, x: p.x, y: p.y, width: p.width, layer: p.layer })) }));
|
|
251
|
+
const connections = toRoute.map(({ t, a, b: z, id }) => ({ name: id, source_trace_id: id, nominalTraceWidth: t.width, width: t.width,
|
|
252
|
+
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,
|
|
253
|
+
port_selector: `${comp[q.source.source_component_id]}.${q.source.name}` })) }));
|
|
254
|
+
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 },
|
|
255
|
+
obstacles, connections, traces, layerCount: 2, allowBlindAndBuriedVias: false, minTraceWidth: 0.15, nominalTraceWidth: 0.15, // tscircuit's defaults
|
|
256
|
+
// the board's fab minimums, under the names the router reads
|
|
257
|
+
minViaDiameter: b.min_via_pad_diameter, minViaHoleDiameter: b.min_via_hole_diameter, minViaPadDiameter: b.min_via_pad_diameter,
|
|
258
|
+
min_via_hole_diameter: b.min_via_hole_diameter, min_via_pad_diameter: b.min_via_pad_diameter,
|
|
259
|
+
minTraceToPadEdgeClearance: b.min_trace_to_pad_edge_clearance, minTraceToHoleEdgeClearance: b.min_trace_to_hole_edge_clearance,
|
|
260
|
+
minViaHoleEdgeToViaHoleEdgeClearance: b.min_via_hole_edge_to_via_hole_edge_clearance, minPlatedHoleDrillEdgeToDrillEdgeClearance: b.min_plated_hole_drill_edge_to_drill_edge_clearance,
|
|
261
|
+
minPadEdgeToPadEdgeClearance: b.min_pad_edge_to_pad_edge_clearance, minBoardEdgeClearance: b.min_board_edge_clearance });
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// The drawn tracks become fixed obstacles for the router: each segment a box a track wide (a diagonal one, a run of
|
|
265
|
+
// boxes about a track-width long), each via a square. Ported from tscircuit core's withFixedTraces (MIT licence,
|
|
266
|
+
// github.com/tscircuit/core), so a board is routed around them as it is there.
|
|
267
|
+
function fixedTraces(input) {
|
|
268
|
+
const obstacles = [];
|
|
269
|
+
for (const trace of input.traces) {
|
|
270
|
+
const connectedTo = [trace.connection_name ?? trace.pcb_trace_id, ...(trace.connectsTo ?? [])];
|
|
271
|
+
trace.route.forEach((p, i) => {
|
|
272
|
+
if (p.route_type === 'via') {
|
|
273
|
+
const d = p.via_diameter ?? input.minViaPadDiameter ?? input.minViaDiameter ?? 0.6;
|
|
274
|
+
obstacles.push({ type: 'rect', center: { x: p.x, y: p.y }, width: d, height: d, layers: ['top', 'bottom'], connectedTo });
|
|
275
|
+
}
|
|
276
|
+
const q = trace.route[i + 1];
|
|
277
|
+
if (!q || !['wire', 'via'].includes(p.route_type) || !['wire', 'via'].includes(q.route_type)) return;
|
|
278
|
+
const width = p.route_type === 'wire' ? p.width : q.route_type === 'wire' ? q.width : input.minTraceWidth;
|
|
279
|
+
const layer = p.route_type === 'wire' ? p.layer : p.to_layer;
|
|
280
|
+
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));
|
|
281
|
+
for (let k = 0; k < steps; k++) {
|
|
282
|
+
const x1 = p.x + ((q.x - p.x) * k) / steps, y1 = p.y + ((q.y - p.y) * k) / steps;
|
|
283
|
+
const x2 = p.x + ((q.x - p.x) * (k + 1)) / steps, y2 = p.y + ((q.y - p.y) * (k + 1)) / steps;
|
|
284
|
+
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 });
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
return { ...input, traces: [], obstacles: [...input.obstacles, ...obstacles] };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async function defaultRouter(json, toRoute) {
|
|
292
|
+
// The autorouter uses JavaScript features from Node 22 (iterator helpers): say so, rather than fail inside it
|
|
293
|
+
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`);
|
|
294
|
+
const { AutoroutingPipelineSolver } = await import('@tscircuit/capacity-autorouter');
|
|
295
|
+
const srj = await routeInput(json, toRoute);
|
|
296
|
+
const solver = new AutoroutingPipelineSolver(srj, { cacheProvider: null });
|
|
297
|
+
solver.solve();
|
|
298
|
+
if (solver.failed) throw new Error(`the autorouter couldn't route every connection: ${solver.error || 'no reason given'}`);
|
|
299
|
+
// The routes for the connections asked for (the solver also hands back the fixed ones), each from its first pad to
|
|
300
|
+
// its second, as tscircuit records them
|
|
301
|
+
const traces = [], asked = new Map(srj.connections.map((c) => [c.name, c.pointsToConnect]));
|
|
302
|
+
const far = (p, q) => Math.hypot(p.x - q.x, p.y - q.y);
|
|
303
|
+
for (const t of solver.getOutputSimplifiedPcbTraces()) {
|
|
304
|
+
const ends = asked.get(t.connection_name);
|
|
305
|
+
if (!ends) continue;
|
|
306
|
+
let route = t.route.map((p) => ({ ...p }));
|
|
307
|
+
if (far(route[0], ends[0]) > far(route.at(-1), ends[0])) route = route.reverse(); // (a via keeps its layer labels, as in tscircuit)
|
|
308
|
+
route[0].start_pcb_port_id = ends[0].pcb_port_id;
|
|
309
|
+
route.at(-1).end_pcb_port_id = ends[1].pcb_port_id;
|
|
310
|
+
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,
|
|
311
|
+
hole_diameter: srj.minViaHoleDiameter, outer_diameter: srj.minViaPadDiameter, layers: ['top', 'bottom'], from_layer: p.from_layer, to_layer: p.to_layer })) });
|
|
312
|
+
}
|
|
313
|
+
if (traces.length < srj.connections.length) throw new Error(`the autorouter routed ${traces.length} of ${srj.connections.length} connections`);
|
|
314
|
+
return { traces };
|
|
315
|
+
}
|
package/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rulecad/pcb",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Circuit boards from rules: parts from rule sets or any standard footprint, quarter-turn rotation, nets and autorouting; keyboards with every matrix track drawn by rule. 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
|
],
|
|
@@ -16,7 +17,9 @@
|
|
|
16
17
|
"tscircuit",
|
|
17
18
|
"gerber",
|
|
18
19
|
"jlcpcb",
|
|
19
|
-
"kicad"
|
|
20
|
+
"kicad",
|
|
21
|
+
"circuit-board",
|
|
22
|
+
"autorouter"
|
|
20
23
|
],
|
|
21
24
|
"engines": {
|
|
22
25
|
"node": ">=20.6"
|
|
@@ -27,14 +30,21 @@
|
|
|
27
30
|
"directory": "packages/pcb"
|
|
28
31
|
},
|
|
29
32
|
"dependencies": {
|
|
33
|
+
"@flatten-js/core": "1.6.14",
|
|
30
34
|
"@jscad/modeling": "^2.12.0",
|
|
31
35
|
"@resvg/resvg-js": "2.6.2",
|
|
32
|
-
"@rulecad/cad": "^0.
|
|
36
|
+
"@rulecad/cad": "^0.2.0",
|
|
37
|
+
"@tscircuit/capacity-autorouter": "0.0.982",
|
|
38
|
+
"@tscircuit/checks": "0.0.253",
|
|
39
|
+
"@tscircuit/circuit-json-util": "0.0.122",
|
|
40
|
+
"@tscircuit/footprinter": "0.0.431",
|
|
33
41
|
"@tscircuit/math-utils": "0.0.36",
|
|
42
|
+
"circuit-json": "0.0.525",
|
|
43
|
+
"circuit-json-to-connectivity-map": "0.0.34",
|
|
34
44
|
"circuit-json-to-gerber": "0.0.113",
|
|
35
45
|
"circuit-json-to-kicad": "0.0.231",
|
|
36
46
|
"circuit-json-to-pnp-csv": "0.0.20",
|
|
37
47
|
"circuit-to-svg": "0.0.450",
|
|
38
|
-
"
|
|
48
|
+
"schematic-symbols": "0.0.248"
|
|
39
49
|
}
|
|
40
50
|
}
|
package/pcb.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
// Boards: a design whose .json says "engine": "pcb". Its main(v) returns a
|
|
2
|
-
//
|
|
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 {
|
|
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,
|
|
32
|
-
const hole = (x, y, 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
|
|
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
|
-
|
|
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,13 +44,62 @@ 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
|
|
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 }; };
|
|
53
51
|
// The Pico's GPIO pins down one edge: [{ pin, gpio }]
|
|
54
52
|
export const picoGpio = (side) => Object.entries(loadRuleSet('pico').rules[`gpio_${side}`].pins).map(([pin, gpio]) => ({ pin: Number(pin), gpio }));
|
|
55
53
|
|
|
54
|
+
// ---------- any board ----------
|
|
55
|
+
// Parts for any board, not only keyboards. Each returns a part as circuit.mjs describes it: put them in board.parts, join
|
|
56
|
+
// their pins with board.nets, and set board.autoroute. o (all optional): { rotation: 0 | 90 | 180 | 270, layer,
|
|
57
|
+
// mpn, labels: { pin1: 'GND', ... }, sch: { x, y } }. Pin labels name the pins in nets ('U1.GND') and on the schematic.
|
|
58
|
+
const chip = (name, x, y, o, more) => ({ name, ftype: 'simple_chip', x, y, layer: o.layer ?? 'top', rotation: o.rotation ?? 0, mpn: o.mpn,
|
|
59
|
+
courtyard: o.courtyard ?? null, sch: { x: o.sch?.x ?? x / 2, y: o.sch?.y ?? y / 2, pinLabels: o.labels ?? {} }, ...more });
|
|
60
|
+
const pinNo = (p) => Number(p.pin.replace(/\D/g, ''));
|
|
61
|
+
// A rule set's pin map ({ "1": "GND", ... }) as pin labels ({ pin1: 'GND', ... })
|
|
62
|
+
const labelsFrom = (set, rule) => Object.fromEntries(Object.entries(loadRuleSet(set).rules[rule].pins).map(([n, l]) => [`pin${n}`, l]));
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Any standard footprint as a part, by its footprinter name: 'jst4_sh', 'pinrow4', 'soic8', '0603', 'sot23' ...
|
|
66
|
+
* (SMD and through-hole). Pins are numbered as the footprint numbers them.
|
|
67
|
+
* @param {string} name @param {string} footprint @param {number} x @param {number} y @param {object} [o]
|
|
68
|
+
*/
|
|
69
|
+
export const part = (name, footprint, x, y, o = {}) => chip(name, x, y, o, { footprint });
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* A pin header from rules (r: pitch, drill, pad; row_spacing for two rows): o.pins pins per row, o.rows 1 or 2.
|
|
73
|
+
* Two rows are numbered as a DIP: 1 to n down the left, n + 1 to 2n up the right.
|
|
74
|
+
*/
|
|
75
|
+
export function header(name, x, y, r, o = {}) {
|
|
76
|
+
const n = o.pins, two = (o.rows ?? 1) === 2, pads = [];
|
|
77
|
+
for (let i = 0; i < n; i++) {
|
|
78
|
+
const py = ((n - 1) / 2 - i) * r.pitch;
|
|
79
|
+
pads.push(pad(`pin${i + 1}`, two ? -r.row_spacing / 2 : 0, py, r.drill, r.pad));
|
|
80
|
+
if (two) pads.push(pad(`pin${2 * n - i}`, r.row_spacing / 2, py, r.drill, r.pad));
|
|
81
|
+
}
|
|
82
|
+
return chip(name, x, y, o, { pads: pads.sort((a, b) => pinNo(a) - pinNo(b)) });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** A Seeed Studio XIAO module (any of the family) on header pins, USB at +y, from the xiao rules; pins labelled D0..D10, 5V, GND, 3V3. */
|
|
86
|
+
export const xiao = (name, x, y, r, o = {}) =>
|
|
87
|
+
header(name, x, y, r, { pins: r.pins / 2, rows: 2, mpn: 'Seeed Studio XIAO', courtyard: { w: r.width, h: r.length }, labels: labelsFrom('xiao', 'pins'), ...o });
|
|
88
|
+
|
|
89
|
+
/** A Qwiic / STEMMA QT socket (JST SH, 4 pins, side entry), from the qwiic rules: the plug goes in from +y, the tabs' side. */
|
|
90
|
+
export function qwiic(name, x, y, r, o = {}) {
|
|
91
|
+
const n = r.pins, smd = (k, x, y, w, h) => ({ pin: `pin${k}`, smd: true, hint: String(k), x, y, w, h });
|
|
92
|
+
const pads = Array.from({ length: n }, (_, i) => smd(i + 1, (i - (n - 1) / 2) * r.pitch, r.pad_y, r.pad_w, r.pad_h))
|
|
93
|
+
.concat([-1, 1].map((side, i) => smd(n + i + 1, side * r.tab_x, r.tab_y, r.tab_w, r.tab_h)));
|
|
94
|
+
return chip(name, x, y, { mpn: 'JST SM04B-SRSS-TB', labels: { ...labelsFrom('qwiic', 'pins'), pin5: 'TAB1', pin6: 'TAB2' }, ...o }, { pads });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** A JST PH socket (2.0 mm, through-hole), from the jstph rules: o.pins pins in a row along x (default 2). */
|
|
98
|
+
export function jstph(name, x, y, r, o = {}) {
|
|
99
|
+
const n = o.pins ?? 2;
|
|
100
|
+
return chip(name, x, y, { mpn: `JST B${n}B-PH-K-S`, ...o }, { pads: Array.from({ length: n }, (_, i) => pad(`pin${i + 1}`, (i - (n - 1) / 2) * r.pitch, 0, r.drill, r.pad)) });
|
|
101
|
+
}
|
|
102
|
+
|
|
56
103
|
// ---------- the keyboard ----------
|
|
57
104
|
// 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
105
|
// the layout row; the matrix column comes from lining every row up with the widest one: each row's keys take, in order,
|
|
@@ -80,7 +127,7 @@ export function fromLayout(list, pitch) {
|
|
|
80
127
|
return ks;
|
|
81
128
|
}
|
|
82
129
|
|
|
83
|
-
// keyboard(v, keys) -> { board (
|
|
130
|
+
// keyboard(v, keys) -> { board (as circuit.mjs describes it), matrix }; v holds the mx, pico, sod123, m2 and jlcpcb rules and
|
|
84
131
|
// the board's variables:
|
|
85
132
|
// track, clear our track width and the gap we keep to anything
|
|
86
133
|
// margin board edge beyond the keys (and the Pico, beside them)
|
|
@@ -113,7 +160,7 @@ export function keyboard(v, keys) {
|
|
|
113
160
|
const other = Object.entries(loadRuleSet('pico').rules.other_pins.pins).map(([pin, name]) => ({ pin: Number(pin), gpio: name }));
|
|
114
161
|
const gpio = [...picoGpio('left'), ...picoGpio('right')];
|
|
115
162
|
const pinLabels = Object.fromEntries([...gpio, ...other].map((g) => [`pin${g.pin}`, g.gpio]));
|
|
116
|
-
const sch = {
|
|
163
|
+
const sch = { x: (cols.length + 1.5) * SX, y: -(rows.length - 1) * SY / 2, pinLabels };
|
|
117
164
|
let left = keyBox.left - v.margin, right = keyBox.right + v.margin, pico0, pinAt, pinRows = [];
|
|
118
165
|
if (under) {
|
|
119
166
|
// 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 +187,16 @@ export function keyboard(v, keys) {
|
|
|
140
187
|
pico0 = { x: cx, y: cy };
|
|
141
188
|
right = cx + p.width / 2 + v.margin;
|
|
142
189
|
}
|
|
143
|
-
const picoPart =
|
|
144
|
-
|
|
145
|
-
|
|
190
|
+
const picoPart = { name: 'U1', ftype: 'simple_chip', mpn: 'Raspberry Pi Pico', x: pico0.x, y: pico0.y, layer: 'top', sch,
|
|
191
|
+
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); }),
|
|
192
|
+
courtyard: under ? null : { w: p.width, h: p.length } };
|
|
146
193
|
|
|
147
194
|
const parts = [picoPart], traces = [];
|
|
148
|
-
const
|
|
195
|
+
const pts = (list) => list.map(([x, y]) => ({ x, y })); // a track's corners between its two pads, on the board
|
|
149
196
|
for (const k of keys) {
|
|
150
|
-
parts.push(mx(`K${k.n}`, k.x, k.y, m, {
|
|
151
|
-
|
|
197
|
+
parts.push(mx(`K${k.n}`, k.x, k.y, m, { x: k.c * SX, y: -k.r * SY }),
|
|
198
|
+
{ 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 } });
|
|
199
|
+
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
200
|
}
|
|
153
201
|
|
|
154
202
|
// ---- rows: cathode to cathode along the back ----
|
|
@@ -156,7 +204,7 @@ export function keyboard(v, keys) {
|
|
|
156
204
|
const ks = keys.filter((k) => k.r === r).sort((a, b) => a.x - b.x);
|
|
157
205
|
for (let i = 1; i < ks.length; i++) {
|
|
158
206
|
const a = ks[i - 1], b = ks[i];
|
|
159
|
-
traces.push(
|
|
207
|
+
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
208
|
}
|
|
161
209
|
}
|
|
162
210
|
|
|
@@ -195,7 +243,7 @@ export function keyboard(v, keys) {
|
|
|
195
243
|
const ks = keys.filter((k) => k.c === c).sort((a, b) => a.r - b.r);
|
|
196
244
|
for (let j = 1; j < ks.length; j++) {
|
|
197
245
|
const a = ks[j - 1], b = ks[j];
|
|
198
|
-
traces.push(
|
|
246
|
+
traces.push({ from: [`K${a.n}`, 'pin2'], to: [`K${b.n}`, 'pin2'], width: v.track, path: pts(columnPath(a, b)) });
|
|
199
247
|
}
|
|
200
248
|
}
|
|
201
249
|
|
|
@@ -203,8 +251,8 @@ export function keyboard(v, keys) {
|
|
|
203
251
|
const rowPin = {}, colPin = {};
|
|
204
252
|
if (under) {
|
|
205
253
|
// 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:
|
|
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:
|
|
254
|
+
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'] })) })),
|
|
255
|
+
...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
256
|
const free = [...gpio];
|
|
209
257
|
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
258
|
const left_ = [...ends];
|
|
@@ -215,7 +263,7 @@ export function keyboard(v, keys) {
|
|
|
215
263
|
left_.splice(left_.indexOf(best.e), 1); free.splice(free.indexOf(best.g), 1);
|
|
216
264
|
(best.e.kind === 'row' ? rowPin : colPin)[best.e.id] = best.g;
|
|
217
265
|
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(
|
|
266
|
+
traces.push({ from: near.sel, to: ['U1', `pin${best.g.pin}`], width: v.track }); // no path: the autorouter's
|
|
219
267
|
}
|
|
220
268
|
} else {
|
|
221
269
|
// Beside: rows take the free left-edge pins nearest their row line, columns the lowest of the rest; every track
|
|
@@ -232,13 +280,13 @@ export function keyboard(v, keys) {
|
|
|
232
280
|
cols.forEach((c, i) => { colPin[c] = lowest[i]; });
|
|
233
281
|
for (const r of rows) {
|
|
234
282
|
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(
|
|
236
|
-
|
|
283
|
+
traces.push({ from: [`D${last.n}`, 'cathode'], to: ['U1', `pin${rowPin[r].pin}`], width: v.track,
|
|
284
|
+
path: pts([[last.x + cathodeX, last.y + lineY], [nearPins - lane, last.y + lineY], [nearPins - lane, pinY]]) });
|
|
237
285
|
}
|
|
238
286
|
cols.forEach((c, i) => {
|
|
239
287
|
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(
|
|
241
|
-
|
|
288
|
+
traces.push({ from: [`K${last.n}`, 'pin2'], to: ['U1', `pin${colPin[c].pin}`], width: v.track,
|
|
289
|
+
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
290
|
});
|
|
243
291
|
}
|
|
244
292
|
|
|
@@ -251,16 +299,16 @@ export function keyboard(v, keys) {
|
|
|
251
299
|
// Silkscreen for whoever solders it: the Pico's outline (on the side it's fitted) with USB marked, each switch's
|
|
252
300
|
// outline, and a bar at each diode's cathode (on the back, between its pads, next to the cathode)
|
|
253
301
|
const silk = v.jlcpcb.silk_line * 2, text = v.jlcpcb.silk_text * 2;
|
|
254
|
-
const
|
|
255
|
-
|
|
302
|
+
const rect = (x, y, w, ht, layer) => ({ rect: { x, y, w, h: ht }, layer });
|
|
303
|
+
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
304
|
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
305
|
const marks = [...(under ? outline(pico0.x, pico0.y, p.length, p.width, 'bottom') : outline(pico0.x, pico0.y, p.width, p.length)),
|
|
258
|
-
|
|
306
|
+
{ text: 'USB', x: usbAt.x, y: usbAt.y, size: text, layer: under ? 'bottom' : 'top' },
|
|
259
307
|
...keys.flatMap((k) => outline(k.x, k.y, m.pcb_body, m.pcb_body)),
|
|
260
|
-
...keys.map((k) =>
|
|
261
|
-
...(v.margin >= text * 2 ? [
|
|
262
|
-
const board =
|
|
263
|
-
|
|
308
|
+
...keys.map((k) => rect(k.x + cathodeX + d.pad_w / 2 + silk * 2, k.y + diode.y, silk, d.pad_h, 'bottom')),
|
|
309
|
+
...(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' }] : [])];
|
|
310
|
+
const board = { center: { x: cx, y: cy }, width: right - left, height: top - bottom, thickness: v.thickness, autoroute: under,
|
|
311
|
+
parts, traces, silk: marks, holes: corners.map(([x, y]) => hole(x, y, v.m2.clearance)) };
|
|
264
312
|
const matrix = {
|
|
265
313
|
keys: keys.map((k) => ({ key: `K${k.n}`, ...(k.label ? { label: k.label } : {}), row: k.r, col: k.c })),
|
|
266
314
|
rows: Object.fromEntries(rows.map((r) => [r, rowPin[r].gpio])), cols: Object.fromEntries(cols.map((c) => [c, colPin[c].gpio])),
|
|
@@ -270,37 +318,13 @@ export function keyboard(v, keys) {
|
|
|
270
318
|
}
|
|
271
319
|
|
|
272
320
|
// ---------- building ----------
|
|
273
|
-
// render(board) -> Circuit JSON.
|
|
274
|
-
|
|
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
|
-
}
|
|
321
|
+
// render(board) -> Circuit JSON (circuit.mjs)
|
|
322
|
+
export const render = (board, options) => circuitJson(board, options); // options.route: a router in place of the autorouter
|
|
299
323
|
|
|
300
324
|
// The board as a solid (previews, fits, the on-screen copy, the model page): the bare board, drilled, bottom at z = 0. Its
|
|
301
325
|
// copper is drawn in the 2D views (views), not in 3D. The board is a 2D outline with its holes, extruded: its faces
|
|
302
326
|
// triangulate cleanly (cylinders subtracted from a block leave long slivers).
|
|
303
|
-
export function solids(json, { thickness, smooth }) {
|
|
327
|
+
export function solids(json, { thickness, smooth = 16 }) { // smooth: segments per hole (a board without the variable gets 16)
|
|
304
328
|
const { rectangle, circle } = jscad.primitives, { subtract } = jscad.booleans, { extrudeLinear } = jscad.extrusions;
|
|
305
329
|
const b = json.find((e) => e.type === 'pcb_board');
|
|
306
330
|
const drills = json.filter((e) => e.type === 'pcb_hole' || e.type === 'pcb_plated_hole' || e.type === 'pcb_via')
|
|
@@ -316,7 +340,14 @@ export function outputs(json, matrix) {
|
|
|
316
340
|
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
341
|
}
|
|
318
342
|
|
|
343
|
+
/**
|
|
344
|
+
* A board's mounting holes (the board's own holes, not a part's): [{ x, y, d }] in mm, in the board's frame (as
|
|
345
|
+
* board_x and board_y). A case reads them to put its bosses under them; a holes fit checks screws find their inserts.
|
|
346
|
+
*/
|
|
347
|
+
export const holes = (json) => json.filter((e) => e.type === 'pcb_hole' && !e.pcb_component_id).map((e) => ({ x: e.x, y: e.y, d: e.hole_diameter }));
|
|
348
|
+
|
|
319
349
|
// ---------- checks ----------
|
|
350
|
+
const mm = (x) => String(Number(x.toFixed(3))); // 0.30000000000000004 reads as 0.3
|
|
320
351
|
// checks(json, v) -> findings ({ level, text }): tscircuit's own design checks, the fab's limits, and the matrix
|
|
321
352
|
export async function checks(json, v, matrix) {
|
|
322
353
|
const { runAllChecks } = await import('@tscircuit/checks');
|
|
@@ -327,7 +358,7 @@ export async function checks(json, v, matrix) {
|
|
|
327
358
|
const traces = json.filter((e) => e.type === 'pcb_trace'), unrouted = json.filter((e) => e.type === 'source_trace').length - traces.length;
|
|
328
359
|
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
360
|
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
|
|
361
|
+
out.push(thin.length ? { level: 'fail', text: `tracks under JLCPCB's ${j.track_min} mm` } : { level: 'pass', text: `tracks ${mm(v.track ?? Math.min(...traces.flatMap((t) => t.route).map((p) => p.width).filter((w) => w != null)))} mm${v.clear != null ? `, gaps ${mm(v.clear)} mm` : ''}: over JLCPCB's ${j.track_min} / ${j.gap_min} mm` });
|
|
331
362
|
const drills = json.filter((e) => e.type === 'pcb_hole' || e.type === 'pcb_plated_hole').map((e) => e.hole_diameter ?? e.hole_width);
|
|
332
363
|
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
364
|
const b = json.find((e) => e.type === 'pcb_board'), size = `${b.width.toFixed(1)} × ${b.height.toFixed(1)} mm`;
|