@protoboardai/ext-tscircuit 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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 +70 -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
package/lib/config.ts
ADDED
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Design constraints from `circuit.config.ts` (the `constraints` object),
|
|
3
|
+
* read into one checked shape. They are the provider's inputs that UHD does
|
|
4
|
+
* not hold: the board outline and thickness, mounting holes, where the
|
|
5
|
+
* enclosure fixes connectors, buttons, displays and sensor windows, keep-outs
|
|
6
|
+
* and the copper rules. Later the enclosure's geometry manifest supplies the
|
|
7
|
+
* fixed placements; today they are written in the config.
|
|
8
|
+
*
|
|
9
|
+
* Coordinates are the board frame: mm, +X right and +Y up in the top view,
|
|
10
|
+
* origin where the outline puts it, rotations counter-clockwise in degrees,
|
|
11
|
+
* z = 0 on the board's mid-plane. It is also the board module's own frame, so
|
|
12
|
+
* UHD regions on the board (design_envelope keep-outs) are read in it as they
|
|
13
|
+
* are: when the outline comes from the envelope it spans [0, length] ×
|
|
14
|
+
* [0, width] (the corner origin of UHD's own example, docs/boards-and-nets.md),
|
|
15
|
+
* and a keep-out applies to each copper layer its z range reaches (top copper
|
|
16
|
+
* at z = +thickness/2, bottom at −thickness/2).
|
|
17
|
+
*/
|
|
18
|
+
import type { DesignEnvelopeTrait } from "@deltarobotics/uhd";
|
|
19
|
+
|
|
20
|
+
export type Vec2 = [number, number];
|
|
21
|
+
export type Side = "top" | "bottom";
|
|
22
|
+
|
|
23
|
+
export interface Placement {
|
|
24
|
+
at: Vec2;
|
|
25
|
+
rotation_deg: number;
|
|
26
|
+
side: Side;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface Keepout {
|
|
30
|
+
name: string;
|
|
31
|
+
/** Axis-aligned rectangle (centre and size) or circle. */
|
|
32
|
+
shape: { rect: { at: Vec2; size: Vec2 } } | { circle: { at: Vec2; diameter_mm: number } };
|
|
33
|
+
/** Layers the router keeps clear; copper pours stay out of every keep-out on every layer. */
|
|
34
|
+
layers: CopperLayer[];
|
|
35
|
+
reason?: string;
|
|
36
|
+
/**
|
|
37
|
+
* Components whose bodies may lie over the keep-out (their courtyard is not a placement error
|
|
38
|
+
* there): the module whose own antenna the keep-out clears. Their copper must still stay out.
|
|
39
|
+
*/
|
|
40
|
+
allow_bodies?: string[];
|
|
41
|
+
/** Where it was stated. */
|
|
42
|
+
from?: "config" | "envelope";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface Hole {
|
|
46
|
+
id: string;
|
|
47
|
+
at: Vec2;
|
|
48
|
+
diameter_mm: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Copper rules handed to tscircuit's local router. */
|
|
52
|
+
export interface Rules {
|
|
53
|
+
trace_width_mm: number;
|
|
54
|
+
power_trace_width_mm: number;
|
|
55
|
+
via_drill_mm: number;
|
|
56
|
+
via_pad_mm: number;
|
|
57
|
+
edge_clearance_mm: number;
|
|
58
|
+
/** Least distance from a non-plated hole's wall to copper of any net (check only; no default: unchecked beyond touching). */
|
|
59
|
+
hole_clearance_mm?: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface BoardConstraints {
|
|
63
|
+
layers: 2 | 4;
|
|
64
|
+
thickness_mm: number;
|
|
65
|
+
/** Closed polygon, counter-clockwise, no repeated first point. */
|
|
66
|
+
outline: Vec2[];
|
|
67
|
+
/** How the outline was given, for reports. */
|
|
68
|
+
outlineKind: "circle" | "rect" | "polygon" | "envelope";
|
|
69
|
+
holes: Hole[];
|
|
70
|
+
/** Positions the enclosure fixes, by instance path. tscircuit does not move these. */
|
|
71
|
+
placements: Record<string, Placement>;
|
|
72
|
+
keepouts: Keepout[];
|
|
73
|
+
rules: Rules;
|
|
74
|
+
/** Local autorouter preset (never the cloud). */
|
|
75
|
+
autorouter: (typeof LOCAL_AUTOROUTERS)[number];
|
|
76
|
+
/** Copper pours (planes) filled after routing: a net on whole layers, clear of other nets' copper. */
|
|
77
|
+
pours: Pour[];
|
|
78
|
+
/**
|
|
79
|
+
* Vias inside SMD pads: `avoid` (default: the router keeps them out, and check reports any as an
|
|
80
|
+
* error) or `fill_and_cap` (allowed; the fabrication order asks the fab to fill and cap them,
|
|
81
|
+
* IPC-4761 type VII, so they cannot wick solder from the joint).
|
|
82
|
+
*/
|
|
83
|
+
via_in_pad: "avoid" | "fill_and_cap";
|
|
84
|
+
/** Vias whose ring touches an SMD pad they do not sit in: `allow` (default) or `avoid` (check reports each). */
|
|
85
|
+
via_touching_pad: "avoid" | "allow";
|
|
86
|
+
/** Vias inside a component's courtyard (under its body): `allow` (default) or `avoid` (check reports each). */
|
|
87
|
+
via_under_body: "avoid" | "allow";
|
|
88
|
+
/** `on` (default) or `off`: no autorouting, placement only (check reports unrouted nets as info). */
|
|
89
|
+
routing: "on" | "off";
|
|
90
|
+
/** Trace width per UHD net id, else per net class (signal, power, ground), else the rules' widths. */
|
|
91
|
+
trace_widths: { nets: Record<string, number>; classes: Partial<Record<"signal" | "power" | "ground", number>> };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** A copper pour: `net` (a UHD net id of the board) over the whole outline of each layer. */
|
|
95
|
+
export interface Pour {
|
|
96
|
+
net: string;
|
|
97
|
+
layers: CopperLayer[];
|
|
98
|
+
/** Clearance to other nets' copper (mm); defaults to the trace rule's 0.2 mm. */
|
|
99
|
+
clearance_mm?: number;
|
|
100
|
+
reason?: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export type CopperLayer = "top" | "bottom" | "inner1" | "inner2";
|
|
104
|
+
|
|
105
|
+
/** tscircuit autorouter presets that run in this process. `auto_cloud`, `freerouting` and the like upload the design and are refused. */
|
|
106
|
+
export const LOCAL_AUTOROUTERS = ["auto_local", "sequential_trace"] as const;
|
|
107
|
+
|
|
108
|
+
/** Defaults: a common low-cost 2-layer process (0.15 mm traces, 0.3/0.6 mm vias), stated as an assumption in the README. */
|
|
109
|
+
export const DEFAULT_RULES: Rules = { trace_width_mm: 0.15, power_trace_width_mm: 0.3, via_drill_mm: 0.3, via_pad_mm: 0.6, edge_clearance_mm: 0.3 };
|
|
110
|
+
|
|
111
|
+
/** The constraint keys the provider declares (protoboard-extension.json lists the same). */
|
|
112
|
+
export const CONSTRAINT_KEYS = ["layers", "thickness_mm", "outline", "mounting_holes", "placements", "keepouts", "rules", "autorouter", "pours", "copper_oz", "finish", "solder_mask", "silkscreen", "assembly", "fab_notes", "via_in_pad", "via_touching_pad", "via_under_body", "routing", "trace_widths"] as const;
|
|
113
|
+
|
|
114
|
+
/** The width a net's traces get: its own, its class's, else the rules' (power and ground nets the power width; signals tscircuit's default). */
|
|
115
|
+
export function traceWidthOf(c: Pick<BoardConstraints, "trace_widths" | "rules">, net: { id: string; kind: "signal" | "power" | "ground" }): number | undefined {
|
|
116
|
+
return c.trace_widths.nets[net.id] ?? c.trace_widths.classes[net.kind] ?? (net.kind !== "signal" ? c.rules.power_trace_width_mm : undefined);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const isNum = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v);
|
|
120
|
+
const isVec2 = (v: unknown): v is Vec2 => Array.isArray(v) && v.length === 2 && v.every(isNum);
|
|
121
|
+
const r4 = (v: number) => Math.round(v * 1e4) / 1e4;
|
|
122
|
+
|
|
123
|
+
/** Signed area of a polygon (positive when counter-clockwise). */
|
|
124
|
+
export function polygonArea(points: Vec2[]): number {
|
|
125
|
+
let a = 0;
|
|
126
|
+
for (let i = 0; i < points.length; i++) {
|
|
127
|
+
const [x1, y1] = points[i];
|
|
128
|
+
const [x2, y2] = points[(i + 1) % points.length];
|
|
129
|
+
a += x1 * y2 - x2 * y1;
|
|
130
|
+
}
|
|
131
|
+
return a / 2;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Area centroid of a simple polygon. */
|
|
135
|
+
export function polygonCentroid(points: Vec2[]): Vec2 {
|
|
136
|
+
const a = polygonArea(points);
|
|
137
|
+
if (Math.abs(a) < 1e-12) return points[0] ?? [0, 0];
|
|
138
|
+
let cx = 0;
|
|
139
|
+
let cy = 0;
|
|
140
|
+
for (let i = 0; i < points.length; i++) {
|
|
141
|
+
const [x1, y1] = points[i];
|
|
142
|
+
const [x2, y2] = points[(i + 1) % points.length];
|
|
143
|
+
const f = x1 * y2 - x2 * y1;
|
|
144
|
+
cx += (x1 + x2) * f;
|
|
145
|
+
cy += (y1 + y2) * f;
|
|
146
|
+
}
|
|
147
|
+
return [r4(cx / (6 * a)), r4(cy / (6 * a))];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Point in polygon (even-odd rule). */
|
|
151
|
+
export function inside(p: Vec2, poly: Vec2[]): boolean {
|
|
152
|
+
let hit = false;
|
|
153
|
+
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
|
|
154
|
+
const [xi, yi] = poly[i];
|
|
155
|
+
const [xj, yj] = poly[j];
|
|
156
|
+
if (yi > p[1] !== yj > p[1] && p[0] < ((xj - xi) * (p[1] - yi)) / (yj - yi) + xi) hit = !hit;
|
|
157
|
+
}
|
|
158
|
+
return hit;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Circle as a polygon whose edges stay within `tolerance` mm of the true circle (at least 32 segments). */
|
|
162
|
+
export function circlePolygon(diameter: number, centre: Vec2 = [0, 0], tolerance = 0.01): Vec2[] {
|
|
163
|
+
const r = diameter / 2;
|
|
164
|
+
const n = Math.max(32, Math.ceil(Math.PI / Math.acos(Math.max(-1, 1 - tolerance / r))));
|
|
165
|
+
return Array.from({ length: n }, (_, i) => {
|
|
166
|
+
const a = (2 * Math.PI * i) / n;
|
|
167
|
+
return [r4(centre[0] + r * Math.cos(a)), r4(centre[1] + r * Math.sin(a))] as Vec2;
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Point strictly inside a keep-out's shape. */
|
|
172
|
+
export const insideKeepout = (p: Vec2, k: Keepout) => {
|
|
173
|
+
if ("rect" in k.shape) {
|
|
174
|
+
const { at, size } = k.shape.rect;
|
|
175
|
+
return Math.abs(p[0] - at[0]) < size[0] / 2 && Math.abs(p[1] - at[1]) < size[1] / 2;
|
|
176
|
+
}
|
|
177
|
+
return Math.hypot(p[0] - k.shape.circle.at[0], p[1] - k.shape.circle.at[1]) < k.shape.circle.diameter_mm / 2;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
/** Whether a keep-out and the outline share any area (sampled on a 21 × 21 grid over the keep-out, plus the outline's vertices). */
|
|
181
|
+
export function keepoutOverlaps(k: Keepout, outline: Vec2[]): boolean {
|
|
182
|
+
const [[cx, cy], [sx, sy]] = "rect" in k.shape ? [k.shape.rect.at, k.shape.rect.size] : [k.shape.circle.at, [k.shape.circle.diameter_mm, k.shape.circle.diameter_mm]];
|
|
183
|
+
for (let i = 0; i <= 20; i++) {
|
|
184
|
+
for (let j = 0; j <= 20; j++) {
|
|
185
|
+
const p: Vec2 = [cx + sx * (i / 20 - 0.5) * 0.999, cy + sy * (j / 20 - 0.5) * 0.999];
|
|
186
|
+
if (insideKeepout(p, k) && inside(p, outline)) return true;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return outline.some((p) => insideKeepout(p, k));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Read the config's constraints. Without an outline, the board's
|
|
194
|
+
* `design_envelope` (length × width) gives the rectangle [0, length] ×
|
|
195
|
+
* [0, width], and its keep-outs become copper keep-outs on the layers their
|
|
196
|
+
* z range reaches. `notes` are findings that do not stop generate: a
|
|
197
|
+
* keep-out off the board or between the copper layers.
|
|
198
|
+
*/
|
|
199
|
+
export function readConstraints(raw: Record<string, unknown>, envelope?: DesignEnvelopeTrait["params"]): { constraints?: BoardConstraints; problems: string[]; notes: string[] } {
|
|
200
|
+
const problems: string[] = [];
|
|
201
|
+
const notes: string[] = [];
|
|
202
|
+
const layers = raw.layers ?? 2;
|
|
203
|
+
if (layers !== 2 && layers !== 4) problems.push(`layers must be 2 or 4 (got ${JSON.stringify(layers)})`);
|
|
204
|
+
const thickness = raw.thickness_mm ?? 1.6;
|
|
205
|
+
if (!isNum(thickness) || thickness <= 0) problems.push("thickness_mm must be a positive number");
|
|
206
|
+
|
|
207
|
+
// ---- outline
|
|
208
|
+
let outline: Vec2[] = [];
|
|
209
|
+
let outlineKind: BoardConstraints["outlineKind"] = "polygon";
|
|
210
|
+
const o = raw.outline as Record<string, unknown> | undefined;
|
|
211
|
+
if (o === undefined) {
|
|
212
|
+
const l = envelope?.max_mm.length;
|
|
213
|
+
const w = envelope?.max_mm.width;
|
|
214
|
+
if (isNum(l) && isNum(w)) {
|
|
215
|
+
outline = [[0, 0], [l, 0], [l, w], [0, w]];
|
|
216
|
+
outlineKind = "envelope";
|
|
217
|
+
} else problems.push("no outline: give constraints.outline ({ circle | rect | polygon }) or a design_envelope with length and width on the board");
|
|
218
|
+
} else if (o && typeof o === "object" && "circle" in o) {
|
|
219
|
+
const c = o.circle as { diameter_mm?: unknown; at?: unknown };
|
|
220
|
+
if (!isNum(c?.diameter_mm) || c.diameter_mm <= 0) problems.push("outline.circle needs diameter_mm");
|
|
221
|
+
else {
|
|
222
|
+
outline = circlePolygon(c.diameter_mm, isVec2(c.at) ? c.at : [0, 0]);
|
|
223
|
+
outlineKind = "circle";
|
|
224
|
+
}
|
|
225
|
+
} else if (o && typeof o === "object" && "rect" in o) {
|
|
226
|
+
const r = o.rect as { width_mm?: unknown; height_mm?: unknown; at?: unknown };
|
|
227
|
+
if (!isNum(r?.width_mm) || !isNum(r?.height_mm)) problems.push("outline.rect needs width_mm and height_mm");
|
|
228
|
+
else {
|
|
229
|
+
const [cx, cy] = isVec2(r.at) ? r.at : [0, 0];
|
|
230
|
+
const [hw, hh] = [r.width_mm / 2, r.height_mm / 2];
|
|
231
|
+
outline = [[cx - hw, cy - hh], [cx + hw, cy - hh], [cx + hw, cy + hh], [cx - hw, cy + hh]];
|
|
232
|
+
outlineKind = "rect";
|
|
233
|
+
}
|
|
234
|
+
} else if (o && typeof o === "object" && "polygon" in o) {
|
|
235
|
+
const p = o.polygon as unknown[];
|
|
236
|
+
if (!Array.isArray(p) || p.length < 3 || !p.every(isVec2)) problems.push("outline.polygon needs at least three [x, y] points (mm)");
|
|
237
|
+
else outline = polygonArea(p as Vec2[]) < 0 ? [...(p as Vec2[])].reverse() : (p as Vec2[]);
|
|
238
|
+
} else problems.push("outline must be { circle: { diameter_mm } }, { rect: { width_mm, height_mm } } or { polygon: [[x, y], …] }");
|
|
239
|
+
|
|
240
|
+
// ---- envelope: the outline must fit what the board module requires
|
|
241
|
+
if (outline.length && envelope?.max_mm) {
|
|
242
|
+
const xs = outline.map((p) => p[0]);
|
|
243
|
+
const ys = outline.map((p) => p[1]);
|
|
244
|
+
const dx = Math.max(...xs) - Math.min(...xs);
|
|
245
|
+
const dy = Math.max(...ys) - Math.min(...ys);
|
|
246
|
+
const [big, small] = [Math.max(dx, dy), Math.min(dx, dy)];
|
|
247
|
+
const { length, width } = envelope.max_mm;
|
|
248
|
+
const lim = [length, width].filter(isNum).sort((a, b) => b - a);
|
|
249
|
+
if ((lim[0] !== undefined && big > lim[0] + 1e-6) || (lim[1] !== undefined && small > lim[1] + 1e-6)) {
|
|
250
|
+
problems.push(`outline ${r4(dx)} × ${r4(dy)} mm exceeds the board's design envelope ${[length, width].filter(isNum).join(" × ")} mm${envelope.reason ? ` (${envelope.reason})` : ""}`);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ---- holes
|
|
255
|
+
const holes: Hole[] = [];
|
|
256
|
+
const rawHoles = (raw.mounting_holes ?? []) as unknown[];
|
|
257
|
+
if (!Array.isArray(rawHoles)) problems.push("mounting_holes must be a list of { id, at: [x, y], diameter_mm }");
|
|
258
|
+
else
|
|
259
|
+
rawHoles.forEach((h, i) => {
|
|
260
|
+
const x = h as Partial<Hole>;
|
|
261
|
+
if (!isVec2(x?.at) || !isNum(x?.diameter_mm)) problems.push(`mounting_holes[${i}] needs at: [x, y] and diameter_mm`);
|
|
262
|
+
else holes.push({ id: typeof x.id === "string" && x.id ? x.id : `hole_${i + 1}`, at: x.at, diameter_mm: x.diameter_mm });
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
// ---- fixed placements
|
|
266
|
+
const placements: Record<string, Placement> = {};
|
|
267
|
+
const rawPlacements = (raw.placements ?? {}) as Record<string, unknown>;
|
|
268
|
+
for (const [inst, v] of Object.entries(rawPlacements)) {
|
|
269
|
+
const p = v as { at?: unknown; rotation_deg?: unknown; side?: unknown };
|
|
270
|
+
if (!isVec2(p?.at)) problems.push(`placements.${inst} needs at: [x, y] (mm)`);
|
|
271
|
+
else if (p.rotation_deg !== undefined && !isNum(p.rotation_deg)) problems.push(`placements.${inst}.rotation_deg must be a number`);
|
|
272
|
+
else if (p.side !== undefined && p.side !== "top" && p.side !== "bottom") problems.push(`placements.${inst}.side must be top or bottom`);
|
|
273
|
+
else placements[inst] = { at: p.at, rotation_deg: (p.rotation_deg as number | undefined) ?? 0, side: (p.side as Side | undefined) ?? "top" };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ---- keep-outs: the config's, then the envelope's
|
|
277
|
+
const keepouts: Keepout[] = [];
|
|
278
|
+
// config entries with a name and allow_bodies but no shape: bodies allowed over the board module's
|
|
279
|
+
// design_envelope keep-out of that name (UHD keep-outs carry no such list: the module whose own
|
|
280
|
+
// antenna the keep-out clears may lie over it)
|
|
281
|
+
const allowOnEnvelope = new Map<string, { index: number; bodies: string[] }>();
|
|
282
|
+
const rawKeepouts = (raw.keepouts ?? []) as unknown[];
|
|
283
|
+
(Array.isArray(rawKeepouts) ? rawKeepouts : []).forEach((k, i) => {
|
|
284
|
+
const y = k as { name?: unknown; rect?: unknown; circle?: unknown; allow_bodies?: unknown };
|
|
285
|
+
if (y && typeof y === "object" && y.rect === undefined && y.circle === undefined && typeof y.name === "string" && y.allow_bodies !== undefined) {
|
|
286
|
+
if (!Array.isArray(y.allow_bodies) || !y.allow_bodies.length || y.allow_bodies.some((a) => typeof a !== "string")) problems.push(`keepouts[${i}].allow_bodies must be a list of instance paths`);
|
|
287
|
+
else allowOnEnvelope.set(y.name, { index: i, bodies: y.allow_bodies as string[] });
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
const x = k as { name?: unknown; rect?: { at?: unknown; size?: unknown }; circle?: { at?: unknown; diameter_mm?: unknown }; layers?: unknown; reason?: unknown; allow_bodies?: unknown };
|
|
291
|
+
const allow = x?.allow_bodies;
|
|
292
|
+
if (allow !== undefined && (!Array.isArray(allow) || allow.some((a) => typeof a !== "string"))) problems.push(`keepouts[${i}].allow_bodies must be a list of instance paths`);
|
|
293
|
+
const extra = { ...(typeof x?.reason === "string" ? { reason: x.reason } : {}), ...(Array.isArray(allow) && allow.length ? { allow_bodies: allow as string[] } : {}) };
|
|
294
|
+
const name = typeof x?.name === "string" ? x.name : `keepout_${i + 1}`;
|
|
295
|
+
const layers = (Array.isArray(x?.layers) ? x.layers : ["top", "bottom"]) as CopperLayer[];
|
|
296
|
+
const known: CopperLayer[] = raw.layers === 4 ? ["top", "bottom", "inner1", "inner2"] : ["top", "bottom"];
|
|
297
|
+
if (!layers.length || !layers.every((l) => known.includes(l))) problems.push(`keepouts[${i}].layers must list layers of this board (${known.join(", ")})`);
|
|
298
|
+
else if (x?.rect && isVec2(x.rect.at) && isVec2(x.rect.size)) keepouts.push({ name, shape: { rect: { at: x.rect.at, size: x.rect.size } }, layers, ...extra, from: "config" });
|
|
299
|
+
else if (x?.circle && isVec2(x.circle.at) && isNum(x.circle.diameter_mm)) keepouts.push({ name, shape: { circle: { at: x.circle.at, diameter_mm: x.circle.diameter_mm } }, layers, ...extra, from: "config" });
|
|
300
|
+
else problems.push(`keepouts[${i}] needs rect: { at, size } or circle: { at, diameter_mm }`);
|
|
301
|
+
});
|
|
302
|
+
for (const k of envelope?.keep_outs ?? []) {
|
|
303
|
+
const [x0, y0, z0] = k.region.min;
|
|
304
|
+
const [x1, y1, z1] = k.region.max;
|
|
305
|
+
const t = isNum(thickness) ? thickness : 1.6;
|
|
306
|
+
const reaches = (z: number) => Math.min(z0, z1) <= z + 1e-9 && Math.max(z0, z1) >= z - 1e-9;
|
|
307
|
+
const layers: Side[] = [...(reaches(t / 2) ? ["top" as const] : []), ...(reaches(-t / 2) ? ["bottom" as const] : [])];
|
|
308
|
+
if (!layers.length) {
|
|
309
|
+
notes.push(`design_envelope keep-out ${k.name} (z ${Math.min(z0, z1)} to ${Math.max(z0, z1)} mm) reaches neither copper layer (top z = ${t / 2}, bottom z = ${-t / 2} in the board frame); it is not applied`);
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
const allow = allowOnEnvelope.get(k.name)?.bodies;
|
|
313
|
+
keepouts.push({ name: k.name, shape: { rect: { at: [r4((x0 + x1) / 2), r4((y0 + y1) / 2)], size: [r4(Math.abs(x1 - x0)), r4(Math.abs(y1 - y0))] } }, layers, ...(k.reason ? { reason: k.reason } : {}), ...(allow ? { allow_bodies: allow } : {}), from: "envelope" });
|
|
314
|
+
}
|
|
315
|
+
const envelopeNames = new Set((envelope?.keep_outs ?? []).map((k) => k.name));
|
|
316
|
+
for (const [name, { index }] of allowOnEnvelope) {
|
|
317
|
+
if (!envelopeNames.has(name)) problems.push(`keepouts[${index}] (${name}) gives allow_bodies without a shape, which names a keep-out of the board's design_envelope, and the board has none called ${name}${envelopeNames.size ? ` (it has ${[...envelopeNames].join(", ")})` : ""}`);
|
|
318
|
+
}
|
|
319
|
+
// a keep-out that misses the board protects nothing: most likely stated in another frame
|
|
320
|
+
if (outline.length) {
|
|
321
|
+
for (const k of keepouts) {
|
|
322
|
+
if (!keepoutOverlaps(k, outline)) notes.push(`keep-out ${k.name}${k.from === "envelope" ? " (design_envelope)" : ""} lies entirely outside the board outline, so it protects nothing; keep-outs are in the board frame (${outlineKind === "envelope" ? "the envelope outline spans [0, length] × [0, width]" : "the frame circuit.config.ts gives the outline in"})`);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ---- rules and router
|
|
327
|
+
const rules: Rules = { ...DEFAULT_RULES };
|
|
328
|
+
const rawRules = (raw.rules ?? {}) as Record<string, unknown>;
|
|
329
|
+
const ruleKeys = [...Object.keys(DEFAULT_RULES), "hole_clearance_mm"];
|
|
330
|
+
for (const [k, v] of Object.entries(rawRules)) {
|
|
331
|
+
if (!ruleKeys.includes(k)) problems.push(`rules.${k} is not a rule (rules: ${ruleKeys.join(", ")})`);
|
|
332
|
+
else if (!isNum(v) || v <= 0) problems.push(`rules.${k} must be a positive number (mm)`);
|
|
333
|
+
else rules[k as keyof Rules] = v;
|
|
334
|
+
}
|
|
335
|
+
const autorouter = (raw.autorouter ?? "auto_local") as BoardConstraints["autorouter"];
|
|
336
|
+
if (!(LOCAL_AUTOROUTERS as readonly string[]).includes(autorouter)) {
|
|
337
|
+
problems.push(`autorouter ${JSON.stringify(raw.autorouter)} is not a local preset (${LOCAL_AUTOROUTERS.join(", ")}); cloud routing uploads the design and is never used`);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// ---- copper pours
|
|
341
|
+
const pours: Pour[] = [];
|
|
342
|
+
const rawPours = (raw.pours ?? []) as unknown[];
|
|
343
|
+
if (!Array.isArray(rawPours)) problems.push("pours must be a list of { net, layers }");
|
|
344
|
+
else
|
|
345
|
+
rawPours.forEach((x, i) => {
|
|
346
|
+
const q = x as { net?: unknown; layers?: unknown; clearance_mm?: unknown; reason?: unknown };
|
|
347
|
+
const ok: CopperLayer[] = isNum(layers) && layers === 4 ? ["top", "bottom", "inner1", "inner2"] : ["top", "bottom"];
|
|
348
|
+
if (typeof q?.net !== "string" || !q.net) problems.push(`pours[${i}] needs net: the UHD net id to pour`);
|
|
349
|
+
else if (!Array.isArray(q.layers) || !q.layers.length || !q.layers.every((l) => ok.includes(l as CopperLayer))) problems.push(`pours[${i}].layers must list layers of this board (${ok.join(", ")})`);
|
|
350
|
+
else if (q.clearance_mm !== undefined && (!isNum(q.clearance_mm) || q.clearance_mm <= 0)) problems.push(`pours[${i}].clearance_mm must be a positive number`);
|
|
351
|
+
else pours.push({ net: q.net, layers: q.layers as CopperLayer[], ...(isNum(q.clearance_mm) ? { clearance_mm: q.clearance_mm } : {}), ...(typeof q.reason === "string" ? { reason: q.reason } : {}) });
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
// ---- fabrication and assembly order (the Manufacturing Package's board order; optional, defaults stated there)
|
|
355
|
+
if (raw.copper_oz !== undefined && (!isNum(raw.copper_oz) || raw.copper_oz <= 0)) problems.push(`copper_oz must be a positive number (oz/ft² of the outer layers; got ${JSON.stringify(raw.copper_oz)})`);
|
|
356
|
+
for (const k of ["finish", "solder_mask", "silkscreen"] as const) {
|
|
357
|
+
if (raw[k] !== undefined && (typeof raw[k] !== "string" || !(raw[k] as string).trim())) problems.push(`${k} must be a non-empty string (got ${JSON.stringify(raw[k])})`);
|
|
358
|
+
}
|
|
359
|
+
if (raw.fab_notes !== undefined && (!Array.isArray(raw.fab_notes) || raw.fab_notes.some((n) => typeof n !== "string" || !n.trim()))) problems.push(`fab_notes must be a list of non-empty strings (got ${JSON.stringify(raw.fab_notes)})`);
|
|
360
|
+
if (raw.assembly !== undefined && raw.assembly !== "fab" && raw.assembly !== "hand") problems.push(`assembly must be "fab" or "hand" (got ${JSON.stringify(raw.assembly)})`);
|
|
361
|
+
if (raw.via_in_pad !== undefined && raw.via_in_pad !== "avoid" && raw.via_in_pad !== "fill_and_cap") problems.push(`via_in_pad must be "avoid" or "fill_and_cap" (got ${JSON.stringify(raw.via_in_pad)})`);
|
|
362
|
+
for (const k of ["via_touching_pad", "via_under_body"] as const) if (raw[k] !== undefined && raw[k] !== "avoid" && raw[k] !== "allow") problems.push(`${k} must be "avoid" or "allow" (got ${JSON.stringify(raw[k])})`);
|
|
363
|
+
if (raw.routing !== undefined && raw.routing !== "on" && raw.routing !== "off") problems.push(`routing must be "on" or "off" (got ${JSON.stringify(raw.routing)})`);
|
|
364
|
+
// ---- trace widths per net and per class
|
|
365
|
+
const trace_widths: BoardConstraints["trace_widths"] = { nets: {}, classes: {} };
|
|
366
|
+
const tw = raw.trace_widths as { nets?: unknown; classes?: unknown } | undefined;
|
|
367
|
+
if (tw !== undefined) {
|
|
368
|
+
if (!tw || typeof tw !== "object" || Array.isArray(tw) || Object.keys(tw).some((k) => k !== "nets" && k !== "classes")) problems.push("trace_widths must be { nets?: { <net id>: mm }, classes?: { signal?, power?, ground?: mm } }");
|
|
369
|
+
else {
|
|
370
|
+
for (const [k, v] of Object.entries((tw.nets ?? {}) as Record<string, unknown>)) {
|
|
371
|
+
if (!isNum(v) || v <= 0) problems.push(`trace_widths.nets.${k} must be a positive number (mm)`);
|
|
372
|
+
else trace_widths.nets[k] = v;
|
|
373
|
+
}
|
|
374
|
+
for (const [k, v] of Object.entries((tw.classes ?? {}) as Record<string, unknown>)) {
|
|
375
|
+
if (k !== "signal" && k !== "power" && k !== "ground") problems.push(`trace_widths.classes.${k} is not a net class (signal, power, ground)`);
|
|
376
|
+
else if (!isNum(v) || v <= 0) problems.push(`trace_widths.classes.${k} must be a positive number (mm)`);
|
|
377
|
+
else trace_widths.classes[k] = v;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (problems.length) return { problems, notes };
|
|
383
|
+
return { constraints: { layers: layers as 2 | 4, thickness_mm: thickness as number, outline: outline.map(([x, y]) => [r4(x), r4(y)]), outlineKind, holes, placements, keepouts, rules, autorouter, pours, via_in_pad: (raw.via_in_pad as "avoid" | "fill_and_cap" | undefined) ?? "avoid", via_touching_pad: (raw.via_touching_pad as "avoid" | "allow" | undefined) ?? "allow", via_under_body: (raw.via_under_body as "avoid" | "allow" | undefined) ?? "allow", routing: (raw.routing as "on" | "off" | undefined) ?? "on", trace_widths }, problems, notes };
|
|
384
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What an export reads, recorded with it (PB-869), so a board's outputs can
|
|
3
|
+
* be shown to follow from their inputs: the same inputs give the same
|
|
4
|
+
* board.step and board.glb byte for byte, and when they differ check says
|
|
5
|
+
* which input changed (CIRCUIT_STALE) instead of leaving a re-export to
|
|
6
|
+
* surprise whoever reads the outputs next (an enclosure built around the
|
|
7
|
+
* board, a release).
|
|
8
|
+
*
|
|
9
|
+
* `tscircuit/export.json`, written by every export:
|
|
10
|
+
*
|
|
11
|
+
* circuit the built board (circuit.json) the outputs are made from
|
|
12
|
+
* board what the board depends on in UHD, not the whole module: its
|
|
13
|
+
* components (part, name, maker, MPN, value, package, pins, body
|
|
14
|
+
* artifacts and mount frames, interface ids the bodies are
|
|
15
|
+
* stripped of), its nets, its edge and the edge parts' mating
|
|
16
|
+
* frames, and its design envelope (outline, keep-outs). A part's
|
|
17
|
+
* description, price or documents are not in it, so editing them
|
|
18
|
+
* does not make out/ stale. Mounting holes and the outline given
|
|
19
|
+
* in circuit.config.ts are in `constraints`.
|
|
20
|
+
* Records before 0.1.7 hold `module` (a digest of every definition
|
|
21
|
+
* the module reaches) instead: check says once that the format
|
|
22
|
+
* changed and compares the rest, until the next export.
|
|
23
|
+
* constraints the circuit.config.ts constraints (outline, keep-outs, fab keys)
|
|
24
|
+
* models each component body file used, by label: a part's STEP, or a box size
|
|
25
|
+
* provider this provider's export code
|
|
26
|
+
* converters the versions of the converter libraries that write the files
|
|
27
|
+
*/
|
|
28
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
29
|
+
import { dirname, join } from "node:path";
|
|
30
|
+
import { fileURLToPath } from "node:url";
|
|
31
|
+
import type { BoardNetlist } from "@protoboardai/core";
|
|
32
|
+
import type { ComponentBody } from "./models.ts";
|
|
33
|
+
import { digestOf, files } from "./native.ts";
|
|
34
|
+
import { packageVersion } from "./runtime.ts";
|
|
35
|
+
|
|
36
|
+
export const EXPORT_SCHEMA = "protoboard.tscircuit-export/v0";
|
|
37
|
+
|
|
38
|
+
export interface ExportInputs {
|
|
39
|
+
circuit: string;
|
|
40
|
+
/** Digest of the board projection (boardProjection). */
|
|
41
|
+
board?: string;
|
|
42
|
+
/** Records before 0.1.7: digest of the whole module and every definition it reaches. */
|
|
43
|
+
module?: string;
|
|
44
|
+
constraints: string;
|
|
45
|
+
models: Record<string, string>;
|
|
46
|
+
provider: string;
|
|
47
|
+
converters: Record<string, string>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ExportRecord {
|
|
51
|
+
schema: typeof EXPORT_SCHEMA;
|
|
52
|
+
inputs: ExportInputs;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const exportFile = (native: string) => `${native}/export.json`;
|
|
56
|
+
|
|
57
|
+
const CONVERTERS = ["circuit-json-to-gerber", "circuit-json-to-gltf", "circuit-json-to-step", "circuit-to-svg"];
|
|
58
|
+
const PACKAGE = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
59
|
+
|
|
60
|
+
let providerDigest: string | undefined;
|
|
61
|
+
/** Digest of the code an export runs: ops/circuit.ts and lib/ (not the check, which writes nothing). */
|
|
62
|
+
function providerCode(): string {
|
|
63
|
+
if (providerDigest) return providerDigest;
|
|
64
|
+
const lib = readdirSync(join(PACKAGE, "lib"))
|
|
65
|
+
.filter((f) => /\.(ts|cjs)$/.test(f) && f !== "checks.ts" && f !== "probe.cjs")
|
|
66
|
+
.sort()
|
|
67
|
+
.map((f) => `lib/${f}`);
|
|
68
|
+
const parts = ["ops/circuit.ts", ...lib].map((f) => `${f}\n${readFileSync(join(PACKAGE, f), "utf8")}`);
|
|
69
|
+
providerDigest = digestOf(parts.join("\n\0"));
|
|
70
|
+
return providerDigest;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The part of the UHD board an export depends on (see `board` above): the board netlist (which
|
|
75
|
+
* already leaves out descriptions, prices and documents), the mating frame each board export's part
|
|
76
|
+
* states, and each part's interface ids (the feature shapes stripped from its body).
|
|
77
|
+
*/
|
|
78
|
+
export function boardProjection(netlist: BoardNetlist, edgeFrames: Record<string, unknown>, interfaces: Record<string, string[]>): unknown {
|
|
79
|
+
const { problems: _problems, ...rest } = netlist;
|
|
80
|
+
const parts = [...new Set(netlist.components.map((c) => c.part))].sort();
|
|
81
|
+
return { ...rest, edgeFrames: Object.fromEntries(Object.entries(edgeFrames).sort(([a], [b]) => a.localeCompare(b))), interfaces: Object.fromEntries(parts.map((p) => [p, [...(interfaces[p] ?? [])].sort()])) };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export const boardDigest = (projection: unknown) => digestOf(stableJson(projection));
|
|
85
|
+
|
|
86
|
+
/** JSON with object keys sorted, so equal projections digest equally whatever order they were built in. */
|
|
87
|
+
function stableJson(v: unknown): string {
|
|
88
|
+
if (Array.isArray(v)) return `[${v.map(stableJson).join(",")}]`;
|
|
89
|
+
if (v && typeof v === "object") return `{${Object.keys(v).sort().filter((k) => (v as Record<string, unknown>)[k] !== undefined).map((k) => `${JSON.stringify(k)}:${stableJson((v as Record<string, unknown>)[k])}`).join(",")}}`;
|
|
90
|
+
return JSON.stringify(v) ?? "null";
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The inputs an export of this board reads, now. */
|
|
94
|
+
export function exportInputs(root: string, native: string, boardDigest: string, constraints: Record<string, unknown>, bodies: ComponentBody[]): ExportInputs {
|
|
95
|
+
const circuit = join(root, files(native).circuit);
|
|
96
|
+
const models: Record<string, string> = {};
|
|
97
|
+
for (const b of bodies) {
|
|
98
|
+
if (b.source === "part" && b.step && b.label) models[b.label] = existsSync(b.step) ? digestOf(readFileSync(b.step)) : "missing";
|
|
99
|
+
if (b.mesh) models[b.mesh.label] = existsSync(b.mesh.path) ? digestOf(readFileSync(b.mesh.path)) : "missing";
|
|
100
|
+
if ((b.source === "box" || b.mesh) && b.box) models[b.box.path] = `box ${b.box.size.join(" x ")}`;
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
circuit: existsSync(circuit) ? digestOf(readFileSync(circuit)) : "missing",
|
|
104
|
+
board: boardDigest,
|
|
105
|
+
constraints: digestOf(JSON.stringify(constraints ?? {})),
|
|
106
|
+
models: Object.fromEntries(Object.entries(models).sort(([a], [b]) => a.localeCompare(b))),
|
|
107
|
+
provider: providerCode(),
|
|
108
|
+
converters: Object.fromEntries(CONVERTERS.map((c) => [c, packageVersion(c)])),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function readExportRecord(root: string, native: string): ExportRecord | undefined {
|
|
113
|
+
const f = join(root, exportFile(native));
|
|
114
|
+
if (!existsSync(f)) return undefined;
|
|
115
|
+
try {
|
|
116
|
+
const r = JSON.parse(readFileSync(f, "utf8")) as ExportRecord;
|
|
117
|
+
return r?.schema === EXPORT_SCHEMA && r.inputs ? r : undefined;
|
|
118
|
+
} catch {
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** What differs between the inputs an export recorded and the inputs now, in words. */
|
|
124
|
+
export function exportDrift(was: ExportInputs, now: ExportInputs): string[] {
|
|
125
|
+
const out: string[] = [];
|
|
126
|
+
if (was.circuit !== now.circuit) out.push("the built board (circuit.json)");
|
|
127
|
+
// a record before 0.1.7 fingerprinted the whole module: it cannot be compared with the board projection
|
|
128
|
+
// (fingerprintFormatChanged says so), and is not reported as a change
|
|
129
|
+
if (was.board !== undefined && was.board !== now.board) out.push("the UHD board (its parts, pins, nets, packages, bodies, edge or envelope)");
|
|
130
|
+
if (was.constraints !== now.constraints) out.push("the circuit.config.ts constraints");
|
|
131
|
+
for (const k of [...new Set([...Object.keys(was.models ?? {}), ...Object.keys(now.models ?? {})])].sort()) {
|
|
132
|
+
const [a, b] = [was.models?.[k], now.models?.[k]];
|
|
133
|
+
if (a === b) continue;
|
|
134
|
+
out.push(a === undefined ? `a body now used: ${k}` : b === undefined ? `a body no longer used: ${k}` : `the body ${k}`);
|
|
135
|
+
}
|
|
136
|
+
if (was.provider !== now.provider) out.push("the provider's export code (@protoboardai/ext-tscircuit)");
|
|
137
|
+
for (const c of [...new Set([...Object.keys(was.converters ?? {}), ...Object.keys(now.converters ?? {})])].sort()) {
|
|
138
|
+
if (was.converters?.[c] !== now.converters?.[c]) out.push(`${c} ${was.converters?.[c] ?? "-"} → ${now.converters?.[c] ?? "-"}`);
|
|
139
|
+
}
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Whether a record was written in the fingerprint format before 0.1.7 (the whole module, not the board projection). */
|
|
144
|
+
export const fingerprintFormatChanged = (was: ExportInputs) => was.board === undefined && was.module !== undefined;
|