@rulecad/pcb 0.2.1 → 0.3.1
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 +24 -11
- package/circuit.mjs +97 -18
- package/package.json +2 -2
- package/pcb.mjs +96 -6
package/README.md
CHANGED
|
@@ -15,19 +15,19 @@ them, unused; rulecad's tests fail if it's ever loaded.
|
|
|
15
15
|
nets, and let the autorouter draw the tracks:
|
|
16
16
|
|
|
17
17
|
```js
|
|
18
|
-
// projects/
|
|
18
|
+
// projects/macropad/objects/pcb.js: four keys wired straight to a XIAO, and a Qwiic socket for a small display
|
|
19
19
|
export function main(v) {
|
|
20
|
-
const { xiao,
|
|
20
|
+
const { xiao, mx, qwiic } = v.pcb, p = v.mx.pitch;
|
|
21
|
+
const keys = Array.from({ length: v.keys }, (_, i) => mx(`K${i + 1}`, (i - (v.keys - 1) / 2) * p, v.key_y, v.mx));
|
|
21
22
|
return {
|
|
22
|
-
center: { x:
|
|
23
|
+
center: { x: 0, y: 0 }, width: p * v.keys + v.margin * 2, height: v.height, thickness: v.thickness, track: v.track, autoroute: true,
|
|
23
24
|
parts: [
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
qwiic('
|
|
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' })
|
|
25
|
+
...keys, // one MX switch per key, from the mx rules
|
|
26
|
+
xiao('U1', 0, v.mcu_y, v.xiao), // pins labelled D0..D10, 3V3, GND, 5V from the xiao rules
|
|
27
|
+
qwiic('J1', v.qwiic_x, v.mcu_y, v.qwiic, { rotation: v.qwiic_turn }) // turned to face the board's edge
|
|
29
28
|
],
|
|
30
|
-
nets: { GND: ['U1.GND', 'J1.GND',
|
|
29
|
+
nets: { GND: ['U1.GND', 'J1.GND', ...keys.map((k) => `${k.name}.pin2`)], SDA: ['U1.D4', 'J1.SDA'], SCL: ['U1.D5', 'J1.SCL'],
|
|
30
|
+
...Object.fromEntries(keys.map((k, i) => [`KEY${i + 1}`, [`${k.name}.pin1`, `U1.D${i}`]])) },
|
|
31
31
|
traces: [], silk: [], holes: []
|
|
32
32
|
};
|
|
33
33
|
}
|
|
@@ -36,10 +36,23 @@ export function main(v) {
|
|
|
36
36
|
- **Parts:** `xiao`, `qwiic`, `jstph` and `header` take their sizes and pinouts from rule sets in @rulecad/rules (each
|
|
37
37
|
value with its source); `part(name, footprint, x, y)` takes any footprinter name: `soic8`, `0603`, `sot23`,
|
|
38
38
|
`jst4_sh`, `pinrow4` (SMD or through-hole). Options: `rotation` (0, 90, 180, 270), `layer`, `mpn`, `labels`.
|
|
39
|
+
- **Parts from KiCad:** `fromKicad(text)` turns a KiCad footprint (`.kicad_mod`) into pad data (y up) and its
|
|
40
|
+
courtyard. Keep the result in a rule set's `"footprints"` with its source and pin names (`labels: { pin1: 'GND' }`),
|
|
41
|
+
then place it with `footprint(name, data, x, y, o)`: a design reads rule sets, never files or the network.
|
|
42
|
+
- **Any board shape:** give `outline: [{ x, y }, ...]` in place of a centre and size. `shape.rounded(w, h, r)`,
|
|
43
|
+
`shape.bevelled(w, h, b)`, `shape.circle(d)` and `shape.rect(w, h)` make common ones. The router keeps tracks
|
|
44
|
+
inside the outline, and a check finds any pad outside it.
|
|
45
|
+
- **Copper of any shape:** a pad `{ pin, smd: true, x, y, points: [[x, y], ...] }` (x, y: where tracks join it), such
|
|
46
|
+
as a ring from `shape.ring(r0, r1)` (a rotary contact, a touch pad). The router sees it as strips that follow its shape.
|
|
39
47
|
- **Nets:** each net's pins are joined by the shortest set of links between them, at `board.track` (or the net's own
|
|
40
48
|
`{ 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
|
|
42
|
-
|
|
49
|
+
- **What a board tells the parts around it:** `board_x`, `board_y`, `board_w`, `board_d`, its mounting holes, where
|
|
50
|
+
each part is (`parts(json)`: `{ U1: { x, y, w, h, layer, rotation } }`) and a shaped board's outline, so a case can
|
|
51
|
+
read it (`"uses": ["macropad/pcb"]` in @rulecad/cad: bosses under its holes, openings over its USB socket and
|
|
52
|
+
buttons) and a holes fit can check the screws find their inserts.
|
|
53
|
+
- **Routing:** connections no rule draws go to tscircuit's capacity autorouter. Its tracks come back as pieces of each
|
|
54
|
+
net, named here by the pads they end on; where a piece ends on a one-sided pad from the other layer, a via is added
|
|
55
|
+
clear of the pad; and the build fails if any pad is left without a track. Drawn tracks can name their `layer`.
|
|
43
56
|
- **Keyboards** are one recipe built on the same parts: every matrix track drawn by rule, one diode per key.
|
|
44
57
|
|
|
45
58
|
A keyboard:
|
package/circuit.mjs
CHANGED
|
@@ -3,14 +3,17 @@
|
|
|
3
3
|
// read the result. Only two tscircuit libraries are used, and neither needs a framework: footprinter (standard
|
|
4
4
|
// footprints such as SOD-123) and the capacity autorouter (connections a board leaves to it).
|
|
5
5
|
//
|
|
6
|
-
// board: { center: { x, y }, width, height, thickness, parts, traces, nets, track, silk, holes, autoroute }
|
|
6
|
+
// board: { center: { x, y }, width, height, thickness, parts, traces, nets, track, silk, holes, autoroute }, or
|
|
7
|
+
// { outline: [{ x, y }, ...], thickness, ... }: any shape, a closed polygon (counter-clockwise); its centre and size
|
|
8
|
+
// are its bounds. pcb.shape makes common ones (rounded, bevelled, circle).
|
|
7
9
|
// part { name, ftype: 'simple_switch' | 'simple_chip' | 'simple_diode', mpn, x, y, layer: 'top' | 'bottom',
|
|
8
10
|
// 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 }]
|
|
11
|
+
// pads: [{ pin, x, y, hole, outer }] (plated, relative to the part) or [{ pin, smd: true, hint, x, y, w, h }]
|
|
12
|
+
// or [{ pin, smd: true, x, y, points: [[x, y], ...] }] (copper of any shape; x, y: where tracks join it),
|
|
10
13
|
// holes: [{ x, y, d }], courtyard: { w, h } | null,
|
|
11
14
|
// footprint: 'sod123' | 'jst4_sh' | 'pinrow4' ... (a footprinter string, SMD or through-hole, instead of pads),
|
|
12
15
|
// sch: { x, y, symbol?, pinLabels? } }
|
|
13
|
-
// trace { from: [part, port], to: [part, port], width, path: [{ x, y }] (absolute, between the two ports) }; a trace
|
|
16
|
+
// trace { from: [part, port], to: [part, port], width, path: [{ x, y }] (absolute, between the two ports), layer }; a trace
|
|
14
17
|
// without a path is left to the autorouter when board.autoroute is set
|
|
15
18
|
// nets { GND: ['U1.GND', 'J1.1', 'J2.1'] } or { GND: { pins: [...], width } }: each net's pins are joined by the
|
|
16
19
|
// shortest set of connections between them (a minimum spanning tree), left to the autorouter, at the net's
|
|
@@ -35,7 +38,50 @@ const FAB_MINIMUMS = { min_trace_width: 0.1, min_via_hole_diameter: 0.2, min_via
|
|
|
35
38
|
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
39
|
min_plated_hole_drill_edge_to_drill_edge_clearance: 0.15, min_board_edge_clearance: 0.2 };
|
|
37
40
|
|
|
41
|
+
// A board with an outline: its centre and size from the outline's bounds (an explicit centre, width or height wins)
|
|
42
|
+
export function bounds(points) {
|
|
43
|
+
const xs = points.map((p) => p.x), ys = points.map((p) => p.y), x0 = Math.min(...xs), x1 = Math.max(...xs), y0 = Math.min(...ys), y1 = Math.max(...ys);
|
|
44
|
+
return { center: { x: (x0 + x1) / 2, y: (y0 + y1) / 2 }, width: x1 - x0, height: y1 - y0 };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Is the point inside the polygon ([{ x, y }])? (even-odd rule)
|
|
48
|
+
function insidePolygon(points, { x, y }) {
|
|
49
|
+
let hit = false;
|
|
50
|
+
for (let i = 0, j = points.length - 1; i < points.length; j = i++) {
|
|
51
|
+
const a = points[i], b = points[j];
|
|
52
|
+
if ((a.y > y) !== (b.y > y) && x < ((b.x - a.x) * (y - a.y)) / (b.y - a.y) + a.x) hit = !hit;
|
|
53
|
+
}
|
|
54
|
+
return hit;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// A polygon as horizontal strips step tall (each the span of the polygon across its middle): boxes a router can use
|
|
58
|
+
function strips(points, step) {
|
|
59
|
+
const ys = points.map((q) => q.y), y0 = Math.min(...ys), y1 = Math.max(...ys), out = [];
|
|
60
|
+
for (let y = y0; y < y1; y += step) {
|
|
61
|
+
const mid = Math.min(y + step / 2, y1), xs = [];
|
|
62
|
+
for (let i = 0, j = points.length - 1; i < points.length; j = i++) {
|
|
63
|
+
const a = points[i], b = points[j];
|
|
64
|
+
if ((a.y > mid) !== (b.y > mid)) xs.push(a.x + ((mid - a.y) * (b.x - a.x)) / (b.y - a.y));
|
|
65
|
+
}
|
|
66
|
+
xs.sort((p, q) => p - q);
|
|
67
|
+
for (let k = 0; k + 1 < xs.length; k += 2) out.push({ x: (xs[k] + xs[k + 1]) / 2, y: y + Math.min(step, y1 - y) / 2, w: xs[k + 1] - xs[k], h: Math.min(step, y1 - y) });
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// An SMD pad's box: a rectangle's own, a polygon's bounds
|
|
73
|
+
export function smtBounds(e) {
|
|
74
|
+
if (e.shape !== 'polygon') return { x: e.x, y: e.y, w: e.width, h: e.height };
|
|
75
|
+
const xs = e.points.map((q) => q.x), ys = e.points.map((q) => q.y), x0 = Math.min(...xs), x1 = Math.max(...xs), y0 = Math.min(...ys), y1 = Math.max(...ys);
|
|
76
|
+
return { x: (x0 + x1) / 2, y: (y0 + y1) / 2, w: x1 - x0, h: y1 - y0 };
|
|
77
|
+
}
|
|
78
|
+
|
|
38
79
|
export async function circuitJson(board, { route = defaultRouter } = {}) {
|
|
80
|
+
board = { parts: [], traces: [], silk: [], ...board }; // the lists are optional
|
|
81
|
+
if (board.outline) {
|
|
82
|
+
if (board.outline.length < 3) throw new Error('a board outline needs at least 3 points');
|
|
83
|
+
board = { ...bounds(board.outline), ...board };
|
|
84
|
+
}
|
|
39
85
|
const out = [], next = {}, id = (type) => `${type}_${(next[type] = (next[type] ?? -1) + 1)}`;
|
|
40
86
|
const add = (type, fields) => { const e = { type, [`${type}_id`]: id(type), ...fields }; out.push(e); return e; };
|
|
41
87
|
|
|
@@ -60,7 +106,11 @@ export async function circuitJson(board, { route = defaultRouter } = {}) {
|
|
|
60
106
|
? { smd: true, pin: `pin${e.port_hints[0]}`, hint: e.port_hints[0], ...at(e.x, e.y), ...size(e.width, e.height) }
|
|
61
107
|
: { 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
108
|
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) =>
|
|
109
|
+
: part.pads.map((p) => {
|
|
110
|
+
if (!p.points) return { ...p, ...at(p.x, p.y), ...(p.smd ? size(p.w, p.h) : {}) };
|
|
111
|
+
const poly = p.points.map(([x, y]) => at(x, y)), xs = poly.map((q) => q.x), ys = poly.map((q) => q.y);
|
|
112
|
+
return { ...p, ...at(p.x, p.y), poly, w: Math.max(...xs) - Math.min(...xs), h: Math.max(...ys) - Math.min(...ys), cx: (Math.max(...xs) + Math.min(...xs)) / 2, cy: (Math.max(...ys) + Math.min(...ys)) / 2 };
|
|
113
|
+
});
|
|
64
114
|
const names = portNames(part, pads);
|
|
65
115
|
const sourcePorts = pads.map((p, i) => add('source_port', { name: names[i].name, pin_number: names[i].n, port_hints: names[i].hints,
|
|
66
116
|
source_component_id: comp.source_component_id, subcircuit_id: SUB }));
|
|
@@ -73,11 +123,11 @@ export async function circuitJson(board, { route = defaultRouter } = {}) {
|
|
|
73
123
|
placed.push({ part, comp, pads, sourcePorts, fpEls, at, mirror, size });
|
|
74
124
|
}
|
|
75
125
|
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 });
|
|
126
|
+
width: board.width, height: board.height, ...(board.outline ? { outline: board.outline.map(({ x, y }) => ({ x, y })) } : {}), material: 'fr4', ...FAB_MINIMUMS });
|
|
77
127
|
|
|
78
128
|
for (const { part, comp, pads, sourcePorts, fpEls, at, mirror, size } of placed) {
|
|
79
129
|
// 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])),
|
|
130
|
+
const extent = [...pads.map((p) => (p.poly ? [p.cx - p.w / 2, p.cy - p.h / 2, p.cx + p.w / 2, p.cy + p.h / 2] : 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
131
|
...(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
132
|
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
133
|
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],
|
|
@@ -86,7 +136,9 @@ export async function circuitJson(board, { route = defaultRouter } = {}) {
|
|
|
86
136
|
pads.forEach((p, i) => {
|
|
87
137
|
const layers = p.smd ? [part.layer] : ['top', 'bottom'];
|
|
88
138
|
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.
|
|
139
|
+
if (p.poly) { // copper of any shape: no paste (a contact, not a joint)
|
|
140
|
+
add('pcb_smtpad', { ...own, pcb_port_id: port.pcb_port_id, layer: part.layer, shape: 'polygon', points: p.poly, port_hints: [p.hint ?? p.pin.replace(/^pin/, '')], is_covered_with_solder_mask: false });
|
|
141
|
+
} else if (p.smd) {
|
|
90
142
|
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
143
|
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
144
|
} else {
|
|
@@ -116,7 +168,7 @@ export async function circuitJson(board, { route = defaultRouter } = {}) {
|
|
|
116
168
|
display_name: `.${t.from.join(' > .')} to .${t.to.join(' > .')}`, min_trace_thickness: t.width });
|
|
117
169
|
if (!t.path) { toRoute.push({ t, a, b, id: st.source_trace_id }); continue; }
|
|
118
170
|
// 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';
|
|
171
|
+
const layer = t.layer ?? (a.layers.length === 1 ? a.layers[0] : 'top'); // or the layer it names
|
|
120
172
|
const pts = [{ x: a.x, y: a.y }, ...t.path, { x: b.x, y: b.y }];
|
|
121
173
|
add('pcb_trace', { route: pts.map((q, i) => ({ route_type: 'wire', x: q.x, y: q.y, width: t.width, layer,
|
|
122
174
|
...(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 });
|
|
@@ -182,7 +234,7 @@ function schematic(out, add, placed, board, port) {
|
|
|
182
234
|
const half = Math.ceil(sourcePorts.length / 2), size = { width: 1.9, height: (half + 1) * 0.2 };
|
|
183
235
|
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
236
|
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,
|
|
237
|
+
add('schematic_text', { text: part.mpn ?? part.name, schematic_component_id: c.schematic_component_id, anchor: 'left', rotation: 0,
|
|
186
238
|
position: { x: s.x - size.width / 2, y: s.y - size.height / 2 - 0.23 }, color: '#006464', font_size: 0.18 });
|
|
187
239
|
sourcePorts.forEach((sp, i) => {
|
|
188
240
|
const left = i < half, k = left ? i : sourcePorts.length - 1 - i, side = left ? 'left' : 'right';
|
|
@@ -238,8 +290,13 @@ export async function routeInput(json, toRoute) {
|
|
|
238
290
|
const joined = (e) => { const ids = inside.get(pcbPort[e.pcb_port_id]?.source_port_id); return ids ? { offBoardConnectsTo: ids, netIsAssignable: true } : {}; };
|
|
239
291
|
const b = json.find((e) => e.type === 'pcb_board'), obstacles = [];
|
|
240
292
|
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'
|
|
242
|
-
|
|
293
|
+
if (e.type === 'pcb_smtpad' && e.shape === 'polygon') { // copper of any shape, as strips the router can see round
|
|
294
|
+
for (const b of strips(e.points, 0.25)) obstacles.push({ circuitJsonMetadata: meta(e, 'pcb_smtpad_id'), componentId: e.pcb_component_id, type: 'rect', layers: [e.layer],
|
|
295
|
+
center: { x: b.x, y: b.y }, width: b.w, height: b.h, connectedTo: linked(e.pcb_smtpad_id), ...joined(e) });
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
if (e.type === 'pcb_smtpad') { const b = smtBounds(e); obstacles.push({ circuitJsonMetadata: meta(e, 'pcb_smtpad_id'), componentId: e.pcb_component_id, type: 'rect', layers: [e.layer],
|
|
299
|
+
center: { x: b.x, y: b.y }, width: b.w, height: b.h, connectedTo: linked(e.pcb_smtpad_id), ...joined(e) }); }
|
|
243
300
|
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
301
|
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
302
|
if (e.type === 'pcb_hole') obstacles.push({ componentId: e.pcb_component_id, obstacleId: e.pcb_hole_id, isNonPlatedHole: true, type: 'rect', shape: 'circle',
|
|
@@ -249,9 +306,10 @@ export async function routeInput(json, toRoute) {
|
|
|
249
306
|
connection_name: e.source_trace_id, connectsTo: [e.route[0].start_pcb_port_id, e.route.at(-1).end_pcb_port_id],
|
|
250
307
|
route: e.route.map((p) => ({ route_type: p.route_type, x: p.x, y: p.y, width: p.width, layer: p.layer })) }));
|
|
251
308
|
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,
|
|
309
|
+
pointsToConnect: [a, z].map((q) => ({ x: q.x, y: q.y, layer: q.layers.length === 1 ? q.layers[0] : 'top', ...(q.layers.length === 1 ? { onlyLayer: q.layers[0] } : {}), pointId: q.pcb.pcb_port_id, pcb_port_id: q.pcb.pcb_port_id,
|
|
253
310
|
port_selector: `${comp[q.source.source_component_id]}.${q.source.name}` })) }));
|
|
254
311
|
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 },
|
|
312
|
+
...(b.outline ? { outline: b.outline } : {}), // the router keeps tracks inside a shaped board
|
|
255
313
|
obstacles, connections, traces, layerCount: 2, allowBlindAndBuriedVias: false, minTraceWidth: 0.15, nominalTraceWidth: 0.15, // tscircuit's defaults
|
|
256
314
|
// the board's fab minimums, under the names the router reads
|
|
257
315
|
minViaDiameter: b.min_via_pad_diameter, minViaHoleDiameter: b.min_via_hole_diameter, minViaPadDiameter: b.min_via_pad_diameter,
|
|
@@ -300,16 +358,37 @@ async function defaultRouter(json, toRoute) {
|
|
|
300
358
|
// its second, as tscircuit records them
|
|
301
359
|
const traces = [], asked = new Map(srj.connections.map((c) => [c.name, c.pointsToConnect]));
|
|
302
360
|
const far = (p, q) => Math.hypot(p.x - q.x, p.y - q.y);
|
|
361
|
+
// The router hands back each net as pieces of track, named after one of its connections. A piece's ends are named
|
|
362
|
+
// by the pads they finish on; a piece that reaches a one-sided pad on the other layer gets a via there (the router
|
|
363
|
+
// sometimes leaves it out), set back along the piece just clear of the pad, and a short track on the pad's layer.
|
|
364
|
+
const pads = json.filter((e) => e.type === 'pcb_smtpad' || e.type === 'pcb_plated_hole').map((e) => e.type === 'pcb_smtpad'
|
|
365
|
+
? { port: e.pcb_port_id, ...smtBounds(e), layer: e.layer, points: e.points } : { port: e.pcb_port_id, x: e.x, y: e.y, w: e.outer_diameter, h: e.outer_diameter, layer: null });
|
|
366
|
+
const within = (d, p) => Math.abs(p.x - d.x) <= d.w / 2 + 1e-6 && Math.abs(p.y - d.y) <= d.h / 2 + 1e-6 && (!d.points || insidePolygon(d.points, p));
|
|
367
|
+
const padAt = (p) => pads.filter((d) => within(d, p)).sort((a, b) => a.w * a.h - b.w * b.h)[0]; // the smallest pad there
|
|
368
|
+
const edge = json.find((e) => e.type === 'pcb_board')?.min_trace_to_pad_edge_clearance ?? 0.1;
|
|
369
|
+
const onto = (pad, p, q) => {
|
|
370
|
+
if (!pad?.layer || p.route_type !== 'wire' || p.layer === pad.layer) return [];
|
|
371
|
+
const back = Math.max(pad.w, pad.h) / 2 + srj.minViaPadDiameter / 2 + edge;
|
|
372
|
+
const len = Math.hypot(q.x - p.x, q.y - p.y) || 1, t = Math.min(back / len, 1), v = { x: p.x + (q.x - p.x) * t, y: p.y + (q.y - p.y) * t };
|
|
373
|
+
return [{ route_type: 'wire', x: p.x, y: p.y, width: p.width, layer: pad.layer }, { route_type: 'wire', x: v.x, y: v.y, width: p.width, layer: pad.layer },
|
|
374
|
+
{ route_type: 'via', x: v.x, y: v.y, from_layer: pad.layer, to_layer: p.layer }, { route_type: 'wire', x: v.x, y: v.y, width: p.width, layer: p.layer }];
|
|
375
|
+
};
|
|
303
376
|
for (const t of solver.getOutputSimplifiedPcbTraces()) {
|
|
304
|
-
|
|
305
|
-
if (!ends) continue;
|
|
377
|
+
if (!asked.has(t.connection_name)) continue;
|
|
306
378
|
let route = t.route.map((p) => ({ ...p }));
|
|
307
|
-
|
|
308
|
-
route[0]
|
|
309
|
-
|
|
379
|
+
const first = padAt(route[0]), last = padAt(route.at(-1));
|
|
380
|
+
const head = onto(first, route[0], route[1] || route[0]), tail = onto(last, route.at(-1), route.at(-2) || route.at(-1));
|
|
381
|
+
if (head.length) route = [...head, ...route.slice(1)];
|
|
382
|
+
if (tail.length) route = [...route.slice(0, -1), ...tail.reverse().map((q) => (q.route_type === 'via' ? { ...q, from_layer: q.to_layer, to_layer: q.from_layer } : q))];
|
|
383
|
+
if (first) route[0].start_pcb_port_id = first.port;
|
|
384
|
+
if (last) route.at(-1).end_pcb_port_id = last.port;
|
|
310
385
|
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
386
|
hole_diameter: srj.minViaHoleDiameter, outer_diameter: srj.minViaPadDiameter, layers: ['top', 'bottom'], from_layer: p.from_layer, to_layer: p.to_layer })) });
|
|
312
387
|
}
|
|
313
|
-
|
|
388
|
+
// Every pad asked for has a track (a pad counts as reached when another pad of the same pin, joined in its part, is)
|
|
389
|
+
const pinOf = new Map(json.filter((e) => e.type === 'pcb_port').map((e) => { const sp = json.find((x) => x.type === 'source_port' && x.source_port_id === e.source_port_id); return [e.pcb_port_id, `${sp?.source_component_id}:${sp?.name}`]; }));
|
|
390
|
+
const reached = new Set(traces.flatMap((t) => [t.route[0].start_pcb_port_id, t.route.at(-1).end_pcb_port_id]).map((id) => pinOf.get(id)));
|
|
391
|
+
const missing = srj.connections.flatMap((c) => c.pointsToConnect).filter((p) => !reached.has(pinOf.get(p.pcb_port_id)));
|
|
392
|
+
if (missing.length) throw new Error(`the autorouter left ${missing.length} pads without a track (first: ${missing[0].port_selector})`);
|
|
314
393
|
return { traces };
|
|
315
394
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rulecad/pcb",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
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",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"@flatten-js/core": "1.6.14",
|
|
34
34
|
"@jscad/modeling": "^2.12.0",
|
|
35
35
|
"@resvg/resvg-js": "2.6.2",
|
|
36
|
-
"@rulecad/cad": "^0.2.0",
|
|
36
|
+
"@rulecad/cad": "^0.2.0 || ^0.3.0",
|
|
37
37
|
"@tscircuit/capacity-autorouter": "0.0.982",
|
|
38
38
|
"@tscircuit/checks": "0.0.253",
|
|
39
39
|
"@tscircuit/circuit-json-util": "0.0.122",
|
package/pcb.mjs
CHANGED
|
@@ -13,7 +13,8 @@
|
|
|
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 { circuitJson } from './circuit.mjs';
|
|
16
|
+
import { circuitJson, bounds } from './circuit.mjs';
|
|
17
|
+
export { bounds };
|
|
17
18
|
import { loadRuleSet } from '@rulecad/cad/rules';
|
|
18
19
|
import jscad from '@jscad/modeling';
|
|
19
20
|
|
|
@@ -317,6 +318,76 @@ export function keyboard(v, keys) {
|
|
|
317
318
|
return { board, matrix };
|
|
318
319
|
}
|
|
319
320
|
|
|
321
|
+
// ---------- footprints from KiCad ----------
|
|
322
|
+
/**
|
|
323
|
+
* A KiCad footprint (.kicad_mod text) as pad data: { pads, courtyard: { w, h } }, in mm, y up (KiCad's y is down).
|
|
324
|
+
* Keep the result in a rule set's "footprints" (with its source), and place it with footprint(): a design never
|
|
325
|
+
* reads files. SMD pads: { pin, smd: true, x, y, w, h } (a turned pad's w and h swapped); plated holes: { pin, x, y,
|
|
326
|
+
* hole, outer }. Pads named alike (several GND pads) keep their name; unnamed pads (mechanical) are left out.
|
|
327
|
+
* @param {string} text @returns {{ pads: object[], courtyard: { w: number, h: number } | null }}
|
|
328
|
+
*/
|
|
329
|
+
export function fromKicad(text) {
|
|
330
|
+
const num = (s) => Number(s), pads = [];
|
|
331
|
+
for (const m of text.matchAll(/\(pad\s+"([^"]*)"\s+(smd|thru_hole|np_thru_hole)\s+(\w+)([\s\S]*?)\(layers/g)) {
|
|
332
|
+
const [, name, kind, , rest] = m;
|
|
333
|
+
if (!name) continue;
|
|
334
|
+
const at = rest.match(/\(at\s+([-\d.]+)\s+([-\d.]+)(?:\s+([-\d.]+))?\)/), sz = rest.match(/\(size\s+([-\d.]+)\s+([-\d.]+)\)/);
|
|
335
|
+
if (!at || !sz) continue;
|
|
336
|
+
const turn = ((num(at[3] || 0) % 180) + 180) % 180 === 90, w = num(turn ? sz[2] : sz[1]), h = num(turn ? sz[1] : sz[2]);
|
|
337
|
+
const x = +num(at[1]).toFixed(4), y = +(-num(at[2])).toFixed(4);
|
|
338
|
+
if (kind === 'smd') pads.push({ pin: name, smd: true, x, y, w, h });
|
|
339
|
+
else if (kind === 'thru_hole') { const d = rest.match(/\(drill\s+(?:oval\s+)?([-\d.]+)/); pads.push({ pin: name, x, y, hole: num(d[1]), outer: Math.max(w, h) }); }
|
|
340
|
+
}
|
|
341
|
+
const pts = text.split(/\n\s*(?=\(fp_|\(pad|\(model|\(zone)/).filter((el) => /^\(fp_(?:line|rect|poly)/.test(el) && /\(layer\s+"F\.CrtYd"\)/.test(el)) // the courtyard's corners
|
|
342
|
+
.flatMap((el) => [...el.matchAll(/\((?:start|end|xy)\s+([-\d.]+)\s+([-\d.]+)\)/g)].map((m) => [num(m[1]), num(m[2])]));
|
|
343
|
+
const span = (k) => Math.max(...pts.map((p) => p[k])) - Math.min(...pts.map((p) => p[k]));
|
|
344
|
+
return { pads, courtyard: pts.length ? { w: +span(0).toFixed(3), h: +span(1).toFixed(3) } : null };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* A part from footprint data (fromKicad's, kept in a rule set): pads as given, pins named by pad name (GND pads
|
|
349
|
+
* alike are joined in the part); data.labels ({ pin1: 'GND' }) and data.mpn name them. o: as part() (rotation,
|
|
350
|
+
* layer, mpn, labels, sch).
|
|
351
|
+
*/
|
|
352
|
+
export function footprint(name, data, x, y, o = {}) {
|
|
353
|
+
const pads = data.pads.map((p, i) => p.smd ? { pin: `pin${p.pin}`, smd: true, hint: String(p.pin), x: p.x, y: p.y, w: p.w, h: p.h } : pad(`pin${p.pin}`, p.x, p.y, p.hole, p.outer));
|
|
354
|
+
return chip(name, x, y, { courtyard: data.courtyard, mpn: data.mpn, labels: data.labels, ...o }, { pads });
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ---------- board shapes ----------
|
|
358
|
+
// Outlines for board.outline: closed polygons, counter-clockwise, centred on at (default the origin). Curves are cut
|
|
359
|
+
// into segments (default 8 per quarter circle: fine for a router and a fab, which mills them as arcs anyway).
|
|
360
|
+
const arc = (cx, cy, r, a0, n) => Array.from({ length: n + 1 }, (_, i) => { const a = a0 + (Math.PI / 2) * (i / n); return { x: cx + r * Math.cos(a), y: cy + r * Math.sin(a) }; });
|
|
361
|
+
export const shape = {
|
|
362
|
+
rect: (w, h, { at = [0, 0] } = {}) => shape.polygon([[-w / 2, -h / 2], [w / 2, -h / 2], [w / 2, h / 2], [-w / 2, h / 2]], { at }),
|
|
363
|
+
// corners rounded to radius r
|
|
364
|
+
rounded: (w, h, r, { at = [0, 0], segments = 8 } = {}) => {
|
|
365
|
+
if (r <= 0) return shape.rect(w, h, { at });
|
|
366
|
+
const x = w / 2 - r, y = h / 2 - r;
|
|
367
|
+
return [...arc(x, -y, r, -Math.PI / 2, segments), ...arc(x, y, r, 0, segments), ...arc(-x, y, r, Math.PI / 2, segments), ...arc(-x, -y, r, Math.PI, segments)]
|
|
368
|
+
.map((p) => ({ x: p.x + at[0], y: p.y + at[1] }));
|
|
369
|
+
},
|
|
370
|
+
// corners cut at 45 degrees, b along each side
|
|
371
|
+
bevelled: (w, h, b, { at = [0, 0] } = {}) => shape.polygon([[-w / 2 + b, -h / 2], [w / 2 - b, -h / 2], [w / 2, -h / 2 + b], [w / 2, h / 2 - b], [w / 2 - b, h / 2], [-w / 2 + b, h / 2], [-w / 2, h / 2 - b], [-w / 2, -h / 2 + b]], { at }),
|
|
372
|
+
// a ring from radius r0 to r1, open by gap at angle (radians) so it is one outline: for a contact ring's copper
|
|
373
|
+
ring: (r0, r1, { gap = 0.3, angle = 0, segments = 48, at = [0, 0] } = {}) => {
|
|
374
|
+
const a0 = angle + gap / r1 / 2, a1 = angle + Math.PI * 2 - gap / r1 / 2, b0 = angle + gap / r0 / 2, b1 = angle + Math.PI * 2 - gap / r0 / 2;
|
|
375
|
+
const arcAt = (r, s, e) => Array.from({ length: segments + 1 }, (_, i) => { const a = s + ((e - s) * i) / segments; return [at[0] + r * Math.cos(a), at[1] + r * Math.sin(a)]; });
|
|
376
|
+
return [...arcAt(r1, a0, a1), ...arcAt(r0, b1, b0)];
|
|
377
|
+
},
|
|
378
|
+
circle: (d, { at = [0, 0], segments = 32 } = {}) => Array.from({ length: segments }, (_, i) => ({ x: at[0] + (d / 2) * Math.cos((Math.PI * 2 * i) / segments), y: at[1] + (d / 2) * Math.sin((Math.PI * 2 * i) / segments) })),
|
|
379
|
+
polygon: (points, { at = [0, 0] } = {}) => points.map(([x, y]) => ({ x: x + at[0], y: y + at[1] }))
|
|
380
|
+
};
|
|
381
|
+
// Is the point inside the polygon? (even-odd rule)
|
|
382
|
+
export function inside(points, x, y) {
|
|
383
|
+
let hit = false;
|
|
384
|
+
for (let i = 0, j = points.length - 1; i < points.length; j = i++) {
|
|
385
|
+
const a = points[i], b = points[j];
|
|
386
|
+
if ((a.y > y) !== (b.y > y) && x < ((b.x - a.x) * (y - a.y)) / (b.y - a.y) + a.x) hit = !hit;
|
|
387
|
+
}
|
|
388
|
+
return hit;
|
|
389
|
+
}
|
|
390
|
+
|
|
320
391
|
// ---------- building ----------
|
|
321
392
|
// render(board) -> Circuit JSON (circuit.mjs)
|
|
322
393
|
export const render = (board, options) => circuitJson(board, options); // options.route: a router in place of the autorouter
|
|
@@ -325,11 +396,11 @@ export const render = (board, options) => circuitJson(board, options); // option
|
|
|
325
396
|
// copper is drawn in the 2D views (views), not in 3D. The board is a 2D outline with its holes, extruded: its faces
|
|
326
397
|
// triangulate cleanly (cylinders subtracted from a block leave long slivers).
|
|
327
398
|
export function solids(json, { thickness, smooth = 16 }) { // smooth: segments per hole (a board without the variable gets 16)
|
|
328
|
-
const { rectangle, circle } = jscad.primitives, { subtract } = jscad.booleans, { extrudeLinear } = jscad.extrusions;
|
|
399
|
+
const { rectangle, circle, polygon } = jscad.primitives, { subtract } = jscad.booleans, { extrudeLinear } = jscad.extrusions;
|
|
329
400
|
const b = json.find((e) => e.type === 'pcb_board');
|
|
330
401
|
const drills = json.filter((e) => e.type === 'pcb_hole' || e.type === 'pcb_plated_hole' || e.type === 'pcb_via')
|
|
331
402
|
.map((e) => circle({ center: [e.x, e.y], radius: (e.hole_diameter ?? e.hole_width) / 2, segments: smooth }));
|
|
332
|
-
const outline = rectangle({ center: [b.center.x, b.center.y], size: [b.width, b.height] });
|
|
403
|
+
const outline = b.outline ? polygon({ points: b.outline.map((p) => [p.x, p.y]) }) : rectangle({ center: [b.center.x, b.center.y], size: [b.width, b.height] });
|
|
333
404
|
return { board: extrudeLinear({ height: thickness }, drills.length ? subtract(outline, ...drills) : outline) };
|
|
334
405
|
}
|
|
335
406
|
|
|
@@ -337,13 +408,22 @@ export function solids(json, { thickness, smooth = 16 }) { // smooth: segments p
|
|
|
337
408
|
// size, mm, in the layout's frame) and, with the Pico under it, the Pico's centre
|
|
338
409
|
export function outputs(json, matrix) {
|
|
339
410
|
const b = json.find((e) => e.type === 'pcb_board');
|
|
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 } : {}) };
|
|
411
|
+
return { board_x: b.center.x, board_y: b.center.y, board_w: b.width, board_d: b.height, ...(b.outline ? { board_outline: b.outline } : {}), ...(matrix?.pico ? { pico_x: matrix.pico.x, pico_y: matrix.pico.y } : {}) };
|
|
341
412
|
}
|
|
342
413
|
|
|
343
414
|
/**
|
|
344
415
|
* 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
416
|
* board_x and board_y). A case reads them to put its bosses under them; a holes fit checks screws find their inserts.
|
|
346
417
|
*/
|
|
418
|
+
/**
|
|
419
|
+
* Where a board's parts are, by name: { U1: { x, y, w, h, layer } } in mm, in the board's frame (w and h as placed,
|
|
420
|
+
* so a turned part's are swapped). A case reads them to put openings over a USB socket, a light or a button.
|
|
421
|
+
*/
|
|
422
|
+
export function parts(json) {
|
|
423
|
+
const names = Object.fromEntries(json.filter((e) => e.type === 'source_component').map((e) => [e.source_component_id, e.name]));
|
|
424
|
+
return Object.fromEntries(json.filter((e) => e.type === 'pcb_component').map((e) => [names[e.source_component_id], { x: e.center.x, y: e.center.y, w: e.width, h: e.height, layer: e.layer, rotation: e.rotation ?? 0 }]));
|
|
425
|
+
}
|
|
426
|
+
|
|
347
427
|
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
428
|
|
|
349
429
|
// ---------- checks ----------
|
|
@@ -352,7 +432,13 @@ const mm = (x) => String(Number(x.toFixed(3))); // 0.30000000000000004 reads as
|
|
|
352
432
|
export async function checks(json, v, matrix) {
|
|
353
433
|
const { runAllChecks } = await import('@tscircuit/checks');
|
|
354
434
|
const out = [], j = v.jlcpcb;
|
|
355
|
-
const
|
|
435
|
+
const board = json.find((e) => e.type === 'pcb_board');
|
|
436
|
+
// On a shaped board, a part's box can reach past a curved edge when its copper doesn't (a ring on a round disc): that
|
|
437
|
+
// part is checked here pad by pad against the outline instead (below)
|
|
438
|
+
const padsOf = (id) => json.filter((e) => e.pcb_component_id === id && ['pcb_smtpad', 'pcb_plated_hole'].includes(e.type));
|
|
439
|
+
const fits = (id) => board.outline && padsOf(id).every((e) => (e.points || [e]).every((q) => inside(board.outline, q.x, q.y)));
|
|
440
|
+
const drc = (await runAllChecks(json)).filter((e) => !(e.type === 'pcb_component_outside_board_error' && fits(e.pcb_component_id)));
|
|
441
|
+
const problems = [...json, ...drc].filter((e) => /_error$/.test(e.type));
|
|
356
442
|
const seen = new Set(), errors = problems.filter((e) => !seen.has(e.message) && seen.add(e.message));
|
|
357
443
|
out.push(errors.length ? { level: 'fail', text: `${errors.length} design-rule errors: ${errors.slice(0, 3).map((e) => e.message).join('; ')}` } : { level: 'pass', text: 'tscircuit\'s design checks: no errors (traces, clearances, holes, board edge)' });
|
|
358
444
|
const traces = json.filter((e) => e.type === 'pcb_trace'), unrouted = json.filter((e) => e.type === 'source_trace').length - traces.length;
|
|
@@ -360,8 +446,12 @@ export async function checks(json, v, matrix) {
|
|
|
360
446
|
const thin = traces.flatMap((t) => t.route).filter((p) => p.width != null && p.width < j.track_min);
|
|
361
447
|
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` });
|
|
362
448
|
const drills = json.filter((e) => e.type === 'pcb_hole' || e.type === 'pcb_plated_hole').map((e) => e.hole_diameter ?? e.hole_width);
|
|
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)` });
|
|
449
|
+
if (drills.length) 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)` });
|
|
364
450
|
const b = json.find((e) => e.type === 'pcb_board'), size = `${b.width.toFixed(1)} × ${b.height.toFixed(1)} mm`;
|
|
451
|
+
if (b.outline) { // a shaped board: every pad and hole inside it
|
|
452
|
+
const pads = json.filter((e) => ['pcb_smtpad', 'pcb_plated_hole', 'pcb_hole'].includes(e.type)), out_ = pads.filter((e) => (e.points || [e]).some((q) => !inside(b.outline, q.x, q.y)));
|
|
453
|
+
out.push(out_.length ? { level: 'fail', text: `${out_.length} pads or holes outside the board's outline (first at ${mm(out_[0].x)}, ${mm(out_[0].y)} mm)` } : { level: 'pass', text: `shaped board (${b.outline.length} points): all ${pads.length} pads and holes inside it` });
|
|
454
|
+
}
|
|
365
455
|
out.push(Math.max(b.width, b.height) <= j.cheap_size ? { level: 'pass', text: `${size}: within JLCPCB's lowest price (${j.cheap_size} × ${j.cheap_size} mm)` } : { level: 'info', text: `${size}: over ${j.cheap_size} × ${j.cheap_size} mm, priced by quote` });
|
|
366
456
|
if (matrix) {
|
|
367
457
|
const cells = new Set(matrix.keys.map((k) => `${k.row},${k.col}`)), gpio = [...Object.values(matrix.rows), ...Object.values(matrix.cols)];
|