@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,594 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The KiCad project of a built board (docs/specs/pcb-kicad-handoff.md §2).
|
|
3
|
+
*
|
|
4
|
+
* tscircuit's own circuit-json-to-kicad writes the board, the schematic and
|
|
5
|
+
* the project file from circuit.json. This module wraps it:
|
|
6
|
+
*
|
|
7
|
+
* before components are renamed to their reference designators (refdes.ts),
|
|
8
|
+
* and their bodies are set on the circuit JSON as circuit:export
|
|
9
|
+
* sets them (models.ts), so the converter places each 3D model
|
|
10
|
+
* after the S-expressions are edited: footprints and symbols move to the
|
|
11
|
+
* project's own libraries (<b>.pretty, <b>.kicad_sym), 3D models
|
|
12
|
+
* to ${KIPRJMOD}/3d/, fields (MPN, manufacturer, LCSC, UHD identity)
|
|
13
|
+
* are set, each footprint is linked to its symbol, UUIDs are derived
|
|
14
|
+
* from the board and the instance paths, the copper layers, board
|
|
15
|
+
* thickness and stack-up follow the config, and the board frame's
|
|
16
|
+
* origin is KiCad's drill/place origin
|
|
17
|
+
*
|
|
18
|
+
* `kicad-cli pcb upgrade` / `sch upgrade` then save both in KiCad 10's own
|
|
19
|
+
* format (upgrade.ts); without KiCad the converter's format is kept.
|
|
20
|
+
*/
|
|
21
|
+
import { createHash } from "node:crypto";
|
|
22
|
+
import { basename, isAbsolute, resolve } from "node:path";
|
|
23
|
+
import type { BoardNetlist } from "@protoboardai/core";
|
|
24
|
+
import { CircuitJsonToKicadPcbConverter, CircuitJsonToKicadProConverter } from "circuit-json-to-kicad";
|
|
25
|
+
import type { BoardConstraints, Keepout } from "../config.ts";
|
|
26
|
+
import type { CircuitElement } from "../runtime.ts";
|
|
27
|
+
import type { NameMap } from "../source.ts";
|
|
28
|
+
import { EXTRA_FIELDS, type ComponentFields } from "./fields.ts";
|
|
29
|
+
import { buildSchematic, type KicadPinType } from "./schematic.ts";
|
|
30
|
+
import { child, children, head, isList, isStr, list, parse, print, printDocument, propertyNode, str, text, type List, type Node } from "./sexpr.ts";
|
|
31
|
+
|
|
32
|
+
/** The converter places the board frame's origin here, with Y down (its c2kMatPcb). */
|
|
33
|
+
export const KICAD_ORIGIN: [number, number] = [100, 100];
|
|
34
|
+
|
|
35
|
+
export interface KicadProjectInput {
|
|
36
|
+
/** Board module id: the file stem and the library name. */
|
|
37
|
+
board: string;
|
|
38
|
+
/** The built board, with bodies applied (cad_component.model_step_url: absolute STEP paths). */
|
|
39
|
+
circuitJson: CircuitElement[];
|
|
40
|
+
map: NameMap;
|
|
41
|
+
netlist: BoardNetlist;
|
|
42
|
+
/** instance → designator */
|
|
43
|
+
refdes: Record<string, string>;
|
|
44
|
+
/** instance → fields */
|
|
45
|
+
fields: Record<string, ComponentFields>;
|
|
46
|
+
constraints?: BoardConstraints;
|
|
47
|
+
/** Absolute folder a relative model path is read from (the native folder: a cadModel in board.tsx). */
|
|
48
|
+
modelBase?: string;
|
|
49
|
+
/** Whether a model file can be used (exists, is a STEP file); a model that cannot is left off its footprint. */
|
|
50
|
+
modelOk?: (abs: string) => boolean;
|
|
51
|
+
/** Outer copper in oz (stack-up; default 1). */
|
|
52
|
+
copperOz?: number;
|
|
53
|
+
/** instance → leaf interface → KiCad electrical type, from the UHD leaf types (schematic.ts `kicadPinType`). */
|
|
54
|
+
pinTypes?: Record<string, Record<string, KicadPinType>>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* UUIDs renumbered in document order (the texts in the order given), except
|
|
59
|
+
* those in `keep`: what KiCad or the converter made up becomes the same on
|
|
60
|
+
* every export of the same board. References between the texts stay
|
|
61
|
+
* consistent (the schematic's root UUID in the project file).
|
|
62
|
+
*/
|
|
63
|
+
export function stabiliseUuids(board: string, texts: string[], keep: Set<string>, salt = "n"): string[] {
|
|
64
|
+
const renumber = new Map<string, string>();
|
|
65
|
+
let n = 0;
|
|
66
|
+
return texts.map((t) =>
|
|
67
|
+
t.replace(UUID, (u) => {
|
|
68
|
+
if (keep.has(u)) return u;
|
|
69
|
+
if (!renumber.has(u)) renumber.set(u, stableUuid(board, `${salt}:${n++}`));
|
|
70
|
+
return renumber.get(u)!;
|
|
71
|
+
}),
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface KicadProject {
|
|
76
|
+
/** Files relative to the KiCad folder. */
|
|
77
|
+
files: Record<string, string>;
|
|
78
|
+
/** 3D models to copy: path relative to the KiCad folder → absolute source. */
|
|
79
|
+
models: Record<string, string>;
|
|
80
|
+
/** Symbol UUID per instance (footprints link to it: Update PCB from Schematic). */
|
|
81
|
+
symbols: Record<string, string>;
|
|
82
|
+
/** The UUIDs derived from instance paths (symbols, footprints): kept when the files are renumbered again. */
|
|
83
|
+
keep: Set<string>;
|
|
84
|
+
notes: string[];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** A UUID derived from the board and a key: the same board exports the same UUIDs. */
|
|
88
|
+
export function stableUuid(board: string, key: string): string {
|
|
89
|
+
const h = createHash("sha256").update(`${board}\0${key}`).digest("hex");
|
|
90
|
+
return `${h.slice(0, 8)}-${h.slice(8, 12)}-4${h.slice(13, 16)}-${((parseInt(h[16], 16) & 3) | 8).toString(16)}${h.slice(17, 20)}-${h.slice(20, 32)}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** A library item name KiCad accepts (no colon or slash). */
|
|
94
|
+
export const libItemName = (s: string) => s.replace(/[:/\\\s"]+/g, "_");
|
|
95
|
+
|
|
96
|
+
/** The library (footprint and symbol) of a board's project. */
|
|
97
|
+
export const libraryName = (board: string) => libItemName(board);
|
|
98
|
+
|
|
99
|
+
const UUID = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g;
|
|
100
|
+
|
|
101
|
+
/** The circuit JSON as the converter should see it: references for names, the part numbers on each component. */
|
|
102
|
+
function prepare(input: KicadProjectInput): { cj: CircuitElement[]; nameOf: Map<string, string> } {
|
|
103
|
+
const nameOf = new Map<string, string>(); // refdes → tscircuit name
|
|
104
|
+
const renamed = new Map<string, string>(); // tscircuit name → refdes
|
|
105
|
+
for (const [name, m] of Object.entries(input.map.components)) {
|
|
106
|
+
const r = input.refdes[m.instance];
|
|
107
|
+
if (r) {
|
|
108
|
+
renamed.set(name, r);
|
|
109
|
+
nameOf.set(r, name);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const pcbOfSource = new Map<string, string>();
|
|
113
|
+
for (const e of input.circuitJson) if (e.type === "pcb_component") pcbOfSource.set(e.pcb_component_id, e.source_component_id);
|
|
114
|
+
const sourceName = new Map(input.circuitJson.filter((e) => e.type === "source_component").map((s) => [s.source_component_id, s.name as string]));
|
|
115
|
+
const cj = input.circuitJson.map((e) => {
|
|
116
|
+
if (e.type === "source_component" && renamed.has(e.name)) {
|
|
117
|
+
const inst = input.map.components[e.name].instance;
|
|
118
|
+
const f = input.fields[inst];
|
|
119
|
+
return { ...e, name: renamed.get(e.name), ...(f?.MPN ? { manufacturer_part_number: f.MPN } : {}) };
|
|
120
|
+
}
|
|
121
|
+
// the reference silk.ts printed for an authored land pattern
|
|
122
|
+
if (e.type === "pcb_silkscreen_text" && e.pcb_component_id) {
|
|
123
|
+
const old = sourceName.get(pcbOfSource.get(e.pcb_component_id) ?? "");
|
|
124
|
+
if (old && e.text === old && renamed.has(old)) return { ...e, text: renamed.get(old) };
|
|
125
|
+
}
|
|
126
|
+
return e;
|
|
127
|
+
});
|
|
128
|
+
return { cj, nameOf };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const hidden = (at: Node[]) => list("effects", list("font", list("size", "1.27", "1.27")), list("hide", "yes"));
|
|
132
|
+
|
|
133
|
+
/** Set (or add) a hidden property on a footprint or symbol. */
|
|
134
|
+
function setProperty(n: List, name: string, value: string, layer?: string) {
|
|
135
|
+
const p = propertyNode(n, name);
|
|
136
|
+
if (p) {
|
|
137
|
+
p.items[2] = str(value);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const at = child(n, "at");
|
|
141
|
+
const pos = at ? [at.items[1] ?? "0", at.items[2] ?? "0", "0"] : ["0", "0", "0"];
|
|
142
|
+
const prop = list("property", str(name), str(value), list("at", ...(layer ? ["0", "0", "0"] : pos)), ...(layer ? [list("layer", str(layer)), list("hide", "yes")] : []), hidden([]));
|
|
143
|
+
// after the last property
|
|
144
|
+
const last = n.items.reduce((k, c, i) => (head(c) === "property" ? i : k), -1);
|
|
145
|
+
n.items.splice(last >= 0 ? last + 1 : n.items.length, 0, prop);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const fieldsOf = (f: ComponentFields): [string, string][] => EXTRA_FIELDS.filter((k) => f[k]).map((k) => [k, f[k] as string]);
|
|
149
|
+
|
|
150
|
+
/** KiCad's layer list and stack-up for 2 or 4 copper layers. */
|
|
151
|
+
function stackup(layers: 2 | 4, thickness: number, oz: number): List {
|
|
152
|
+
const cu = Math.round(0.035 * oz * 10000) / 10000;
|
|
153
|
+
const mask = 0.01;
|
|
154
|
+
const copper = layers === 4 ? ["F.Cu", "In1.Cu", "In2.Cu", "B.Cu"] : ["F.Cu", "B.Cu"];
|
|
155
|
+
const dielectric = Math.max(0.05, (thickness - copper.length * cu - 2 * mask) / (copper.length - 1));
|
|
156
|
+
const r = (v: number) => String(Math.round(v * 10000) / 10000);
|
|
157
|
+
const items: Node[] = [
|
|
158
|
+
list("layer", str("F.SilkS"), list("type", str("Top Silk Screen"))),
|
|
159
|
+
list("layer", str("F.Paste"), list("type", str("Top Solder Paste"))),
|
|
160
|
+
list("layer", str("F.Mask"), list("type", str("Top Solder Mask")), list("thickness", r(mask))),
|
|
161
|
+
];
|
|
162
|
+
copper.forEach((l, i) => {
|
|
163
|
+
items.push(list("layer", str(l), list("type", str("copper")), list("thickness", r(cu))));
|
|
164
|
+
if (i < copper.length - 1) items.push(list("layer", str(`dielectric ${i + 1}`), list("type", str(i === 1 || layers === 2 ? "core" : "prepreg")), list("thickness", r(dielectric)), list("material", str("FR4")), list("epsilon_r", "4.5"), list("loss_tangent", "0.02")));
|
|
165
|
+
});
|
|
166
|
+
items.push(list("layer", str("B.Mask"), list("type", str("Bottom Solder Mask")), list("thickness", r(mask))), list("layer", str("B.Paste"), list("type", str("Bottom Solder Paste"))), list("layer", str("B.SilkS"), list("type", str("Bottom Silk Screen"))));
|
|
167
|
+
return list("stackup", ...items, list("copper_finish", str("None")), list("dielectric_constraints", "no"));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* A footprint as its library holds it: placed geometry and nets removed. KiCad
|
|
172
|
+
* stores pad and text angles relative to the footprint, so they stay; a
|
|
173
|
+
* bottom-side copy is flipped back to the top.
|
|
174
|
+
*/
|
|
175
|
+
function libraryFootprint(fp: List, name: string): List {
|
|
176
|
+
const out = list("footprint", str(name), list("version", "20241229"), list("generator", str("protoboard")));
|
|
177
|
+
const bottom = text(child(fp, "layer")?.items[1]) === "B.Cu";
|
|
178
|
+
const rot = Number(text(child(fp, "at")?.items[3]) ?? 0) || 0;
|
|
179
|
+
const flipLayer = (s: string) => (s.startsWith("F.") ? `B.${s.slice(2)}` : s.startsWith("B.") ? `F.${s.slice(2)}` : s);
|
|
180
|
+
const unflip = (n: Node): Node => {
|
|
181
|
+
if (!isList(n)) return n;
|
|
182
|
+
const h = head(n);
|
|
183
|
+
if (h === "layer" || h === "layers") return { items: n.items.map((c, i) => (i === 0 ? c : isStr(c) ? str(flipLayer(c.str)) : typeof c === "string" ? flipLayer(c) : c)) };
|
|
184
|
+
if (h === "at" || h === "start" || h === "end" || h === "mid" || h === "center" || h === "xy") {
|
|
185
|
+
const items = [...n.items];
|
|
186
|
+
if (items[1] !== undefined) items[1] = String(-Number(text(items[1])));
|
|
187
|
+
if (h === "at" && items[3] !== undefined) items[3] = String(-Number(text(items[3])));
|
|
188
|
+
return { items };
|
|
189
|
+
}
|
|
190
|
+
if (h === "justify") return { items: n.items.filter((c) => text(c) !== "mirror") };
|
|
191
|
+
return { items: n.items.map(unflip) };
|
|
192
|
+
};
|
|
193
|
+
for (const c of fp.items.slice(2)) {
|
|
194
|
+
if (!isList(c)) continue;
|
|
195
|
+
const h = head(c);
|
|
196
|
+
if (h === "at" || h === "uuid" || h === "path" || h === "sheetname" || h === "sheetfile" || h === "locked") continue;
|
|
197
|
+
if (h === "property" && (EXTRA_FIELDS as readonly string[]).includes(text(c.items[1]) ?? "")) continue;
|
|
198
|
+
if (h === "layer") {
|
|
199
|
+
out.items.push(list("layer", str("F.Cu")));
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
const strip = (n: Node): Node => (isList(n) ? { items: n.items.filter((x) => !(isList(x) && (head(x) === "net" || head(x) === "uuid"))).map(strip) } : n);
|
|
203
|
+
let item = strip(c) as List;
|
|
204
|
+
// board files hold pad and text angles absolute; a library's are the footprint's own
|
|
205
|
+
if (rot && (h === "pad" || h === "property" || h === "fp_text")) {
|
|
206
|
+
const at = child(item, "at");
|
|
207
|
+
if (at && at.items[3] !== undefined) {
|
|
208
|
+
const a = (((Number(text(at.items[3])) - rot) % 360) + 360) % 360;
|
|
209
|
+
at.items[3] = String(Math.round(a * 1e6) / 1e6);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
if (bottom) item = unflip(item) as List;
|
|
213
|
+
if (h === "property" && text(item.items[1]) === "Reference") item.items[2] = str("REF**");
|
|
214
|
+
out.items.push(item);
|
|
215
|
+
}
|
|
216
|
+
return out;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** A symbol library from the schematic's embedded symbols, names without the library prefix. */
|
|
220
|
+
function symbolLibrary(libSymbols: List[]): string {
|
|
221
|
+
const lib = list("kicad_symbol_lib", list("version", "20241209"), list("generator", str("protoboard")), list("generator_version", str("0.1")));
|
|
222
|
+
const seen = new Set<string>();
|
|
223
|
+
for (const s of libSymbols) {
|
|
224
|
+
if (seen.has(text(s.items[1]) ?? "")) continue;
|
|
225
|
+
seen.add(text(s.items[1]) ?? "");
|
|
226
|
+
const copy = JSON.parse(JSON.stringify(s)) as List;
|
|
227
|
+
const name = text(copy.items[1]) ?? "";
|
|
228
|
+
copy.items[1] = str(name.includes(":") ? name.slice(name.indexOf(":") + 1) : name);
|
|
229
|
+
lib.items.push(copy);
|
|
230
|
+
}
|
|
231
|
+
return printDocument(lib);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** The footprint's reference node: a Reference property, or (older files) `(fp_text reference …)`; its value is item 2. */
|
|
235
|
+
const referenceNode = (fp: List): List | undefined => propertyNode(fp, "Reference") ?? children(fp, "fp_text").find((t) => text(t.items[1]) === "reference");
|
|
236
|
+
|
|
237
|
+
/** Set a footprint's attributes (`(attr smd)`, `(attr board_only exclude_from_bom …)`). */
|
|
238
|
+
function setAttr(fp: List, flags: string[]) {
|
|
239
|
+
const i = fp.items.findIndex((c) => head(c) === "attr");
|
|
240
|
+
const node = list("attr", ...flags);
|
|
241
|
+
if (i >= 0) fp.items[i] = node;
|
|
242
|
+
else fp.items.splice(fp.items.findIndex((c) => head(c) === "at") + 1, 0, node);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const num = (n: Node | undefined) => Number(text(n) ?? 0);
|
|
246
|
+
const fmt = (v: number) => String(Math.round(v * 1e6) / 1e6);
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Pads turned with their footprint. In a board file KiCad reads a pad's angle
|
|
250
|
+
* as absolute; the converter writes 0 and gives the board-aligned size, which
|
|
251
|
+
* is the right copper but not the library's pad turned (so KiCad reports the
|
|
252
|
+
* footprint as differing from its library copy). For a footprint turned by a
|
|
253
|
+
* right angle the pad gets the footprint's angle and its own size back.
|
|
254
|
+
*/
|
|
255
|
+
function normalisePads(fp: List) {
|
|
256
|
+
const at = child(fp, "at");
|
|
257
|
+
const rot = (((num(at?.items[3]) % 360) + 360) % 360);
|
|
258
|
+
if (!rot || rot % 90) return;
|
|
259
|
+
for (const p of children(fp, "pad")) {
|
|
260
|
+
if (child(p, "primitives")) continue;
|
|
261
|
+
const pat = child(p, "at");
|
|
262
|
+
if (!pat || num(pat.items[3])) continue;
|
|
263
|
+
if (rot % 180) {
|
|
264
|
+
const size = child(p, "size");
|
|
265
|
+
if (size) [size.items[1], size.items[2]] = [size.items[2], size.items[1]];
|
|
266
|
+
const drill = child(p, "drill");
|
|
267
|
+
if (drill && text(drill.items[1]) === "oval" && drill.items.length >= 4) [drill.items[2], drill.items[3]] = [drill.items[3], drill.items[2]];
|
|
268
|
+
}
|
|
269
|
+
pat.items[3] = fmt(rot);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* The file a part body is copied to under `3d/` (PB-1027): each source file
|
|
275
|
+
* gets its own name. Bodies are often all called `body.step` (a project's
|
|
276
|
+
* parts/<id>/cad/body.step), so the name is the part id and the file's name;
|
|
277
|
+
* a name already taken by another file gets the source path's digest too.
|
|
278
|
+
* `taken` maps the names given so far to their sources.
|
|
279
|
+
*/
|
|
280
|
+
export function modelFileName(src: string, part: string | undefined, taken: Map<string, string>): string {
|
|
281
|
+
for (const [name, from] of taken) if (from === src) return name;
|
|
282
|
+
const base = libItemName(basename(src));
|
|
283
|
+
const stem = part && !base.startsWith(`${libItemName(part)}-`) && base !== `${libItemName(part)}.step` ? `${libItemName(part)}-${base}` : base;
|
|
284
|
+
let name = `3d/${stem}`;
|
|
285
|
+
if (taken.has(name)) {
|
|
286
|
+
const dot = stem.lastIndexOf(".");
|
|
287
|
+
const tag = createHash("sha256").update(src).digest("hex").slice(0, 8);
|
|
288
|
+
name = `3d/${dot > 0 ? `${stem.slice(0, dot)}-${tag}${stem.slice(dot)}` : `${stem}-${tag}`}`;
|
|
289
|
+
}
|
|
290
|
+
taken.set(name, src);
|
|
291
|
+
return name;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** A footprint with nothing in it: no pad, no drawing, no model (the converter's "Unknown" for a pcb_component with no source). */
|
|
295
|
+
export function emptyFootprint(fp: List): boolean {
|
|
296
|
+
return !fp.items.some((c) => isList(c) && /^(pad|fp_line|fp_rect|fp_circle|fp_arc|fp_poly|fp_curve|model|zone)$/.test(head(c) ?? ""));
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** A keep-out's corners in KiCad's frame (origin KICAD_ORIGIN, Y down), as a bounding box. */
|
|
300
|
+
function keepoutBox(k: Keepout): [number, number, number, number] {
|
|
301
|
+
const [cx, cy, w, h] = "rect" in k.shape ? [k.shape.rect.at[0], k.shape.rect.at[1], k.shape.rect.size[0], k.shape.rect.size[1]] : [k.shape.circle.at[0], k.shape.circle.at[1], k.shape.circle.diameter_mm, k.shape.circle.diameter_mm];
|
|
302
|
+
return [KICAD_ORIGIN[0] + cx - w / 2, KICAD_ORIGIN[1] - cy - h / 2, KICAD_ORIGIN[0] + cx + w / 2, KICAD_ORIGIN[1] - cy + h / 2];
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Keep-outs that let some components' bodies over them (`allow_bodies`: the
|
|
307
|
+
* module whose own antenna the keep-out clears) in KiCad (PB-1027, F23).
|
|
308
|
+
* KiCad's keep-out areas forbid every footprint or none, so each such zone is
|
|
309
|
+
* named after its keep-out and allows footprints, and a custom rule in the
|
|
310
|
+
* project's `<b>.kicad_dru` forbids every footprint but the allowed ones
|
|
311
|
+
* there. Tracks, vias, pads and pours stay forbidden. Returns the rules file
|
|
312
|
+
* (undefined when no keep-out has exceptions).
|
|
313
|
+
*/
|
|
314
|
+
export function keepoutExceptions(pcb: List, keepouts: Keepout[], refdes: Record<string, string>): { rules?: string; notes: string[] } {
|
|
315
|
+
const notes: string[] = [];
|
|
316
|
+
const rules: string[] = [];
|
|
317
|
+
const zones = children(pcb, "zone").filter((z) => child(z, "keepout"));
|
|
318
|
+
for (const k of keepouts) {
|
|
319
|
+
if (!k.allow_bodies?.length) continue;
|
|
320
|
+
const [x0, y0, x1, y1] = keepoutBox(k);
|
|
321
|
+
const near = (a: number, b: number) => Math.abs(a - b) < 0.02;
|
|
322
|
+
const mine = zones.filter((z) => {
|
|
323
|
+
const pts = children(child(child(z, "polygon") ?? list("polygon"), "pts") ?? list("pts"), "xy").map((p) => [num(p.items[1]), num(p.items[2])] as const);
|
|
324
|
+
if (!pts.length) return false;
|
|
325
|
+
const xs = pts.map((p) => p[0]);
|
|
326
|
+
const ys = pts.map((p) => p[1]);
|
|
327
|
+
return near(Math.min(...xs), x0) && near(Math.max(...xs), x1) && near(Math.min(...ys), y0) && near(Math.max(...ys), y1);
|
|
328
|
+
});
|
|
329
|
+
if (!mine.length) {
|
|
330
|
+
notes.push(`keep-out ${k.name}: no KiCad keep-out area was found for it, so its exception for ${k.allow_bodies.join(", ")} is not in KiCad`);
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
const name = libItemName(k.name);
|
|
334
|
+
for (const z of mine) {
|
|
335
|
+
const at = z.items.findIndex((c) => head(c) === "name");
|
|
336
|
+
if (at >= 0) z.items[at] = list("name", str(name));
|
|
337
|
+
else z.items.splice(z.items.findIndex((c) => head(c) === "uuid") + 1 || 1, 0, list("name", str(name)));
|
|
338
|
+
const fps = child(child(z, "keepout")!, "footprints");
|
|
339
|
+
if (fps) fps.items[1] = "allowed";
|
|
340
|
+
}
|
|
341
|
+
const refs = k.allow_bodies.map((i) => refdes[i] ?? i);
|
|
342
|
+
rules.push(`\t(rule "keepout ${name}: no footprint but ${refs.join(", ")}"\n\t\t(constraint disallow footprint)\n\t\t(condition "A.intersectsArea('${name}')${refs.map((r) => ` && A.Reference != '${r}'`).join("")}"))`);
|
|
343
|
+
}
|
|
344
|
+
return { ...(rules.length ? { rules: rules.join("\n") } : {}), notes };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* The project's custom DRC rules (`<b>.kicad_dru`): the config's via size as a
|
|
349
|
+
* minimum for every via (PB-1027, F31: KiCad's net class via size is what new
|
|
350
|
+
* vias get, not a limit, so a via made smaller by hand passed), and the
|
|
351
|
+
* keep-out exceptions (keepoutExceptions).
|
|
352
|
+
*/
|
|
353
|
+
export function customRules(constraints: BoardConstraints | undefined, keepoutRules?: string): string | undefined {
|
|
354
|
+
const r = constraints?.rules;
|
|
355
|
+
const lines: string[] = [];
|
|
356
|
+
if (r) lines.push(`\t(rule "via size (circuit.config.ts rules)"\n\t\t(condition "A.Type == 'Via'")\n\t\t(constraint via_diameter (min ${r.via_pad_mm}mm))\n\t\t(constraint hole_size (min ${r.via_drill_mm}mm)))`);
|
|
357
|
+
if (keepoutRules) lines.push(keepoutRules);
|
|
358
|
+
if (!lines.length) return undefined;
|
|
359
|
+
return `(version 1)\n# Written by @protoboardai/ext-tscircuit from circuit.config.ts: the via rules as minimums, and keep-outs that\n# let named bodies over them (allow_bodies; their KiCad areas allow footprints and a rule forbids all others).\n${lines.join("\n")}\n`;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export function kicadProject(input: KicadProjectInput): KicadProject {
|
|
363
|
+
const { board } = input;
|
|
364
|
+
const lib = libraryName(board);
|
|
365
|
+
const notes: string[] = [];
|
|
366
|
+
const { cj, nameOf } = prepare(input);
|
|
367
|
+
const instanceOf = (ref: string) => {
|
|
368
|
+
const name = nameOf.get(ref);
|
|
369
|
+
return name ? input.map.components[name] : undefined;
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
const pcbConv = new CircuitJsonToKicadPcbConverter(cj as never, { projectName: board });
|
|
373
|
+
pcbConv.runUntilFinished();
|
|
374
|
+
const proConv = new CircuitJsonToKicadProConverter(cj as never, { projectName: board, schematicFilename: `${board}.kicad_sch`, pcbFilename: `${board}.kicad_pcb` });
|
|
375
|
+
proConv.runUntilFinished();
|
|
376
|
+
const pcb = parse(pcbConv.getOutputString());
|
|
377
|
+
|
|
378
|
+
const keep = new Set<string>(); // UUIDs derived on purpose: not renumbered below
|
|
379
|
+
const symbols: Record<string, string> = {};
|
|
380
|
+
for (const c of input.netlist.components) {
|
|
381
|
+
symbols[c.instance] = stableUuid(board, `symbol:${c.instance}`);
|
|
382
|
+
keep.add(symbols[c.instance]);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const fpByInstance = new Map<string, string>();
|
|
386
|
+
for (const m of Object.values(input.map.components)) fpByInstance.set(m.instance, `${lib}:${libItemName(m.footprint)}`);
|
|
387
|
+
const footprintOf = (inst: string) => fpByInstance.get(inst) ?? `${lib}:${libItemName(inst)}`;
|
|
388
|
+
// the footprint carries the symbol's description (the part's name) and datasheet
|
|
389
|
+
const symbolText = new Map<string, Record<string, string>>(input.netlist.components.map((c) => [c.instance, { Description: c.name, Datasheet: "" }]));
|
|
390
|
+
|
|
391
|
+
// ---- board: footprints, fields, models, links to the symbols, layers, thickness, origin
|
|
392
|
+
const models: Record<string, string> = {};
|
|
393
|
+
const modelNames = new Map<string, string>(); // 3d/<file> → source
|
|
394
|
+
const library = new Map<string, List>();
|
|
395
|
+
let empty = 0;
|
|
396
|
+
const padNets = new Map<string, Map<string, string>>(); // refdes → pad → net
|
|
397
|
+
let holes = 0;
|
|
398
|
+
const dropped = new Set<string>();
|
|
399
|
+
const unnetted: { ref: string; fp: List }[] = [];
|
|
400
|
+
for (const fp of children(pcb, "footprint")) {
|
|
401
|
+
const refNode = referenceNode(fp);
|
|
402
|
+
let ref = text(refNode?.items[2]) ?? "";
|
|
403
|
+
// a footprint with nothing in it and no component (the converter's "Unknown" for a pcb_component without a
|
|
404
|
+
// source component): left out, not written as an empty "MountingHole" (PB-1027, F21)
|
|
405
|
+
if (!instanceOf(ref) && emptyFootprint(fp)) {
|
|
406
|
+
pcb.items = pcb.items.filter((c) => c !== fp);
|
|
407
|
+
empty++;
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
// a footprint with no component (a mounting hole): a designator, and nothing in the schematic, BOM or placement
|
|
411
|
+
if (!ref) {
|
|
412
|
+
ref = `H${++holes}`;
|
|
413
|
+
if (refNode) refNode.items[2] = str(ref);
|
|
414
|
+
else {
|
|
415
|
+
setProperty(fp, "Reference", ref, "F.Fab");
|
|
416
|
+
setProperty(fp, "Value", "MountingHole", "F.Fab");
|
|
417
|
+
}
|
|
418
|
+
setAttr(fp, ["board_only", "exclude_from_pos_files", "exclude_from_bom"]);
|
|
419
|
+
}
|
|
420
|
+
normalisePads(fp);
|
|
421
|
+
const m = instanceOf(ref);
|
|
422
|
+
const entry = m ? libItemName(m.footprint) : libItemName((text(fp.items[1]) ?? "footprint").replace(/^[^:]*:/, ""));
|
|
423
|
+
fp.items[1] = str(`${lib}:${entry}`);
|
|
424
|
+
if (m) {
|
|
425
|
+
const f = input.fields[m.instance];
|
|
426
|
+
if (f) {
|
|
427
|
+
setProperty(fp, "Value", f.Value);
|
|
428
|
+
for (const [k, v] of fieldsOf(f)) setProperty(fp, k, v, "F.Fab");
|
|
429
|
+
}
|
|
430
|
+
for (const [k, v] of Object.entries(symbolText.get(m.instance) ?? {})) setProperty(fp, k, v, "F.Fab");
|
|
431
|
+
const pads = children(fp, "pad");
|
|
432
|
+
setAttr(fp, [pads.length && pads.every((p) => text(p.items[2]) === "smd") ? "smd" : "through_hole"]);
|
|
433
|
+
const nets = new Map<string, string>();
|
|
434
|
+
for (const p of pads) {
|
|
435
|
+
const net = child(p, "net");
|
|
436
|
+
const name = net ? text(net.items[net.items.length - 1]) : undefined;
|
|
437
|
+
if (name && text(p.items[1]) && !nets.has(text(p.items[1])!)) nets.set(text(p.items[1])!, name);
|
|
438
|
+
}
|
|
439
|
+
padNets.set(ref, nets);
|
|
440
|
+
unnetted.push({ ref, fp });
|
|
441
|
+
const uuid = child(fp, "uuid");
|
|
442
|
+
const fpUuid = stableUuid(board, `footprint:${m.instance}`);
|
|
443
|
+
keep.add(fpUuid);
|
|
444
|
+
if (uuid) uuid.items[1] = fpUuid;
|
|
445
|
+
// the symbol this footprint belongs to (root sheet)
|
|
446
|
+
const at = fp.items.findIndex((c) => head(c) === "at");
|
|
447
|
+
fp.items.splice(at + 1, 0, list("path", str(`/${symbols[m.instance]}`)), list("sheetname", str("/")), list("sheetfile", str(`${board}.kicad_sch`)));
|
|
448
|
+
}
|
|
449
|
+
for (const model of children(fp, "model")) {
|
|
450
|
+
const raw = text(model.items[1]) ?? "";
|
|
451
|
+
if (!raw || raw.startsWith("${KIPRJMOD}")) continue;
|
|
452
|
+
const src = isAbsolute(raw) || !input.modelBase ? raw : resolve(input.modelBase, raw);
|
|
453
|
+
if (input.modelOk && !input.modelOk(src)) {
|
|
454
|
+
fp.items = fp.items.filter((c) => c !== model);
|
|
455
|
+
dropped.add(`${ref} (${basename(src)})`);
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
const file = modelFileName(src, m ? input.fields[m.instance]?.UHD_Part : undefined, modelNames);
|
|
459
|
+
models[file] = src;
|
|
460
|
+
model.items[1] = str(`\${KIPRJMOD}/${file}`);
|
|
461
|
+
}
|
|
462
|
+
// the library's copy: an unturned top-side instance when there is one, else a top-side one
|
|
463
|
+
const rank = (f: List) => (text(child(f, "layer")?.items[1]) === "B.Cu" ? 2 : num(child(f, "at")?.items[3]) % 360 ? 1 : 0);
|
|
464
|
+
const prev = library.get(entry);
|
|
465
|
+
if (!prev || rank(fp) < rank(prev)) library.set(entry, fp);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// ---- one KiCad group per sub-module, named by its path and nested as the sub-modules nest (docs/specs/nested-boards.md §8)
|
|
469
|
+
const placed = new Set(children(pcb, "footprint").map((fp) => instanceOf(text(referenceNode(fp)?.items[2]) ?? "")?.instance).filter(Boolean));
|
|
470
|
+
const made = new Set<string>();
|
|
471
|
+
const groupNodes: List[] = [];
|
|
472
|
+
// innermost first, so a group lists only the sub-groups that exist
|
|
473
|
+
for (const g of [...(input.netlist.groups ?? [])].reverse()) {
|
|
474
|
+
const members = [
|
|
475
|
+
...input.netlist.components.filter((c) => c.group === g.path && placed.has(c.instance)).map((c) => stableUuid(board, `footprint:${c.instance}`)),
|
|
476
|
+
...(input.netlist.groups ?? []).filter((x) => x.parent === g.path && made.has(x.path)).map((x) => stableUuid(board, `group:${x.path}`)),
|
|
477
|
+
];
|
|
478
|
+
if (!members.length) continue;
|
|
479
|
+
made.add(g.path);
|
|
480
|
+
const uuid = stableUuid(board, `group:${g.path}`);
|
|
481
|
+
keep.add(uuid);
|
|
482
|
+
groupNodes.unshift(list("group", str(g.path), list("uuid", uuid), list("members", ...members)));
|
|
483
|
+
}
|
|
484
|
+
pcb.items.push(...groupNodes);
|
|
485
|
+
|
|
486
|
+
const general = child(pcb, "general");
|
|
487
|
+
const thickness = input.constraints?.thickness_mm;
|
|
488
|
+
if (general && thickness) {
|
|
489
|
+
const t = child(general, "thickness");
|
|
490
|
+
if (t) t.items[1] = String(thickness);
|
|
491
|
+
else general.items.push(list("thickness", String(thickness)));
|
|
492
|
+
}
|
|
493
|
+
const want = input.constraints?.layers;
|
|
494
|
+
const layersNode = child(pcb, "layers");
|
|
495
|
+
if (layersNode && want === 2) {
|
|
496
|
+
const before = layersNode.items.length;
|
|
497
|
+
layersNode.items = layersNode.items.filter((l) => !(isList(l) && /^In\d+\.Cu$/.test(text(l.items[1]) ?? "")));
|
|
498
|
+
if (layersNode.items.length !== before) notes.push("inner copper layers removed: the config states 2 layers");
|
|
499
|
+
}
|
|
500
|
+
const setup = child(pcb, "setup") ?? (pcb.items.splice(pcb.items.findIndex((c) => head(c) === "layers") + 1, 0, list("setup")), child(pcb, "setup")!);
|
|
501
|
+
setup.items = setup.items.filter((c) => !["stackup", "aux_axis_origin", "grid_origin"].includes(head(c) ?? ""));
|
|
502
|
+
const copperLayers = layersNode ? layersNode.items.filter((l) => isList(l) && /\.Cu$/.test(text(l.items[1]) ?? "")).length : 2;
|
|
503
|
+
setup.items.splice(1, 0, stackup(copperLayers >= 4 ? 4 : 2, thickness ?? 1.6, input.copperOz ?? 1));
|
|
504
|
+
setup.items.push(list("aux_axis_origin", String(KICAD_ORIGIN[0]), String(KICAD_ORIGIN[1])), list("grid_origin", String(KICAD_ORIGIN[0]), String(KICAD_ORIGIN[1])));
|
|
505
|
+
|
|
506
|
+
if (empty) notes.push(`${empty} empty footprint(s) with no component left out (the converter writes one for a tscircuit pcb_component with no source component)`);
|
|
507
|
+
// ---- keep-outs whose exceptions KiCad's areas cannot hold: a custom rule (PB-1027, F23)
|
|
508
|
+
const exceptions = keepoutExceptions(pcb, input.constraints?.keepouts ?? [], input.refdes);
|
|
509
|
+
notes.push(...exceptions.notes);
|
|
510
|
+
if (dropped.size) notes.push(`${dropped.size} footprint(s) have no 3D model: their body file is missing or not a STEP file (${[...dropped].slice(0, 4).join(", ")}${dropped.size > 4 ? ", …" : ""})`);
|
|
511
|
+
|
|
512
|
+
// ---- pads without a net: a pad numbered like a netted one is the same pin (several lands of one
|
|
513
|
+
// pin: a module's ground pads, a receptacle's shield) and takes its net; a pin on no net takes
|
|
514
|
+
// KiCad's own "unconnected-(…)" net, as Update PCB from Schematic would give it
|
|
515
|
+
// ---- the schematic (schematic.ts): written from the model and the board's pad nets, not converted
|
|
516
|
+
const pads = new Map<string, Map<string, string>>();
|
|
517
|
+
for (const m of Object.values(input.map.components)) {
|
|
518
|
+
const byPad = new Map<string, string>();
|
|
519
|
+
for (const [port, iface] of Object.entries(m.ports)) if (iface) byPad.set(port.replace(/^pin/, ""), iface);
|
|
520
|
+
pads.set(m.instance, byPad);
|
|
521
|
+
}
|
|
522
|
+
const schematic = buildSchematic({
|
|
523
|
+
board,
|
|
524
|
+
lib,
|
|
525
|
+
netlist: input.netlist,
|
|
526
|
+
pads,
|
|
527
|
+
refdes: input.refdes,
|
|
528
|
+
fields: Object.fromEntries(Object.entries(input.fields).map(([inst, f]) => [inst, [["Value", f.Value] as [string, string], ...fieldsOf(f)]])),
|
|
529
|
+
footprint: footprintOf,
|
|
530
|
+
padNets,
|
|
531
|
+
pinTypes: input.pinTypes,
|
|
532
|
+
symbolUuid: symbols,
|
|
533
|
+
uuid: (key) => stableUuid(board, `sch:${key}`),
|
|
534
|
+
});
|
|
535
|
+
const sch = schematic.sch;
|
|
536
|
+
const libSymbols = schematic.libSymbols;
|
|
537
|
+
const refOf = new Map(Object.entries(input.refdes).map(([i, r]) => [r, i]));
|
|
538
|
+
const pinNames = schematic.pinNames;
|
|
539
|
+
const symbolOf = new Map([...refOf].map(([r, i]) => [r, schematic.symbolOf[i] ?? ""]));
|
|
540
|
+
const header = children(pcb, "net");
|
|
541
|
+
const netNumber = new Map(header.map((n) => [text(n.items[2]) ?? "", text(n.items[1]) ?? ""]));
|
|
542
|
+
let next = Math.max(0, ...header.map((n) => Number(text(n.items[1]) ?? 0))) + 1;
|
|
543
|
+
const numbered = header.length > 0 && /^\d+$/.test(text(header[0].items[1]) ?? "");
|
|
544
|
+
const joined: string[] = [];
|
|
545
|
+
const added: List[] = [];
|
|
546
|
+
for (const { ref, fp } of unnetted) {
|
|
547
|
+
const nets = padNets.get(ref)!;
|
|
548
|
+
for (const p of children(fp, "pad")) {
|
|
549
|
+
const n = text(p.items[1]) ?? "";
|
|
550
|
+
if (!n || child(p, "net")) continue;
|
|
551
|
+
let name = nets.get(n);
|
|
552
|
+
if (name) joined.push(`${ref}.${n}`);
|
|
553
|
+
else {
|
|
554
|
+
const pin = pinNames.get(symbolOf.get(ref) ?? "")?.get(n);
|
|
555
|
+
if (pin === undefined) continue; // a mechanical pad with no pin
|
|
556
|
+
name = `unconnected-(${ref}${pin && pin !== "~" ? `-${pin}` : ""}-Pad${n})`;
|
|
557
|
+
}
|
|
558
|
+
if (numbered && !netNumber.has(name)) {
|
|
559
|
+
netNumber.set(name, String(next));
|
|
560
|
+
added.push(list("net", String(next++), str(name)));
|
|
561
|
+
}
|
|
562
|
+
const net = numbered ? list("net", netNumber.get(name)!, str(name)) : list("net", str(name));
|
|
563
|
+
const after = p.items.findIndex((c) => head(c) === "layers");
|
|
564
|
+
p.items.splice(after >= 0 ? after + 1 : p.items.length, 0, net);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
if (added.length) pcb.items.splice(pcb.items.indexOf(header[header.length - 1]) + 1, 0, ...added);
|
|
568
|
+
if (joined.length) notes.push(`${joined.length} pad(s) sharing a pin number with a netted pad were given its net (${joined.slice(0, 4).join(", ")}${joined.length > 4 ? ", …" : ""}); tscircuit had left them off the net, so KiCad's DRC may report them unrouted`);
|
|
569
|
+
|
|
570
|
+
// ---- the project's libraries
|
|
571
|
+
const files: Record<string, string> = {};
|
|
572
|
+
for (const [entry, fp] of [...library.entries()].sort(([a], [b]) => a.localeCompare(b))) files[`${lib}.pretty/${entry}.kicad_mod`] = printDocument(libraryFootprint(fp, entry));
|
|
573
|
+
files[`${lib}.kicad_sym`] = symbolLibrary(libSymbols);
|
|
574
|
+
const dru = customRules(input.constraints, exceptions.rules);
|
|
575
|
+
if (dru) files[`${board}.kicad_dru`] = dru;
|
|
576
|
+
files["fp-lib-table"] = `(fp_lib_table\n\t(version 7)\n\t(lib (name "${lib}") (type "KiCad") (uri "\${KIPRJMOD}/${lib}.pretty") (options "") (descr "${board}: footprints exported by @protoboardai/ext-tscircuit"))\n)\n`;
|
|
577
|
+
files["sym-lib-table"] = `(sym_lib_table\n\t(version 7)\n\t(lib (name "${lib}") (type "KiCad") (uri "\${KIPRJMOD}/${lib}.kicad_sym") (options "") (descr "${board}: symbols exported by @protoboardai/ext-tscircuit"))\n)\n`;
|
|
578
|
+
|
|
579
|
+
// ---- stable UUIDs everywhere else: renumbered in document order (schematic, board, project)
|
|
580
|
+
const [schText, pcbText, proText] = stabiliseUuids(board, [printDocument(sch), printDocument(pcb), proConv.getOutputString()], keep);
|
|
581
|
+
const pro = JSON.parse(proText) as Record<string, unknown> & { head?: Record<string, unknown>; meta?: Record<string, unknown>; net_settings?: { classes?: Record<string, unknown>[] } };
|
|
582
|
+
// a project file without dates: the same board exports the same file
|
|
583
|
+
if (pro.head) for (const k of ["created", "modified"]) delete pro.head[k];
|
|
584
|
+
const rules = input.constraints?.rules;
|
|
585
|
+
if (rules && pro.net_settings?.classes?.[0]) Object.assign(pro.net_settings.classes[0], { track_width: rules.trace_width_mm, via_diameter: rules.via_pad_mm, via_drill: rules.via_drill_mm });
|
|
586
|
+
// the config's edge clearance is the board's rule (KiCad's default is 0.5 mm)
|
|
587
|
+
const board_ = (pro as { board?: { design_settings?: { rules?: Record<string, number> } } }).board;
|
|
588
|
+
if (rules && board_?.design_settings) board_.design_settings.rules = { ...(board_.design_settings.rules ?? {}), min_copper_edge_clearance: rules.edge_clearance_mm };
|
|
589
|
+
files[`${board}.kicad_sch`] = schText;
|
|
590
|
+
files[`${board}.kicad_pcb`] = pcbText;
|
|
591
|
+
files[`${board}.kicad_pro`] = JSON.stringify(pro, null, 2) + "\n";
|
|
592
|
+
void print;
|
|
593
|
+
return { files, models, symbols, keep, notes };
|
|
594
|
+
}
|