@protoboardai/ext-tscircuit 0.0.0-stage → 0.2.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/LICENSE +202 -0
- package/NOTICE +59 -0
- package/README.md +493 -2
- package/evals/announce-pcb-fallback/case.ts +67 -0
- package/evals/catch-swapped-nets/case.ts +69 -0
- package/evals/generate-imu-board/case.ts +60 -0
- package/evals/locked-board-fix-upstream/case.ts +70 -0
- package/lib/checks.ts +464 -0
- package/lib/config.ts +384 -0
- package/lib/fingerprint.ts +144 -0
- package/lib/footprints.ts +370 -0
- package/lib/kicad/cli.ts +93 -0
- package/lib/kicad/drc.ts +95 -0
- package/lib/kicad/fields.ts +65 -0
- package/lib/kicad/handoff.ts +112 -0
- package/lib/kicad/netlists.ts +311 -0
- package/lib/kicad/probe.cjs +21 -0
- package/lib/kicad/project.ts +594 -0
- package/lib/kicad/refdes.ts +130 -0
- package/lib/kicad/schematic.ts +624 -0
- package/lib/kicad/sexpr.ts +117 -0
- package/lib/land-geometry.ts +177 -0
- package/lib/land-patterns.ts +706 -0
- package/lib/layout.ts +236 -0
- package/lib/models.ts +284 -0
- package/lib/native.ts +199 -0
- package/lib/outputs.ts +383 -0
- package/lib/paste.ts +69 -0
- package/lib/probe.cjs +31 -0
- package/lib/runtime.ts +185 -0
- package/lib/silk.ts +141 -0
- package/lib/source.ts +472 -0
- package/lib/stepfile.ts +210 -0
- package/lib/suppliers.ts +39 -0
- package/ops/circuit.ts +270 -0
- package/ops/kicad.ts +636 -0
- package/ops/package.ts +156 -0
- package/package.json +81 -4
- package/protoboard-extension.json +313 -0
- package/skills/circuit-board/SKILL.md +494 -0
- package/skills/circuit-board/references/pcb-workflow.md +87 -0
- package/skills/kicad-cli/SKILL.md +210 -0
- package/skills/kicad-layout-review/SKILL.md +275 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KiCad's S-expression files (.kicad_pcb, .kicad_sch, .kicad_mod, .kicad_sym,
|
|
3
|
+
* the netlist), read and written without a KiCad dependency. A node is an
|
|
4
|
+
* atom (a bare token: `smd`, `0.4`, `yes`), a quoted string, or a list. Quoted
|
|
5
|
+
* strings stay quoted on output, so a file read and written back means the
|
|
6
|
+
* same to KiCad.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface Str {
|
|
10
|
+
str: string;
|
|
11
|
+
}
|
|
12
|
+
export type Atom = string | Str;
|
|
13
|
+
export type Node = Atom | List;
|
|
14
|
+
export interface List {
|
|
15
|
+
items: Node[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const isList = (n: Node | undefined): n is List => !!n && typeof n === "object" && "items" in n;
|
|
19
|
+
export const isStr = (n: Node | undefined): n is Str => !!n && typeof n === "object" && "str" in n;
|
|
20
|
+
export const str = (s: string): Str => ({ str: s });
|
|
21
|
+
export const list = (...items: Node[]): List => ({ items });
|
|
22
|
+
|
|
23
|
+
/** The text of an atom or string; undefined for a list. */
|
|
24
|
+
export const text = (n: Node | undefined): string | undefined => (n === undefined || isList(n) ? undefined : typeof n === "string" ? n : n.str);
|
|
25
|
+
|
|
26
|
+
/** A list's head token (`footprint` in `(footprint "R_0402" …)`). */
|
|
27
|
+
export const head = (n: Node | undefined): string | undefined => (isList(n) ? text(n.items[0]) : undefined);
|
|
28
|
+
|
|
29
|
+
/** Direct children that are lists with this head. */
|
|
30
|
+
export const children = (n: List, name: string): List[] => n.items.filter((c): c is List => isList(c) && head(c) === name);
|
|
31
|
+
export const child = (n: List, name: string): List | undefined => n.items.find((c): c is List => isList(c) && head(c) === name);
|
|
32
|
+
/** The first value of a child (`(layer "F.Cu")` → `F.Cu`). */
|
|
33
|
+
export const valueOf = (n: List, name: string): string | undefined => {
|
|
34
|
+
const c = child(n, name);
|
|
35
|
+
return c ? text(c.items[1]) : undefined;
|
|
36
|
+
};
|
|
37
|
+
/** A `(property "Name" "value" …)` child's value. */
|
|
38
|
+
export const propertyNode = (n: List, name: string): List | undefined => children(n, "property").find((p) => text(p.items[1]) === name);
|
|
39
|
+
export const property = (n: List, name: string): string | undefined => text(propertyNode(n, name)?.items[2]);
|
|
40
|
+
|
|
41
|
+
/** Every list below `n` (depth first, `n` included) with this head. */
|
|
42
|
+
export function findAll(n: List, name: string, out: List[] = []): List[] {
|
|
43
|
+
if (head(n) === name) out.push(n);
|
|
44
|
+
for (const c of n.items) if (isList(c)) findAll(c, name, out);
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export class ParseError extends Error {}
|
|
49
|
+
|
|
50
|
+
/** Parse one S-expression document (the first top-level list). */
|
|
51
|
+
export function parse(src: string): List {
|
|
52
|
+
let i = 0;
|
|
53
|
+
const n = src.length;
|
|
54
|
+
const stack: List[] = [];
|
|
55
|
+
let root: List | undefined;
|
|
56
|
+
while (i < n) {
|
|
57
|
+
const c = src[i];
|
|
58
|
+
if (c === "(") {
|
|
59
|
+
const l: List = { items: [] };
|
|
60
|
+
if (stack.length) stack[stack.length - 1].items.push(l);
|
|
61
|
+
else if (!root) root = l;
|
|
62
|
+
else throw new ParseError(`more than one top-level list (at offset ${i})`);
|
|
63
|
+
stack.push(l);
|
|
64
|
+
i++;
|
|
65
|
+
} else if (c === ")") {
|
|
66
|
+
if (!stack.length) throw new ParseError(`unbalanced ")" at offset ${i}`);
|
|
67
|
+
stack.pop();
|
|
68
|
+
i++;
|
|
69
|
+
} else if (c === '"') {
|
|
70
|
+
let s = "";
|
|
71
|
+
i++;
|
|
72
|
+
while (i < n && src[i] !== '"') {
|
|
73
|
+
if (src[i] === "\\" && i + 1 < n) {
|
|
74
|
+
const e = src[i + 1];
|
|
75
|
+
s += e === "n" ? "\n" : e === "t" ? "\t" : e === "r" ? "\r" : e;
|
|
76
|
+
i += 2;
|
|
77
|
+
} else s += src[i++];
|
|
78
|
+
}
|
|
79
|
+
if (i >= n) throw new ParseError("unterminated string");
|
|
80
|
+
i++;
|
|
81
|
+
if (!stack.length) throw new ParseError("a string outside any list");
|
|
82
|
+
stack[stack.length - 1].items.push({ str: s });
|
|
83
|
+
} else if (c === " " || c === "\n" || c === "\t" || c === "\r") i++;
|
|
84
|
+
else {
|
|
85
|
+
const start = i;
|
|
86
|
+
while (i < n && !" \n\t\r()\"".includes(src[i])) i++;
|
|
87
|
+
if (!stack.length) throw new ParseError(`a token outside any list at offset ${start}`);
|
|
88
|
+
stack[stack.length - 1].items.push(src.slice(start, i));
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (stack.length) throw new ParseError(`${stack.length} list(s) not closed`);
|
|
92
|
+
if (!root) throw new ParseError("no S-expression");
|
|
93
|
+
return root;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const quote = (s: string) => `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n")}"`;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Write a node: a list whose items are all atoms stays on one line, others
|
|
100
|
+
* put each list child on its own line, indented by tabs (KiCad's own layout).
|
|
101
|
+
*/
|
|
102
|
+
export function print(n: Node, depth = 0): string {
|
|
103
|
+
if (!isList(n)) return typeof n === "string" ? n : quote(n.str);
|
|
104
|
+
const flat = n.items.every((c) => !isList(c));
|
|
105
|
+
if (flat) return `(${n.items.map((c) => print(c)).join(" ")})`;
|
|
106
|
+
const pad = "\t".repeat(depth + 1);
|
|
107
|
+
let out = "(";
|
|
108
|
+
let first = true;
|
|
109
|
+
for (const c of n.items) {
|
|
110
|
+
if (isList(c)) out += `\n${pad}${print(c, depth + 1)}`;
|
|
111
|
+
else out += (first ? "" : " ") + print(c);
|
|
112
|
+
first = false;
|
|
113
|
+
}
|
|
114
|
+
return `${out}\n${"\t".repeat(depth)})`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export const printDocument = (n: List) => print(n) + "\n";
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Land-pattern geometry: the entry format, the stencil and mask specs, the
|
|
3
|
+
* verification record, and helpers that lay out pad rows. No table here
|
|
4
|
+
* (land-patterns.ts) and no lookup (footprints.ts).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** One copper pad, top view, mm, origin at the package centre (pin 1 where the drawing puts it). */
|
|
8
|
+
export interface LandPad {
|
|
9
|
+
/** Pin designator as the part states it ("1", "57", "EP"); `""` for a mechanical pad with no pin. */
|
|
10
|
+
pin: string;
|
|
11
|
+
x: number;
|
|
12
|
+
y: number;
|
|
13
|
+
/** Size along X and Y before any part rotation. */
|
|
14
|
+
w: number;
|
|
15
|
+
h: number;
|
|
16
|
+
shape?: "rect" | "pill";
|
|
17
|
+
/**
|
|
18
|
+
* A plated hole through the board inside the pad (a through-hole lead, a
|
|
19
|
+
* connector's shell stake): its size along X and Y, a round hole when equal,
|
|
20
|
+
* a slot otherwise. The pad (w × h) is then the copper ring on every layer.
|
|
21
|
+
*/
|
|
22
|
+
drill?: { w: number; h: number };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A non-plated hole of the land pattern (a locating peg), top view, mm, from the package centre. */
|
|
26
|
+
export interface LandHole {
|
|
27
|
+
x: number;
|
|
28
|
+
y: number;
|
|
29
|
+
diameter_mm: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** One stencil aperture, footprint frame (top view, mm). */
|
|
33
|
+
export interface PasteWindow {
|
|
34
|
+
x: number;
|
|
35
|
+
y: number;
|
|
36
|
+
w: number;
|
|
37
|
+
h: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The stencil. Every SMD pad gets an aperture the size of the pad grown by
|
|
42
|
+
* `margin_mm` (0: 1:1, negative: smaller), unless `pins` says otherwise for a
|
|
43
|
+
* pin: its own margin, explicit windows (a thermal pad's window-pane), or none.
|
|
44
|
+
* `plated: true` prints paste on the component's side of its plated holes
|
|
45
|
+
* (pin-in-paste); otherwise they get none.
|
|
46
|
+
*/
|
|
47
|
+
export interface PasteSpec {
|
|
48
|
+
margin_mm?: number;
|
|
49
|
+
pins?: Record<string, { margin_mm: number } | { windows: PasteWindow[] } | "none">;
|
|
50
|
+
plated?: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The solder-mask opening: every pad's opening is the pad grown by `margin_mm` (non-solder-mask-defined). */
|
|
54
|
+
export interface MaskSpec {
|
|
55
|
+
margin_mm: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A dimension read off a drawing: exact (±0.01 mm) or a range the drawing allows ([min, max]). */
|
|
59
|
+
export type Dim = number | [number, number];
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* What the manufacturer's recommended land pattern states, transcribed from
|
|
63
|
+
* the drawing and cited. `verifyEntry` compares the entry's geometry with it:
|
|
64
|
+
* the pads, their pitch and row spacing, the holes and slots, the courtyard,
|
|
65
|
+
* the paste and the mask. An entry is verified when it has this record and
|
|
66
|
+
* every comparison holds.
|
|
67
|
+
*/
|
|
68
|
+
export interface LandVerification {
|
|
69
|
+
/** The document: title with revision (or document number), and its URL. */
|
|
70
|
+
document: string;
|
|
71
|
+
url?: string;
|
|
72
|
+
/** Page and figure (or table, or section) the values were read from. */
|
|
73
|
+
page: number | string;
|
|
74
|
+
figure: string;
|
|
75
|
+
/** Who compared it and when (ISO date). */
|
|
76
|
+
checked: string;
|
|
77
|
+
/**
|
|
78
|
+
* Pad groups. Every SMD pad of the entry belongs to exactly one group by
|
|
79
|
+
* its pin (`""` for mechanical pads). `size` is [X, Y] in the entry's frame
|
|
80
|
+
* (either way round is accepted, a rotated row); `merged` means the listed
|
|
81
|
+
* pins' pads together make one land of that size (a connector's paired
|
|
82
|
+
* contacts on one pad, drawn as two halves so each designator has a pad).
|
|
83
|
+
* `pitch_mm`: centre spacing along a row; `row_mm`: centre distance between
|
|
84
|
+
* the outermost parallel rows of the group.
|
|
85
|
+
*/
|
|
86
|
+
pads: { pins: string[]; size: [Dim, Dim]; merged?: boolean; pitch_mm?: Dim; row_mm?: Dim }[];
|
|
87
|
+
/** Every pad centre the drawing dimensions, in the drawing's own origin (`origin` is the entry origin in drawing coordinates). */
|
|
88
|
+
centres?: { origin: [number, number]; points: [number, number][] };
|
|
89
|
+
/**
|
|
90
|
+
* Plated slots (drill and copper ring sizes, [X, Y], either way round) and their count; `at`: their
|
|
91
|
+
* centres as the drawing dimensions them, in the entry's frame (one per slot).
|
|
92
|
+
*/
|
|
93
|
+
slots?: { drill: [number, number]; pad: [number, number]; count: number; at?: [number, number][] }[];
|
|
94
|
+
/** Round holes: plated (with their pad's diameter) or not; `at` as for slots. */
|
|
95
|
+
holes?: { diameter_mm: number; count: number; plated: boolean; pad_mm?: number; at?: [number, number][] }[];
|
|
96
|
+
/** Courtyard size [X, Y]: the drawing's, or how it was set when the drawing gives none. */
|
|
97
|
+
courtyard: { size_mm: [number, number]; basis: string };
|
|
98
|
+
/** Stencil: the margin for ordinary pads, window counts and sizes per pin, coverage of the pin's pad (fraction). */
|
|
99
|
+
paste: { margin_mm: number; windows?: Record<string, { count: number; size: [number, number]; coverage?: Dim }>; none?: string[]; plated?: boolean; basis: string };
|
|
100
|
+
mask: { margin_mm: number; basis: string };
|
|
101
|
+
notes?: string;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Where a mating part enters a connector, in the footprint's own frame (tscircuit's names). */
|
|
105
|
+
export type Insertion = "from_left" | "from_right" | "from_top" | "from_bottom" | "from_above";
|
|
106
|
+
|
|
107
|
+
export interface FootprintEntry {
|
|
108
|
+
id: string;
|
|
109
|
+
/**
|
|
110
|
+
* Which UHD packages the entry fits. `package` is compared with the part's
|
|
111
|
+
* package name ignoring case, spaces and dashes; the other fields, when
|
|
112
|
+
* given, must match the part's facts (size within 0.15 mm, either way round;
|
|
113
|
+
* `manufacturer` as a case-blind prefix of the part's manufacturer, for a
|
|
114
|
+
* land pattern one maker recommends for its own parts).
|
|
115
|
+
*/
|
|
116
|
+
match: { package: string; pin_count?: number; pitch_mm?: number; size_mm?: [number, number]; manufacturer?: string };
|
|
117
|
+
footprinter?: string;
|
|
118
|
+
pads?: LandPad[];
|
|
119
|
+
/** Non-plated holes the part needs (locating pegs), authored entries only. */
|
|
120
|
+
holes?: LandHole[];
|
|
121
|
+
/** Built-in pad number for a part designator, where they differ. */
|
|
122
|
+
padMap?: Record<string, string>;
|
|
123
|
+
insertion?: Insertion;
|
|
124
|
+
/** Courtyard (keep-clear rectangle around pads and body), mm. Default: pads and body plus 0.25 mm. */
|
|
125
|
+
courtyard_mm?: [number, number];
|
|
126
|
+
/** Centre of the courtyard in the entry's frame when it is not the origin (a module whose body overhangs its pads). */
|
|
127
|
+
courtyard_at?: [number, number];
|
|
128
|
+
/** Stencil apertures; default: 1:1 on every SMD pad, none on plated holes. */
|
|
129
|
+
paste?: PasteSpec;
|
|
130
|
+
/** Solder-mask openings; default: the pad (margin 0). */
|
|
131
|
+
mask?: MaskSpec;
|
|
132
|
+
/** Where the land pattern comes from. */
|
|
133
|
+
source: string;
|
|
134
|
+
/** The checked record against the manufacturer's drawing (see LandVerification); absent: unverified. */
|
|
135
|
+
verification?: LandVerification;
|
|
136
|
+
/** @deprecated free-text note from before structured verification; ignored (an entry without `verification` is unverified). */
|
|
137
|
+
verified?: string | false;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const r4 = (v: number) => Math.round(v * 1e4) / 1e4;
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Quad no-lead (QFN/LGA) perimeter pads, pin 1 at the top of the left side,
|
|
144
|
+
* numbered counter-clockwise viewed from the top: left (top → bottom), bottom
|
|
145
|
+
* (left → right), right (bottom → top), top (right → left).
|
|
146
|
+
*
|
|
147
|
+
* `row` is the distance between the centres of opposite pad rows.
|
|
148
|
+
*/
|
|
149
|
+
export function quadPads(o: { perSide: number; pitch: number; row: number; padLength: number; padWidth: number }): LandPad[] {
|
|
150
|
+
const pads: LandPad[] = [];
|
|
151
|
+
const span = (o.perSide - 1) * o.pitch;
|
|
152
|
+
const c = o.row / 2;
|
|
153
|
+
for (let i = 0; i < o.perSide; i++) {
|
|
154
|
+
const t = span / 2 - i * o.pitch;
|
|
155
|
+
const n = (side: number) => String(side * o.perSide + i + 1);
|
|
156
|
+
pads.push({ pin: n(0), x: r4(-c), y: r4(t), w: o.padLength, h: o.padWidth });
|
|
157
|
+
pads.push({ pin: n(1), x: r4(-t), y: r4(-c), w: o.padWidth, h: o.padLength });
|
|
158
|
+
pads.push({ pin: n(2), x: r4(c), y: r4(-t), w: o.padLength, h: o.padWidth });
|
|
159
|
+
pads.push({ pin: n(3), x: r4(t), y: r4(c), w: o.padWidth, h: o.padLength });
|
|
160
|
+
}
|
|
161
|
+
return pads.sort((a, b) => Number(a.pin) - Number(b.pin));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Dual no-lead (SON/DFN) pads along X, pin 1 at the left of the bottom row,
|
|
166
|
+
* numbered counter-clockwise viewed from the top.
|
|
167
|
+
*/
|
|
168
|
+
export function dualPads(o: { perSide: number; pitch: number; row: number; padLength: number; padWidth: number; shape?: LandPad["shape"] }): LandPad[] {
|
|
169
|
+
const pads: LandPad[] = [];
|
|
170
|
+
const span = (o.perSide - 1) * o.pitch;
|
|
171
|
+
for (let i = 0; i < o.perSide; i++) {
|
|
172
|
+
const x = -span / 2 + i * o.pitch;
|
|
173
|
+
pads.push({ pin: String(i + 1), x: r4(x), y: r4(-o.row / 2), w: o.padWidth, h: o.padLength, ...(o.shape ? { shape: o.shape } : {}) });
|
|
174
|
+
pads.push({ pin: String(2 * o.perSide - i), x: r4(x), y: r4(o.row / 2), w: o.padWidth, h: o.padLength, ...(o.shape ? { shape: o.shape } : {}) });
|
|
175
|
+
}
|
|
176
|
+
return pads.sort((a, b) => Number(a.pin) - Number(b.pin));
|
|
177
|
+
}
|