@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/stepfile.ts
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Small ISO 10303-21 (STEP) helpers for the 3D outputs: read the DATA section
|
|
3
|
+
* into entities, and drop named sub-shapes from a part's body file.
|
|
4
|
+
*
|
|
5
|
+
* UHD CAD bodies carry "body plus one named sub-shape per interface feature"
|
|
6
|
+
* (UHD src/authoring/cad.ts, cad_step): thin marker solids, 0.25 mm thick,
|
|
7
|
+
* on the faces an interface binds to (for an SMD part, its seating-plane pads,
|
|
8
|
+
* extruded downwards into the board). They identify features for binding;
|
|
9
|
+
* they are not part of the body. circuit-json-to-step and the GLB converter
|
|
10
|
+
* both take every solid in the file, so the provider strips the feature
|
|
11
|
+
* shapes before handing a part body to them.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** The DATA section as entity id → body (the text after `=`), in file order. */
|
|
15
|
+
export function stepEntities(text: string): Map<number, string> {
|
|
16
|
+
const out = new Map<number, string>();
|
|
17
|
+
const data = text.indexOf("DATA;");
|
|
18
|
+
if (data < 0) return out;
|
|
19
|
+
let chunk = "";
|
|
20
|
+
let quoted = false;
|
|
21
|
+
for (let i = data + 5; i < text.length; i++) {
|
|
22
|
+
const ch = text[i];
|
|
23
|
+
if (ch === "'") quoted = !quoted; // '' inside a string toggles twice
|
|
24
|
+
if (ch !== ";" || quoted) {
|
|
25
|
+
chunk += ch;
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
const s = chunk.trim();
|
|
29
|
+
chunk = "";
|
|
30
|
+
if (s === "ENDSEC") break;
|
|
31
|
+
const m = /^#(\d+)\s*=\s*([\s\S]*)$/.exec(s);
|
|
32
|
+
if (m) out.set(Number(m[1]), m[2]);
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const refs = (body: string) => [...body.matchAll(/#(\d+)/g)].map((m) => Number(m[1]));
|
|
38
|
+
const typeOf = (body: string) => /^\s*([A-Z0-9_]+)\s*\(/.exec(body)?.[1] ?? "";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* `body` without its reference to `id` when that reference is one element of
|
|
42
|
+
* a list holding others (`(#1,#2)` → `(#2)`); undefined when the entity
|
|
43
|
+
* cannot do without it (a direct parameter, or the only element of a list).
|
|
44
|
+
*/
|
|
45
|
+
function dropReference(body: string, id: number): string | undefined {
|
|
46
|
+
let depth = 0;
|
|
47
|
+
let quoted = false;
|
|
48
|
+
let out = body;
|
|
49
|
+
const re = new RegExp(`#${id}(?!\\d)`, "g");
|
|
50
|
+
// positions of the reference, outside strings, with the depth of the parentheses around it
|
|
51
|
+
const hits: { at: number; depth: number }[] = [];
|
|
52
|
+
for (let i = 0; i < body.length; i++) {
|
|
53
|
+
const ch = body[i];
|
|
54
|
+
if (ch === "'") quoted = !quoted;
|
|
55
|
+
if (quoted) continue;
|
|
56
|
+
if (ch === "(") depth++;
|
|
57
|
+
else if (ch === ")") depth--;
|
|
58
|
+
else if (ch === "#") {
|
|
59
|
+
re.lastIndex = i;
|
|
60
|
+
const m = re.exec(body);
|
|
61
|
+
if (m && m.index === i) hits.push({ at: i, depth });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (!hits.length) return body;
|
|
65
|
+
if (typeOf(body) === "") return undefined; // a complex entity: never edited
|
|
66
|
+
for (const h of hits.reverse()) {
|
|
67
|
+
if (h.depth < 2) return undefined;
|
|
68
|
+
const len = `#${id}`.length;
|
|
69
|
+
const before = out.slice(0, h.at).replace(/\s+$/, "");
|
|
70
|
+
const after = out.slice(h.at + len).replace(/^\s+/, "");
|
|
71
|
+
if (before.endsWith("(") && after.startsWith(")")) return undefined;
|
|
72
|
+
out = before.endsWith(",") ? before.slice(0, -1) + after : before + after.replace(/^,/, "");
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The STEP file without the solids of the products named in `names` (UHD
|
|
79
|
+
* interface feature shapes). Entities that referred to a removed one lose that
|
|
80
|
+
* list element, or go too when they cannot do without it. Returns the text
|
|
81
|
+
* unchanged when nothing matches.
|
|
82
|
+
*/
|
|
83
|
+
export function withoutShapes(text: string, names: Iterable<string>): { text: string; removed: string[] } {
|
|
84
|
+
const wanted = new Set(names);
|
|
85
|
+
const e = stepEntities(text);
|
|
86
|
+
const referrers = new Map<number, number[]>();
|
|
87
|
+
for (const [id, body] of e) for (const r of refs(body)) referrers.set(r, [...(referrers.get(r) ?? []), id]);
|
|
88
|
+
const using = (id: number, type: RegExp) => (referrers.get(id) ?? []).filter((r) => type.test(typeOf(e.get(r)!)));
|
|
89
|
+
|
|
90
|
+
// the named products' definitions, and every definition assembled under them
|
|
91
|
+
const usages = [...e].filter(([, b]) => typeOf(b) === "NEXT_ASSEMBLY_USAGE_OCCURRENCE").map(([, b]) => refs(b) as [number, number]);
|
|
92
|
+
const named = new Map<string, number[][]>();
|
|
93
|
+
for (const [id, body] of e) {
|
|
94
|
+
const m = /^PRODUCT\('((?:[^']|'')*)'/.exec(body);
|
|
95
|
+
if (!m || !wanted.has(m[1].replace(/''/g, "'"))) continue;
|
|
96
|
+
const pdefs = using(id, /^PRODUCT_DEFINITION_FORMATION/).flatMap((f) => using(f, /^PRODUCT_DEFINITION$/));
|
|
97
|
+
named.set(m[1], [...(named.get(m[1]) ?? []), pdefs]);
|
|
98
|
+
}
|
|
99
|
+
// a feature is a compound (an assembly of its pads); a body of the part that happens to share
|
|
100
|
+
// its name (a USB-C receptacle's `shell` solid beside its `shell` stake feature) stays
|
|
101
|
+
const assembly = (d: number) => usages.some(([p]) => p === d);
|
|
102
|
+
const removed: string[] = [];
|
|
103
|
+
const defs = new Set<number>();
|
|
104
|
+
for (const [name, products] of named) {
|
|
105
|
+
const compounds = products.filter((ds) => ds.some(assembly));
|
|
106
|
+
for (const ds of products.length > 1 && compounds.length ? compounds : products) for (const d of ds) defs.add(d);
|
|
107
|
+
removed.push(name);
|
|
108
|
+
}
|
|
109
|
+
for (let grew = true; grew; ) {
|
|
110
|
+
grew = false;
|
|
111
|
+
for (const [parent, child] of usages) {
|
|
112
|
+
// a child also used outside the named products stays
|
|
113
|
+
if (defs.has(parent) && !defs.has(child) && usages.every(([p, c]) => c !== child || defs.has(p))) defs.add(child), (grew = true);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
// their shape representations' items: the solids (placements stay)
|
|
117
|
+
const doomed = new Set<number>();
|
|
118
|
+
for (const d of defs) {
|
|
119
|
+
const reps = using(d, /^PRODUCT_DEFINITION_SHAPE$/)
|
|
120
|
+
.flatMap((s) => using(s, /^SHAPE_DEFINITION_REPRESENTATION$/))
|
|
121
|
+
.map((sdr) => refs(e.get(sdr)!)[1]);
|
|
122
|
+
for (const rep of reps) {
|
|
123
|
+
const items = refs((e.get(rep) ?? "").replace(/,\s*#\d+\s*\)\s*$/, ")"));
|
|
124
|
+
for (const item of items) if (typeOf(e.get(item) ?? "") !== "AXIS2_PLACEMENT_3D") doomed.add(item);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (!doomed.size) return { text, removed: [] };
|
|
128
|
+
|
|
129
|
+
const bodies = new Map(e);
|
|
130
|
+
const queue = [...doomed];
|
|
131
|
+
while (queue.length) {
|
|
132
|
+
const id = queue.pop()!;
|
|
133
|
+
if (!bodies.delete(id)) continue;
|
|
134
|
+
for (const r of referrers.get(id) ?? []) {
|
|
135
|
+
const body = bodies.get(r);
|
|
136
|
+
if (body === undefined) continue;
|
|
137
|
+
const kept = dropReference(body, id);
|
|
138
|
+
if (kept === undefined) queue.push(r);
|
|
139
|
+
else bodies.set(r, kept);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
// then the removed solids' own geometry, wherever nothing kept still uses it
|
|
143
|
+
const below = new Set<number>();
|
|
144
|
+
const walk = [...doomed];
|
|
145
|
+
while (walk.length) for (const r of refs(e.get(walk.pop()!) ?? "")) if (!below.has(r)) below.add(r), walk.push(r);
|
|
146
|
+
for (let changed = true; changed; ) {
|
|
147
|
+
changed = false;
|
|
148
|
+
for (const id of below) {
|
|
149
|
+
if (bodies.has(id) && (referrers.get(id) ?? []).every((r) => !bodies.has(r))) {
|
|
150
|
+
bodies.delete(id);
|
|
151
|
+
changed = true;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const head = text.slice(0, text.indexOf("DATA;"));
|
|
156
|
+
const data = [...bodies].map(([id, body]) => `#${id} = ${body};`).join("\n");
|
|
157
|
+
return { text: `${head}DATA;\n${data}\nENDSEC;\nEND-ISO-10303-21;\n`, removed };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* A STEP assembly moved along Z by `dz` (mm): the placement of each top-level
|
|
162
|
+
* occurrence in the root product's shape representation is shifted (a new
|
|
163
|
+
* point for each, so nothing shared moves twice). The root is the product
|
|
164
|
+
* definition no assembly usage names as a component. KiCad writes its board
|
|
165
|
+
* this way, the board body and every footprint's model placed in the root
|
|
166
|
+
* (`pcb export step`), with z = 0 on the board's bottom face; the stack's
|
|
167
|
+
* board frame has z = 0 on the mid-plane (layout.ts), so a locked board's
|
|
168
|
+
* board.step is moved down by half its thickness. Unchanged (`moved: 0`) when
|
|
169
|
+
* the file has no such root.
|
|
170
|
+
*/
|
|
171
|
+
export function shiftStepAssembly(text: string, dz: number): { text: string; moved: number } {
|
|
172
|
+
const ents = stepEntities(text);
|
|
173
|
+
const related = new Set<number>();
|
|
174
|
+
const base = new Set<number>();
|
|
175
|
+
for (const body of ents.values()) {
|
|
176
|
+
const n = /^NEXT_ASSEMBLY_USAGE_OCCURRENCE\(.*,#(\d+),#(\d+),[^,]*\)$/s.exec(body.trim());
|
|
177
|
+
if (n) related.add(Number(n[2]));
|
|
178
|
+
const t = /^ITEM_DEFINED_TRANSFORMATION\('[^']*','[^']*',#(\d+),#(\d+)\)$/.exec(body.trim());
|
|
179
|
+
if (t) base.add(Number(t[1]));
|
|
180
|
+
}
|
|
181
|
+
const root = [...ents].find(([id, b]) => typeOf(b) === "PRODUCT_DEFINITION" && !related.has(id))?.[0];
|
|
182
|
+
const pds = [...ents].find(([, b]) => typeOf(b) === "PRODUCT_DEFINITION_SHAPE" && refs(b).at(-1) === root)?.[0];
|
|
183
|
+
const sdr = [...ents.values()].find((b) => typeOf(b) === "SHAPE_DEFINITION_REPRESENTATION" && refs(b)[0] === pds);
|
|
184
|
+
const rep = sdr ? ents.get(refs(sdr)[1]) : undefined;
|
|
185
|
+
if (root === undefined || !rep) return { text, moved: 0 };
|
|
186
|
+
const items = /^[A-Z_]+\(\s*'[^']*'\s*,\s*\(([^)]*)\)/s.exec(rep.trim())?.[1] ?? "";
|
|
187
|
+
let next = Math.max(0, ...ents.keys()) + 1;
|
|
188
|
+
const added: string[] = [];
|
|
189
|
+
const rewrite = new Map<number, string>();
|
|
190
|
+
for (const id of refs(items)) {
|
|
191
|
+
const axis = ents.get(id);
|
|
192
|
+
if (base.has(id) || !axis || typeOf(axis) !== "AXIS2_PLACEMENT_3D") continue;
|
|
193
|
+
const m = /^AXIS2_PLACEMENT_3D\(('[^']*'),#(\d+),(.*)\)$/s.exec(axis.trim());
|
|
194
|
+
const p = m ? /^CARTESIAN_POINT\(('[^']*'),\(([^)]*)\)\)$/.exec(ents.get(Number(m[2]))?.trim() ?? "") : null;
|
|
195
|
+
if (!m || !p) continue;
|
|
196
|
+
const xyz = p[2].split(",").map(Number);
|
|
197
|
+
if (xyz.length !== 3 || xyz.some((v) => !Number.isFinite(v))) continue;
|
|
198
|
+
const real = (v: number) => {
|
|
199
|
+
const s = (Math.round(v * 1e6) / 1e6 || 0).toString();
|
|
200
|
+
return s.includes(".") || s.includes("e") ? s.replace("e", "E") : `${s}.`;
|
|
201
|
+
};
|
|
202
|
+
added.push(`#${next} = CARTESIAN_POINT(${p[1]},(${[xyz[0], xyz[1], xyz[2] + dz].map(real).join(",")}));`);
|
|
203
|
+
rewrite.set(id, `AXIS2_PLACEMENT_3D(${m[1]},#${next++},${m[3]})`);
|
|
204
|
+
}
|
|
205
|
+
if (!rewrite.size) return { text, moved: 0 };
|
|
206
|
+
const out = text.replace(/^#(\d+)\s*=\s*AXIS2_PLACEMENT_3D\([^;]*\);/gm, (line, id) => (rewrite.has(Number(id)) ? `#${id} = ${rewrite.get(Number(id))};` : line));
|
|
207
|
+
const data = out.indexOf("DATA;");
|
|
208
|
+
const end = out.indexOf("ENDSEC;", data);
|
|
209
|
+
return { text: out.slice(0, end) + added.join("\n") + "\n" + out.slice(end), moved: rewrite.size };
|
|
210
|
+
}
|
package/lib/suppliers.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Supplier part numbers for the board's components, from the sourcing data the
|
|
3
|
+
* project already holds (no network), read through core's one offer reader
|
|
4
|
+
* (`@protoboardai/core/offers`, PB-1033):
|
|
5
|
+
*
|
|
6
|
+
* parts/offers.json the library's offers by part id (`protoboard parts offers`)
|
|
7
|
+
* parts/evidence/<part>/sourcing.json the project's own sourcing record (ext-sourcing)
|
|
8
|
+
*
|
|
9
|
+
* A project part's own evidence comes first, a library part's library offers.
|
|
10
|
+
* An offer counts for a component when it names no ordering code or the
|
|
11
|
+
* component's own MPN (a series passive's offers are per value). Suppliers are
|
|
12
|
+
* read into tscircuit's keys; LCSC first (JLCPCB assembles from LCSC's
|
|
13
|
+
* C-numbers), then DigiKey and Mouser. They go into the generated source
|
|
14
|
+
* (`supplierPartNumbers`), the BOM, the pick-and-place and the KiCad fields,
|
|
15
|
+
* so a fab or a later KiCad export carries them.
|
|
16
|
+
*/
|
|
17
|
+
import type { BoardComponent } from "@protoboardai/core";
|
|
18
|
+
import { readProjectOffers, supplierNumbersOf, SUPPLIER_ORDER, type ProjectOffers, type SupplierNumbers } from "@protoboardai/core/offers";
|
|
19
|
+
|
|
20
|
+
export { supplierKey, type SupplierKey, type SupplierNumbers } from "@protoboardai/core/offers";
|
|
21
|
+
|
|
22
|
+
/** The offers a capability's project holds (its catalog folder says which parts are library parts). */
|
|
23
|
+
export const projectOffers = (project: { root: string; config: { catalog?: string } }): ProjectOffers => readProjectOffers(project.root, { catalog: project.config.catalog });
|
|
24
|
+
|
|
25
|
+
/** Supplier part numbers by instance (components with none are left out), LCSC first, each supplier's in precedence order. */
|
|
26
|
+
export function supplierNumbers(root: string, components: Pick<BoardComponent, "instance" | "part" | "mpn">[], offers: ProjectOffers = readProjectOffers(root)): Record<string, SupplierNumbers> {
|
|
27
|
+
const out: Record<string, SupplierNumbers> = {};
|
|
28
|
+
for (const c of components) {
|
|
29
|
+
const n = supplierNumbersOf(offers.forPart(c.part), c.mpn);
|
|
30
|
+
if (Object.keys(n).length) out[c.instance] = n;
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The BOM and PnP columns: the first LCSC number, and every number as `supplier:number`. */
|
|
36
|
+
export function supplierColumns(n: SupplierNumbers | undefined): { lcsc: string; suppliers: string } {
|
|
37
|
+
if (!n) return { lcsc: "", suppliers: "" };
|
|
38
|
+
return { lcsc: n.lcsc?.[0] ?? "", suppliers: SUPPLIER_ORDER.flatMap((k) => (n[k] ?? []).map((s) => `${k}:${s}`)).join(" ") };
|
|
39
|
+
}
|
package/ops/circuit.ts
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `circuit` capability with tscircuit (docs/specs/tool-integrations.md,
|
|
3
|
+
* docs/research/tscircuit-spike.md §9):
|
|
4
|
+
*
|
|
5
|
+
* generate UHD board → board netlist (core, tool-neutral) → tscircuit source
|
|
6
|
+
* in tscircuit/, then a local build and route → circuit.json
|
|
7
|
+
* export circuit.json (rebuilt first if the source changed) → standard outputs in out/
|
|
8
|
+
* extract circuit.json → frames for the board's exports and interfaces, artifacts
|
|
9
|
+
* check tscircuit DRC + drift between the built board and the UHD board (pure)
|
|
10
|
+
*/
|
|
11
|
+
import { boardNetlist, boardOptions, declaredArtifacts, type BoardNetlist, type CapabilityDecl, type CapabilityContext, type CapabilityOperationFn, type ExtractedFacts } from "@protoboardai/core";
|
|
12
|
+
import type { ArtifactDef, GeometryFrame } from "@deltarobotics/uhd";
|
|
13
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { checkBoard } from "../lib/checks.ts";
|
|
16
|
+
import { handPadJoins } from "../lib/layout.ts";
|
|
17
|
+
import { readConstraints, type BoardConstraints } from "../lib/config.ts";
|
|
18
|
+
import { boardDigest, boardProjection, EXPORT_SCHEMA, exportDrift, exportFile, exportInputs, fingerprintFormatChanged, readExportRecord, type ExportRecord } from "../lib/fingerprint.ts";
|
|
19
|
+
import { applyBodies, boxStep, cadModels, chooseBodies, withMeshes, type ComponentBody } from "../lib/models.ts";
|
|
20
|
+
import { projectOffers, supplierNumbers } from "../lib/suppliers.ts";
|
|
21
|
+
import { buildAndRecord, buildErrors, digestOf, files, inputsDigest, readLayout, readNative, readOverrides, sourceDigest, type Native } from "../lib/native.ts";
|
|
22
|
+
import { boardGlb, boardManifest, boardStep, bomCsv, fabricationFiles, laidOutNetlist, pnpCsv, renders, type PartEdgeFrame } from "../lib/outputs.ts";
|
|
23
|
+
import { ENTRY_SOURCE, generateSource } from "../lib/source.ts";
|
|
24
|
+
import { withoutShapes } from "../lib/stepfile.ts";
|
|
25
|
+
import { isLocked } from "../lib/kicad/handoff.ts";
|
|
26
|
+
import { checkLocked, exportLocked, lockedGenerate, refdesFor } from "./kicad.ts";
|
|
27
|
+
|
|
28
|
+
/** The circuit capability as the manifest declares it: its artifacts are the ones extract reports. */
|
|
29
|
+
const CIRCUIT_DECL = (JSON.parse(readFileSync(new URL("../protoboard-extension.json", import.meta.url), "utf8")) as { capabilities: CapabilityDecl[] }).capabilities.find((c) => c.id === "circuit")!;
|
|
30
|
+
|
|
31
|
+
const netlistOf = (ctx: CapabilityContext): { netlist: BoardNetlist; digest: string } => {
|
|
32
|
+
const netlist = boardNetlist(ctx.module.definition, ctx.project.lookup, boardOptions(ctx.project));
|
|
33
|
+
return { netlist, digest: digestOf(JSON.stringify(netlist)) };
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** The mating frame each board export's part states for the exported interface (part coordinates), with the part's mount origin. */
|
|
37
|
+
const edgeFrames = (ctx: CapabilityContext, netlist: BoardNetlist): Record<string, PartEdgeFrame> => {
|
|
38
|
+
const out: Record<string, PartEdgeFrame> = {};
|
|
39
|
+
for (const e of netlist.edge) {
|
|
40
|
+
const comp = netlist.components.find((c) => c.instance === e.instance);
|
|
41
|
+
const frame = ctx.project.lookup(comp?.part ?? "")?.interfaces.find((i) => i.id === e.interface)?.geometry?.frame;
|
|
42
|
+
if (!comp || !frame) continue;
|
|
43
|
+
const mount = comp.bodies.find((b) => b.mount)?.mount?.origin;
|
|
44
|
+
out[e.id] = { frame, ...(mount ? { mount: [mount[0], mount[1], mount[2]] as [number, number, number] } : {}) };
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** Digest of what the board depends on in UHD (fingerprint.ts boardProjection). */
|
|
50
|
+
const boardFingerprint = (ctx: CapabilityContext, netlist: BoardNetlist) =>
|
|
51
|
+
boardDigest(boardProjection(netlist, edgeFrames(ctx, netlist), Object.fromEntries(netlist.components.map((c) => [c.part, (ctx.project.lookup(c.part)?.interfaces ?? []).map((i) => i.id)]))));
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Write what the chosen bodies need in the native folder: a box STEP for each package box, and a
|
|
55
|
+
* copy of each part STEP without its interface feature shapes; point each body at its copy.
|
|
56
|
+
*/
|
|
57
|
+
function prepareBodies(ctx: CapabilityContext, bodies: ComponentBody[]): void {
|
|
58
|
+
const root = ctx.project.root;
|
|
59
|
+
for (const b of bodies) if (b.box && !existsSync(join(root, b.box.path))) ctx.writeOutput(b.box.path, boxStep(`box ${b.box.size.join(" x ")} mm`, b.box.size), "component body box from package facts");
|
|
60
|
+
const copies = new Map<string, boolean>();
|
|
61
|
+
for (const b of bodies) {
|
|
62
|
+
if (!b.step || !b.stripped || !b.strip?.length) continue;
|
|
63
|
+
if (!copies.has(b.stripped)) {
|
|
64
|
+
const { text, removed } = withoutShapes(readFileSync(b.step, "utf8"), b.strip);
|
|
65
|
+
if (removed.length) ctx.writeOutput(b.stripped, text, `${b.label} without its interface feature shapes (${removed.join(", ")})`);
|
|
66
|
+
copies.set(b.stripped, removed.length > 0);
|
|
67
|
+
}
|
|
68
|
+
if (copies.get(b.stripped)) b.step = join(root, b.stripped);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Generate's inputs besides the board: layout.json, footprints.json, the constraints and the supplier numbers (native.ts). */
|
|
73
|
+
const generateInputs = (ctx: CapabilityContext, netlist: BoardNetlist) => inputsDigest(ctx.project.root, ctx.module.native, ctx.constraints, supplierNumbers(ctx.project.root, netlist.components, projectOffers(ctx.project)));
|
|
74
|
+
|
|
75
|
+
const constraintsOf = (ctx: CapabilityContext, netlist: BoardNetlist) => readConstraints(ctx.constraints, netlist.envelope);
|
|
76
|
+
|
|
77
|
+
const failed = (summary: string, problems: string[]) => ({ status: "failed" as const, summary: `${summary}: ${problems[0]}${problems.length > 1 ? ` (+${problems.length - 1} more)` : ""}`, data: { problems } });
|
|
78
|
+
|
|
79
|
+
/** Errors that mean the router never ran: the layout needs work before anything else is meaningful. */
|
|
80
|
+
const blocking = (errors: { type: string; message: string }[]) => errors.filter((e) => e.type === "pcb_packing_error" || (e.type === "pcb_autorouting_error" && /skipped/.test(e.message)) || e.type === "pcb_missing_footprint_error");
|
|
81
|
+
|
|
82
|
+
export const generate: CapabilityOperationFn = async (ctx) => {
|
|
83
|
+
const locked = lockedGenerate(ctx); // KiCad is the layout of record (ops/kicad.ts)
|
|
84
|
+
if (locked) return locked;
|
|
85
|
+
const root = ctx.project.root;
|
|
86
|
+
const f = files(ctx.module.native);
|
|
87
|
+
const { netlist, digest } = netlistOf(ctx);
|
|
88
|
+
if (netlist.problems.length) return failed(`${ctx.module.id} cannot be read as a board`, netlist.problems);
|
|
89
|
+
for (const n of netlist.notes ?? []) ctx.log(`warning: ${n}`);
|
|
90
|
+
const { constraints, problems, notes } = constraintsOf(ctx, netlist);
|
|
91
|
+
if (!constraints) return failed("circuit.config.ts constraints", problems);
|
|
92
|
+
for (const n of notes) ctx.log(`warning: ${n}`);
|
|
93
|
+
const { overrides, problems: fp } = readOverrides(root, ctx.module.native);
|
|
94
|
+
const { layout, problems: lp } = readLayout(root, ctx.module.native);
|
|
95
|
+
if (fp.length || lp.length) return failed("provider files", [...fp, ...lp]);
|
|
96
|
+
|
|
97
|
+
// each part's body as a cadModel (relative paths), and its supplier part numbers (LCSC first)
|
|
98
|
+
const bodies = chooseBodies(ctx.project, netlist.components, ctx.module.native);
|
|
99
|
+
prepareBodies(ctx, bodies);
|
|
100
|
+
const suppliers = supplierNumbers(root, netlist.components, projectOffers(ctx.project));
|
|
101
|
+
const g = generateSource({ netlist, constraints, overrides, layout, suppliers, models: cadModels(root, ctx.module.native, bodies) });
|
|
102
|
+
if (g.problems.length) return failed("no source written", g.problems);
|
|
103
|
+
for (const n of g.notes) ctx.log(`warning: ${n}`);
|
|
104
|
+
ctx.writeOutput(f.gen, g.source, "generated tscircuit source (rewritten by generate)");
|
|
105
|
+
if (!existsSync(join(root, f.entry))) ctx.writeOutput(f.entry, ENTRY_SOURCE, "tscircuit entry (written once; hand edits go here)");
|
|
106
|
+
if (!existsSync(join(root, f.layout))) {
|
|
107
|
+
ctx.writeOutput(f.layout, JSON.stringify({ $comment: "Positions for parts circuit.config.ts does not fix: { <instance>: { at: [x, y], rotation_deg, side } } (mm, board frame). Parts not listed are placed by tscircuit." }, null, 2) + "\n", "layout tweaks (hand-owned)");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// designators recorded per instance from the first build (F07), so the silkscreen prints them (F14) and a later KiCad lock keeps them
|
|
111
|
+
const designators = refdesFor(ctx, netlist, true).assigned;
|
|
112
|
+
const built = await buildAndRecord(ctx, g.map, digest, generateInputs(ctx, netlist), designators);
|
|
113
|
+
// traces written by hand between pads of one pin: generate joins them itself now (PB-1027, F22)
|
|
114
|
+
for (const h of handPadJoins(built.circuitJson, g.map, g.source)) ctx.log(`warning: a trace written by hand (${h.trace}) joins pads of ${h.instance}:${h.pin}, which generate joins itself; remove it from ${f.entry} (in KiCad it becomes copper on no net)`);
|
|
115
|
+
const errors = buildErrors(built.circuitJson);
|
|
116
|
+
const stop = blocking(errors);
|
|
117
|
+
const placement = Object.values(g.map.components).reduce((n, c) => ({ ...n, [c.placement]: (n[c.placement] ?? 0) + 1 }), {} as Record<string, number>);
|
|
118
|
+
const n = (t: string) => built.circuitJson.filter((e) => e.type === t).length;
|
|
119
|
+
const summary = `${netlist.components.length} components (${Object.entries(placement).map(([k, v]) => `${v} ${k}`).join(", ")}), ${netlist.nets.length} nets → ${n("pcb_trace")} traces, ${n("pcb_via")} vias in ${(built.build.build_ms / 1000).toFixed(1)} s${constraints.routing === "off" ? " (routing: off, placement only)" : ""}; ${errors.length} tscircuit error(s)`;
|
|
120
|
+
const data = { components: netlist.components.length, nets: netlist.nets.length, ...(notes.length ? { notes } : {}), placement, traces: n("pcb_trace"), vias: n("pcb_via"), build_ms: built.build.build_ms, errors: errors.map((e) => `${e.type}: ${e.message}`) };
|
|
121
|
+
if (stop.length) {
|
|
122
|
+
return { status: "failed", summary: `${summary}. Not routed: ${stop[0].message}. Move parts in ${f.layout} (or fix them in circuit.config.ts) and generate again`, data };
|
|
123
|
+
}
|
|
124
|
+
return { status: "succeeded", summary, data };
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/** The current build, rebuilt first when the native source changed since it. */
|
|
128
|
+
async function current(ctx: CapabilityContext, netlistDigest: string): Promise<Native | undefined> {
|
|
129
|
+
const root = ctx.project.root;
|
|
130
|
+
const native = readNative(root, ctx.module.native);
|
|
131
|
+
if (!native) return undefined;
|
|
132
|
+
if (native.build.sourceDigest === sourceDigest(root, ctx.module.native, native.build.sourceFiles)) return native;
|
|
133
|
+
ctx.log(`native source changed since the last build: rebuilding`);
|
|
134
|
+
return buildAndRecord(ctx, native.build.map, native.build.netlistDigest ?? netlistDigest, native.build.inputsDigest ?? "", refdesFor(ctx, netlistOf(ctx).netlist, false).assigned);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export const exportOutputs: CapabilityOperationFn = async (ctx) => {
|
|
138
|
+
if (isLocked(ctx.project.root, ctx.module.dir)) return exportLocked(ctx); // from KiCad (ops/kicad.ts)
|
|
139
|
+
const root = ctx.project.root;
|
|
140
|
+
const out = ctx.module.out;
|
|
141
|
+
const { netlist, digest } = netlistOf(ctx);
|
|
142
|
+
const native = await current(ctx, digest);
|
|
143
|
+
if (!native) return { status: "failed", summary: `no built board in ${ctx.module.native}/; run circuit:generate first` };
|
|
144
|
+
const { constraints } = constraintsOf(ctx, netlist);
|
|
145
|
+
const { circuitJson: cj, build } = native;
|
|
146
|
+
const stale = build.inputsDigest !== generateInputs(ctx, netlist);
|
|
147
|
+
if (stale) ctx.log(`layout.json, footprints.json or the circuit.config.ts constraints changed since generate; these outputs show the last generated layout. Run circuit:generate`);
|
|
148
|
+
|
|
149
|
+
// bodies: the part's own STEP, else a box from its package facts
|
|
150
|
+
const bodies = chooseBodies(ctx.project, netlist.components, ctx.module.native);
|
|
151
|
+
// what this export reads, before the bodies are pointed at their stripped copies (fingerprint.ts)
|
|
152
|
+
const inputs = exportInputs(root, ctx.module.native, boardFingerprint(ctx, netlist), ctx.constraints, bodies);
|
|
153
|
+
prepareBodies(ctx, bodies);
|
|
154
|
+
const { circuitJson: withBodies, used, labels } = applyBodies(cj, build.map, bodies, root);
|
|
155
|
+
|
|
156
|
+
// every output is made before any is written: a converter that throws leaves the last export's out/ as it was
|
|
157
|
+
// (core stages an export's out/ and swaps it in only when the export succeeds)
|
|
158
|
+
const fabrication = fabricationFiles(cj);
|
|
159
|
+
const { manifest, geometry, notes } = boardManifest(cj, build.map, netlist, constraints, build.sourceDigest, edgeFrames(ctx, netlist));
|
|
160
|
+
const glb = await boardGlb(withMeshes(withBodies, build.map, bodies), build.map);
|
|
161
|
+
const step = await boardStep(withBodies, build.map, labels);
|
|
162
|
+
const svgs = renders(cj);
|
|
163
|
+
|
|
164
|
+
for (const [path, text] of Object.entries(fabrication)) ctx.writeOutput(`${out}/${path}`, text);
|
|
165
|
+
const suppliers = supplierNumbers(root, netlist.components, projectOffers(ctx.project));
|
|
166
|
+
ctx.writeOutput(`${out}/bom.csv`, bomCsv(netlist, suppliers), "bill of materials by UHD instance, with supplier part numbers");
|
|
167
|
+
ctx.writeOutput(`${out}/pnp.csv`, pnpCsv(netlist, geometry, suppliers), "pick-and-place by UHD instance");
|
|
168
|
+
ctx.writeOutput(`${out}/board.manifest.json`, JSON.stringify(manifest, null, 2) + "\n", "geometry manifest");
|
|
169
|
+
ctx.writeOutput(`${out}/netlist.json`, JSON.stringify(laidOutNetlist(cj, build.map, netlist), null, 2) + "\n", "nets as laid out");
|
|
170
|
+
ctx.writeOutput(`${out}/board.glb`, glb.glb, "board with component bodies (glTF binary, metres, Y up)");
|
|
171
|
+
ctx.writeOutput(`${out}/board.step`, step, "board with component bodies (STEP, mm, board frame)");
|
|
172
|
+
for (const [name, svg] of Object.entries(svgs)) ctx.writeOutput(`${out}/${name}`, svg, "render");
|
|
173
|
+
const record: ExportRecord = { schema: EXPORT_SCHEMA, inputs };
|
|
174
|
+
ctx.writeOutput(exportFile(ctx.module.native), JSON.stringify(record, null, 2) + "\n", "export record: the inputs these outputs were made from");
|
|
175
|
+
|
|
176
|
+
// bodies chosen for board.glb that its converter could not load (it drew a box instead): named, not counted as bodies
|
|
177
|
+
const failed = new Set(glb.failed);
|
|
178
|
+
// applyBodies points each cad_component at the body's STEP, or at `${root}/${box path}`
|
|
179
|
+
const unloaded = bodies.filter((b) => (used[b.instance] === "part" || used[b.instance] === "box") && (failed.has(b.step ?? `${root}/${b.box?.path}`) || (!!b.mesh && failed.has(b.mesh.path)))).map((b) => b.instance);
|
|
180
|
+
for (const f of glb.failed) ctx.log(`warning: board.glb: could not load the body ${f}; the converter drew a box in its place`);
|
|
181
|
+
const count = (s: string) => Object.entries(used).filter(([i, u]) => u === s && !unloaded.includes(i)).length;
|
|
182
|
+
const summary = `exported ${netlist.components.length} components: Gerber + drill, BOM, PnP, GLB/STEP (${count("part")} part bodies, ${count("box")} package boxes${count("tscircuit") ? `, ${count("tscircuit")} tscircuit bodies` : ""}${count("none") ? `, ${count("none")} without a body` : ""}${unloaded.length ? `; board.glb could not load ${unloaded.length} bodies and drew boxes for ${unloaded.slice(0, 5).join(", ")}${unloaded.length > 5 ? ", …" : ""}` : ""}), manifest, netlist, renders`;
|
|
183
|
+
const data = { bodies: used, ...(unloaded.length ? { bodiesNotLoaded: unloaded, filesNotLoaded: glb.failed } : {}), notes, ...(stale ? { stale: "generate inputs changed since the last generate" } : {}) };
|
|
184
|
+
return { status: "succeeded", summary: stale ? `${summary}. Stale: generate inputs changed since the last generate; run circuit:generate` : summary, data };
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
export const extract: CapabilityOperationFn = async (ctx) => {
|
|
188
|
+
const root = ctx.project.root;
|
|
189
|
+
const { netlist, digest } = netlistOf(ctx);
|
|
190
|
+
const native = await current(ctx, digest);
|
|
191
|
+
if (!native) return { status: "failed", summary: `no built board in ${ctx.module.native}/; run circuit:generate first` };
|
|
192
|
+
const { constraints } = constraintsOf(ctx, netlist);
|
|
193
|
+
const { manifest, geometry } = boardManifest(native.circuitJson, native.build.map, netlist, constraints, native.build.sourceDigest, edgeFrames(ctx, netlist));
|
|
194
|
+
const def = ctx.module.definition;
|
|
195
|
+
|
|
196
|
+
// frames for the board's own exports and interfaces, from the manifest features of the same id
|
|
197
|
+
const frames: Record<string, GeometryFrame> = {};
|
|
198
|
+
const ids = new Set([...(def.exports ?? []).map((e) => e.id), ...def.interfaces.map((i) => i.id)]);
|
|
199
|
+
for (const [id, feature] of Object.entries(manifest.features)) if (ids.has(id)) frames[id] = feature.frame;
|
|
200
|
+
// a mechanical mounting interface of the board binds to its underside
|
|
201
|
+
for (const i of def.interfaces) if (i.domain === "mechanical" && !frames[i.id]) frames[i.id] = manifest.features.outline.frame;
|
|
202
|
+
|
|
203
|
+
const out = ctx.module.out;
|
|
204
|
+
const provenance = { tool: "tscircuit", toolVersion: geometry.toolVersion, sourceDigest: native.build.sourceDigest };
|
|
205
|
+
// the artifacts the manifest declares (core attaches them to the board at load: PB-1030), with this build's provenance
|
|
206
|
+
const candidates: ArtifactDef[] = declaredArtifacts({ dir: ctx.module.dir, out, native: ctx.module.native, module: def.id }, CIRCUIT_DECL, "tscircuit").map((a) => ({
|
|
207
|
+
...a,
|
|
208
|
+
provenance: a.provenance?.generatedFrom ? { ...provenance, generatedFrom: a.provenance.generatedFrom } : { tool: "tscircuit", toolVersion: geometry.toolVersion },
|
|
209
|
+
}));
|
|
210
|
+
const artifacts = candidates.filter((a) => existsSync(join(root, a.filePath!)));
|
|
211
|
+
const facts: ExtractedFacts & { geometry: unknown } = {
|
|
212
|
+
frames,
|
|
213
|
+
artifacts,
|
|
214
|
+
// the layout itself, for re-binding steps and reports (not UHD)
|
|
215
|
+
geometry: {
|
|
216
|
+
outline: geometry.outline,
|
|
217
|
+
thickness_mm: geometry.thickness_mm,
|
|
218
|
+
layers: geometry.layers,
|
|
219
|
+
holes: geometry.holes,
|
|
220
|
+
components: Object.fromEntries(geometry.components.map((c) => [c.instance, { at: c.origin, center: c.center, rotation_deg: c.rotation_deg, side: c.side, frame: c.frame }])),
|
|
221
|
+
routing: geometry.routing,
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
return { status: "succeeded", summary: `${Object.keys(frames).length} frame(s) (${Object.keys(frames).join(", ")}), ${artifacts.length} artifact(s), ${geometry.components.length} placements`, data: facts };
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
export const check: CapabilityOperationFn = async (ctx) => {
|
|
228
|
+
const root = ctx.project.root;
|
|
229
|
+
const { netlist, digest } = netlistOf(ctx);
|
|
230
|
+
const f = files(ctx.module.native);
|
|
231
|
+
let constraints: BoardConstraints | undefined;
|
|
232
|
+
if (netlist.problems.length) return { status: "succeeded", summary: "board cannot be read", diagnostics: netlist.problems.map((m) => ({ code: "CIRCUIT_BOARD_UNREADABLE", severity: "error" as const, message: m, entity: { kind: "definition" as const, id: ctx.module.id } })) };
|
|
233
|
+
// the grouping's warnings: an unused export of a sub-module, a sub-module with no parts (docs/specs/nested-boards.md §11)
|
|
234
|
+
const grouping = (netlist.notes ?? []).map((m) => ({ code: "CIRCUIT_BOARD_GROUPING", severity: "warning" as const, message: m, entity: { kind: "definition" as const, id: ctx.module.id } }));
|
|
235
|
+
const locked = checkLocked(ctx, netlist); // model, handoff record and KiCad compared (ops/kicad.ts)
|
|
236
|
+
if (locked) return { status: "succeeded", summary: `locked: ${locked.filter((d) => d.severity === "error").length} error(s)`, diagnostics: [...locked, ...grouping] };
|
|
237
|
+
const read = constraintsOf(ctx, netlist);
|
|
238
|
+
constraints = read.constraints;
|
|
239
|
+
const notes = read.notes;
|
|
240
|
+
const diagnostics = await checkBoard({
|
|
241
|
+
native: readNative(root, ctx.module.native),
|
|
242
|
+
netlist,
|
|
243
|
+
netlistDigest: digest,
|
|
244
|
+
sourceDigest: sourceDigest(root, ctx.module.native, readNative(root, ctx.module.native)?.build.sourceFiles),
|
|
245
|
+
inputsDigest: generateInputs(ctx, netlist),
|
|
246
|
+
constraints,
|
|
247
|
+
constraintNotes: notes,
|
|
248
|
+
file: existsSync(join(root, f.gen)) ? f.gen : f.entry,
|
|
249
|
+
module: ctx.module.id,
|
|
250
|
+
overrides: readOverrides(root, ctx.module.native).overrides,
|
|
251
|
+
...(existsSync(join(root, f.gen)) ? { generatedSource: readFileSync(join(root, f.gen), "utf8") } : {}),
|
|
252
|
+
});
|
|
253
|
+
// outputs made from other inputs than the current ones: a re-export would change them (fingerprint.ts)
|
|
254
|
+
if (existsSync(join(root, ctx.module.out, "board.step")) || existsSync(join(root, ctx.module.out, "board.glb"))) {
|
|
255
|
+
const recorded = readExportRecord(root, ctx.module.native);
|
|
256
|
+
const rerun = `run \`protoboard run circuit:export --module ${ctx.module.id}\``;
|
|
257
|
+
if (!recorded) diagnostics.push({ code: "CIRCUIT_STALE", severity: "warning", message: `${ctx.module.out}/ has no record of the inputs it was exported from (${exportFile(ctx.module.native)}): it was exported by an earlier provider, and a re-export may change board.step and board.glb; ${rerun}`, entity: { kind: "definition", id: ctx.module.id }, source: { file: ctx.module.out } });
|
|
258
|
+
else {
|
|
259
|
+
const now = exportInputs(root, ctx.module.native, boardFingerprint(ctx, netlist), ctx.constraints, chooseBodies(ctx.project, netlist.components, ctx.module.native));
|
|
260
|
+
const drift = exportDrift(recorded.inputs, now);
|
|
261
|
+
if (fingerprintFormatChanged(recorded.inputs)) diagnostics.push({ code: "CIRCUIT_FINGERPRINT_FORMAT", severity: "info", message: `${exportFile(ctx.module.native)} was written by an earlier provider, which fingerprinted the whole UHD module (a description or a price edit counted as a change); the board's own dependencies are fingerprinted now. A change to the board since that export cannot be told from the record, so it is not reported as stale; the next \`protoboard run circuit:export --module ${ctx.module.id}\` records the new format`, entity: { kind: "definition", id: ctx.module.id }, source: { file: exportFile(ctx.module.native) } });
|
|
262
|
+
if (drift.length) diagnostics.push({ code: "CIRCUIT_STALE", severity: "warning", message: `${ctx.module.out}/ was exported from other inputs than the current ones (${drift.join("; ")}): a re-export will change its outputs (board.step, board.glb and the rest); ${rerun}`, entity: { kind: "definition", id: ctx.module.id }, source: { file: exportFile(ctx.module.native) } });
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
diagnostics.push(...grouping);
|
|
266
|
+
// constraints generate would refuse (and fab-order keys the release would print wrong)
|
|
267
|
+
for (const m of read.problems) diagnostics.push({ code: "CIRCUIT_CONFIG_INVALID", severity: "error", message: `circuit.config.ts constraints: ${m}`, entity: { kind: "definition", id: ctx.module.id } });
|
|
268
|
+
const errors = diagnostics.filter((d) => d.severity === "error").length;
|
|
269
|
+
return { status: "succeeded", summary: errors ? `${errors} error(s)` : "board matches the UHD module; no tscircuit errors", diagnostics };
|
|
270
|
+
};
|