@rulecad/pcb 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +15 -2
  2. package/circuit.mjs +97 -18
  3. package/package.json +2 -2
  4. package/pcb.mjs +96 -6
package/README.md CHANGED
@@ -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 contact ring from `shape.ring(r0, r1)`. 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` 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.
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": ["node/carrier"]` in @rulecad/cad: bosses under its holes, openings over its socket, light and
52
+ button) 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) => ({ ...p, ...at(p.x, p.y), ...(p.smd ? size(p.w, p.h) : {}) }));
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.smd) {
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') 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) });
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
- const ends = asked.get(t.connection_name);
305
- if (!ends) continue;
377
+ if (!asked.has(t.connection_name)) continue;
306
378
  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;
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
- if (traces.length < srj.connections.length) throw new Error(`the autorouter routed ${traces.length} of ${srj.connections.length} connections`);
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.2.1",
3
+ "version": "0.3.0",
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 drc = await runAllChecks(json), problems = [...json, ...drc].filter((e) => /_error$/.test(e.type));
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)];