@huaqiu/dsh-tool-pcb-viewer 0.4.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 +21 -0
- package/README.md +67 -0
- package/cordis.patch.yml +4 -0
- package/lib/client.js +58877 -0
- package/lib/index.d.mts +26 -0
- package/lib/index.mjs +616 -0
- package/lib/standalone.js +58326 -0
- package/package.json +66 -0
- package/src/adapter.ts +237 -0
- package/src/assets/demo.d.ts +2 -0
- package/src/assets/demo.js +2 -0
- package/src/client/index.ts +6 -0
- package/src/client/panel.tsx +496 -0
- package/src/client/standalone.ts +35 -0
- package/src/index.ts +302 -0
- package/src/model.ts +91 -0
- package/src/parse.ts +24 -0
- package/src/pcb/outline.ts +115 -0
- package/src/pcb/parseKicad.ts +286 -0
- package/src/scene/buildBoard.ts +333 -0
- package/src/scene/components.ts +480 -0
- package/src/scene/materials.ts +23 -0
- package/src/scene/textures.ts +506 -0
- package/src/scene/view2d.ts +301 -0
- package/src/viewer.ts +556 -0
package/lib/index.d.mts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Context } from "@deepseek-ai/cordis";
|
|
2
|
+
//#region src/index.d.ts
|
|
3
|
+
/** Plugin id — matches package.json. */
|
|
4
|
+
declare const name = "@huaqiu/dsh-tool-pcb-viewer";
|
|
5
|
+
/**
|
|
6
|
+
* Cordis services this half depends on.
|
|
7
|
+
*/
|
|
8
|
+
declare const inject: readonly ["tools", "systemPrompt", "webServer", "sessions"];
|
|
9
|
+
interface PcbViewerPluginConfig {
|
|
10
|
+
/** max .kicad_pcb file size in bytes (default 120MB). */
|
|
11
|
+
maxFileBytes?: number;
|
|
12
|
+
}
|
|
13
|
+
interface BoardStats {
|
|
14
|
+
sizeKB: number;
|
|
15
|
+
comps: number;
|
|
16
|
+
pads: number;
|
|
17
|
+
traces: number;
|
|
18
|
+
zones: number;
|
|
19
|
+
vias: number;
|
|
20
|
+
layers: number;
|
|
21
|
+
widthMM: number;
|
|
22
|
+
heightMM: number;
|
|
23
|
+
}
|
|
24
|
+
declare function apply(ctx: Context, config?: PcbViewerPluginConfig): () => void;
|
|
25
|
+
//#endregion
|
|
26
|
+
export { BoardStats, PcbViewerPluginConfig, apply, inject, name };
|
package/lib/index.mjs
ADDED
|
@@ -0,0 +1,616 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
6
|
+
//#region src/pcb/parseKicad.ts
|
|
7
|
+
function tokenize(src) {
|
|
8
|
+
const toks = [];
|
|
9
|
+
let i = 0;
|
|
10
|
+
const n = src.length;
|
|
11
|
+
while (i < n) {
|
|
12
|
+
const c = src[i];
|
|
13
|
+
if (c === " " || c === " " || c === "\n" || c === "\r") {
|
|
14
|
+
i++;
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
if (c === ";") {
|
|
18
|
+
while (i < n && src[i] !== "\n") i++;
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
if (c === "(" || c === ")") {
|
|
22
|
+
toks.push(c);
|
|
23
|
+
i++;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
if (c === "\"") {
|
|
27
|
+
let j = i + 1;
|
|
28
|
+
let s = "";
|
|
29
|
+
while (j < n && src[j] !== "\"") {
|
|
30
|
+
if (src[j] === "\\") {
|
|
31
|
+
const e = src[j + 1];
|
|
32
|
+
s += e === "n" ? "\n" : e === "t" ? " " : e === "r" ? "\r" : e ?? "";
|
|
33
|
+
j += 2;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
s += src[j];
|
|
37
|
+
j++;
|
|
38
|
+
}
|
|
39
|
+
toks.push("\"" + s);
|
|
40
|
+
i = j + 1;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
let j = i;
|
|
44
|
+
while (j < n && !" \n\r()".includes(src[j])) j++;
|
|
45
|
+
toks.push(src.slice(i, j));
|
|
46
|
+
i = j;
|
|
47
|
+
}
|
|
48
|
+
return toks;
|
|
49
|
+
}
|
|
50
|
+
function parseSexp(toks) {
|
|
51
|
+
let pos = 0;
|
|
52
|
+
function parse() {
|
|
53
|
+
if (toks[pos] === "(") {
|
|
54
|
+
pos++;
|
|
55
|
+
const list = [];
|
|
56
|
+
while (pos < toks.length && toks[pos] !== ")") list.push(parse());
|
|
57
|
+
pos++;
|
|
58
|
+
return list;
|
|
59
|
+
}
|
|
60
|
+
return toks[pos++] ?? "";
|
|
61
|
+
}
|
|
62
|
+
const roots = [];
|
|
63
|
+
while (pos < toks.length) roots.push(parse());
|
|
64
|
+
return roots[0] ?? [];
|
|
65
|
+
}
|
|
66
|
+
const num = (v) => typeof v === "string" ? parseFloat(v) : NaN;
|
|
67
|
+
const atom = (v) => typeof v === "string" ? v.replace(/^"/, "") : "";
|
|
68
|
+
/** find first child list whose head matches */
|
|
69
|
+
function child(list, head) {
|
|
70
|
+
for (const c of list) if (Array.isArray(c) && c[0] === head) return c;
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
function children(list, head) {
|
|
74
|
+
const out = [];
|
|
75
|
+
for (const c of list) if (Array.isArray(c) && c[0] === head) out.push(c);
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
/** (at x y [rot]) → {x,y,rot} */
|
|
79
|
+
function parseAt(node) {
|
|
80
|
+
if (!node) return {
|
|
81
|
+
x: 0,
|
|
82
|
+
y: 0,
|
|
83
|
+
rot: 0
|
|
84
|
+
};
|
|
85
|
+
return {
|
|
86
|
+
x: num(node[1]),
|
|
87
|
+
y: num(node[2]),
|
|
88
|
+
rot: node[3] !== void 0 ? num(node[3]) : 0
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
const rad = (d) => d * Math.PI / 180;
|
|
92
|
+
/** rotate local point by deg then translate */
|
|
93
|
+
function xform(lx, ly, fp) {
|
|
94
|
+
const r = rad(fp.rot), cs = Math.cos(r), sn = Math.sin(r);
|
|
95
|
+
return [fp.x + lx * cs - ly * sn, fp.y + lx * sn + ly * cs];
|
|
96
|
+
}
|
|
97
|
+
function parseKicad(src) {
|
|
98
|
+
const root = parseSexp(tokenize(src));
|
|
99
|
+
if (!Array.isArray(root)) throw new Error("not a valid .kicad_pcb");
|
|
100
|
+
const netName = /* @__PURE__ */ new Map();
|
|
101
|
+
for (const n of children(root, "net")) netName.set(num(n[1]), atom(n[2]));
|
|
102
|
+
let cuLayers = [];
|
|
103
|
+
const layersNode = child(root, "layers");
|
|
104
|
+
if (layersNode) for (const L of layersNode.slice(1)) {
|
|
105
|
+
const nm = Array.isArray(L) ? atom(L[1]) : "";
|
|
106
|
+
if (nm.endsWith(".Cu")) cuLayers.push(nm);
|
|
107
|
+
}
|
|
108
|
+
cuLayers.sort((a, b) => {
|
|
109
|
+
const rank = (s) => s === "F.Cu" ? 0 : s === "B.Cu" ? 100 : 1 + (parseInt(s.replace(/\D+/g, "")) || 0);
|
|
110
|
+
return rank(a) - rank(b);
|
|
111
|
+
});
|
|
112
|
+
if (cuLayers.length < 2) cuLayers = ["F.Cu", "B.Cu"];
|
|
113
|
+
const outline = [];
|
|
114
|
+
const collectCuts = (nodes, kind) => {
|
|
115
|
+
for (const g of nodes) {
|
|
116
|
+
const layerN = child(g, "layer");
|
|
117
|
+
if (!layerN || atom(layerN[1]) !== "Edge.Cuts") continue;
|
|
118
|
+
if (kind === "line") {
|
|
119
|
+
const st = child(g, "start"), en = child(g, "end");
|
|
120
|
+
outline.push({
|
|
121
|
+
type: "line",
|
|
122
|
+
a: [num(st[1]), num(st[2])],
|
|
123
|
+
b: [num(en[1]), num(en[2])]
|
|
124
|
+
});
|
|
125
|
+
} else if (kind === "arc") {
|
|
126
|
+
const st = child(g, "start"), md = child(g, "mid"), en = child(g, "end");
|
|
127
|
+
outline.push({
|
|
128
|
+
type: "arc",
|
|
129
|
+
a: [num(st[1]), num(st[2])],
|
|
130
|
+
m: [num(md[1]), num(md[2])],
|
|
131
|
+
b: [num(en[1]), num(en[2])]
|
|
132
|
+
});
|
|
133
|
+
} else if (kind === "rect") {
|
|
134
|
+
const st = child(g, "start"), en = child(g, "end");
|
|
135
|
+
const a = [num(st[1]), num(st[2])];
|
|
136
|
+
const b = [num(en[1]), num(en[2])];
|
|
137
|
+
outline.push({
|
|
138
|
+
type: "line",
|
|
139
|
+
a,
|
|
140
|
+
b: [b[0], a[1]]
|
|
141
|
+
}, {
|
|
142
|
+
type: "line",
|
|
143
|
+
a: [b[0], a[1]],
|
|
144
|
+
b
|
|
145
|
+
}, {
|
|
146
|
+
type: "line",
|
|
147
|
+
a: b,
|
|
148
|
+
b: [a[0], b[1]]
|
|
149
|
+
}, {
|
|
150
|
+
type: "line",
|
|
151
|
+
a: [a[0], b[1]],
|
|
152
|
+
b: a
|
|
153
|
+
});
|
|
154
|
+
} else {
|
|
155
|
+
const cN = child(g, "center"), eN = child(g, "end");
|
|
156
|
+
const c = [num(cN[1]), num(cN[2])];
|
|
157
|
+
const e = [num(eN[1]), num(eN[2])];
|
|
158
|
+
outline.push({
|
|
159
|
+
type: "circle",
|
|
160
|
+
c,
|
|
161
|
+
r: Math.hypot(e[0] - c[0], e[1] - c[1])
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
for (const node of root) {
|
|
167
|
+
if (!Array.isArray(node)) continue;
|
|
168
|
+
if (node[0] === "gr_line") collectCuts([node], "line");
|
|
169
|
+
else if (node[0] === "gr_arc") collectCuts([node], "arc");
|
|
170
|
+
else if (node[0] === "gr_rect") collectCuts([node], "rect");
|
|
171
|
+
else if (node[0] === "gr_circle") collectCuts([node], "circle");
|
|
172
|
+
}
|
|
173
|
+
const comps = [];
|
|
174
|
+
for (const fpNode of children(root, "footprint")) {
|
|
175
|
+
const fp = atom(fpNode[1]);
|
|
176
|
+
const layerNode = child(fpNode, "layer");
|
|
177
|
+
const layer = layerNode ? atom(layerNode[1]) : "F.Cu";
|
|
178
|
+
const at = parseAt(child(fpNode, "at"));
|
|
179
|
+
let ref = "";
|
|
180
|
+
for (const p of children(fpNode, "property")) if (atom(p[1]) === "Reference") ref = atom(p[2]);
|
|
181
|
+
if (!ref) {
|
|
182
|
+
const fpText = child(fpNode, "fp_text");
|
|
183
|
+
if (fpText && atom(fpText[1]) === "reference") ref = atom(fpText[2]);
|
|
184
|
+
}
|
|
185
|
+
const pads = [];
|
|
186
|
+
for (const pad of children(fpNode, "pad")) {
|
|
187
|
+
const pAt = parseAt(child(pad, "at"));
|
|
188
|
+
const sizeN = child(pad, "size");
|
|
189
|
+
const w = sizeN ? num(sizeN[1]) : 1;
|
|
190
|
+
const l = sizeN ? num(sizeN[2]) : 1;
|
|
191
|
+
const layersN = child(pad, "layers");
|
|
192
|
+
const layers = layersN ? layersN.slice(1).map(atom) : [];
|
|
193
|
+
const netN = child(pad, "net");
|
|
194
|
+
const net = netN ? netName.get(num(netN[1])) ?? atom(netN[2]) ?? "" : "";
|
|
195
|
+
const th = pad[2] === "thru_hole";
|
|
196
|
+
const shape = typeof pad[3] === "string" ? pad[3] : "rect";
|
|
197
|
+
const [ax, ay] = xform(pAt.x, pAt.y, at);
|
|
198
|
+
const totalRot = at.rot + pAt.rot;
|
|
199
|
+
const swap = Math.abs((totalRot % 180 + 180) % 180 - 90) < 45;
|
|
200
|
+
pads.push({
|
|
201
|
+
x: +ax.toFixed(4),
|
|
202
|
+
y: +ay.toFixed(4),
|
|
203
|
+
w: +(swap ? l : w).toFixed(4),
|
|
204
|
+
l: +(swap ? w : l).toFixed(4),
|
|
205
|
+
shape,
|
|
206
|
+
net,
|
|
207
|
+
top: th || layers.includes("F.Cu") || layers.includes("*.Cu"),
|
|
208
|
+
bottom: th || layers.includes("B.Cu") || layers.includes("*.Cu"),
|
|
209
|
+
th
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
comps.push({
|
|
213
|
+
ref,
|
|
214
|
+
x: +at.x.toFixed(4),
|
|
215
|
+
y: +at.y.toFixed(4),
|
|
216
|
+
rot: +at.rot.toFixed(2),
|
|
217
|
+
layer,
|
|
218
|
+
fp,
|
|
219
|
+
pads
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
const traces = [];
|
|
223
|
+
for (const node of root) {
|
|
224
|
+
if (!Array.isArray(node)) continue;
|
|
225
|
+
if (node[0] === "segment") {
|
|
226
|
+
const st = child(node, "start"), en = child(node, "end");
|
|
227
|
+
const layerN = child(node, "layer");
|
|
228
|
+
const widthN = child(node, "width");
|
|
229
|
+
traces.push({
|
|
230
|
+
pts: [[num(st[1]), num(st[2])], [num(en[1]), num(en[2])]],
|
|
231
|
+
w: widthN ? num(widthN[1]) : .25,
|
|
232
|
+
layer: layerN ? atom(layerN[1]) : "F.Cu"
|
|
233
|
+
});
|
|
234
|
+
} else if (node[0] === "arc") {
|
|
235
|
+
const st = child(node, "start"), mid = child(node, "mid"), en = child(node, "end");
|
|
236
|
+
const layerN = child(node, "layer");
|
|
237
|
+
const widthN = child(node, "width");
|
|
238
|
+
traces.push({
|
|
239
|
+
arc: true,
|
|
240
|
+
pts: [
|
|
241
|
+
[num(st[1]), num(st[2])],
|
|
242
|
+
[num(mid[1]), num(mid[2])],
|
|
243
|
+
[num(en[1]), num(en[2])]
|
|
244
|
+
],
|
|
245
|
+
w: widthN ? num(widthN[1]) : .25,
|
|
246
|
+
layer: layerN ? atom(layerN[1]) : "F.Cu"
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
const zones = [];
|
|
251
|
+
for (const z of children(root, "zone")) {
|
|
252
|
+
const layerN = child(z, "layer");
|
|
253
|
+
const layersN = child(z, "layers");
|
|
254
|
+
const netN = child(z, "net_name");
|
|
255
|
+
const layerNames = layersN ? layersN.slice(1).map(atom) : [layerN ? atom(layerN[1]) : "F.Cu"];
|
|
256
|
+
for (const poly of children(z, "polygon")) {
|
|
257
|
+
const ptsN = child(poly, "pts");
|
|
258
|
+
if (!ptsN) continue;
|
|
259
|
+
const pts = [];
|
|
260
|
+
for (const xy of ptsN.slice(1)) if (Array.isArray(xy) && xy[0] === "xy") pts.push([num(xy[1]), num(xy[2])]);
|
|
261
|
+
if (pts.length >= 3) zones.push({
|
|
262
|
+
pts,
|
|
263
|
+
layer: layerNames[0] ?? "F.Cu",
|
|
264
|
+
layers: layerNames,
|
|
265
|
+
net: netN ? atom(netN[1]) : ""
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
const vias = [];
|
|
270
|
+
for (const v of children(root, "via")) {
|
|
271
|
+
const at = parseAt(child(v, "at"));
|
|
272
|
+
const sizeN = child(v, "size");
|
|
273
|
+
const drillN = child(v, "drill");
|
|
274
|
+
vias.push({
|
|
275
|
+
x: +at.x.toFixed(3),
|
|
276
|
+
y: +at.y.toFixed(3),
|
|
277
|
+
size: sizeN ? num(sizeN[1]) : .8,
|
|
278
|
+
drill: drillN ? num(drillN[1]) : .4
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
const texts = [];
|
|
282
|
+
for (const t of children(root, "gr_text")) {
|
|
283
|
+
const layerN = child(t, "layer");
|
|
284
|
+
const lyr = layerN ? atom(layerN[1]) : "";
|
|
285
|
+
if (!lyr.includes("SilkS")) continue;
|
|
286
|
+
const at = parseAt(child(t, "at"));
|
|
287
|
+
const effects = child(t, "effects");
|
|
288
|
+
const font = effects ? child(effects, "font") : null;
|
|
289
|
+
const sizeN = font ? child(font, "size") : null;
|
|
290
|
+
texts.push({
|
|
291
|
+
text: atom(t[1]),
|
|
292
|
+
x: at.x,
|
|
293
|
+
y: at.y,
|
|
294
|
+
rot: at.rot,
|
|
295
|
+
size: sizeN ? num(sizeN[1]) : 1,
|
|
296
|
+
layer: lyr
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
|
|
300
|
+
const eat = (x, y) => {
|
|
301
|
+
x0 = Math.min(x0, x);
|
|
302
|
+
y0 = Math.min(y0, y);
|
|
303
|
+
x1 = Math.max(x1, x);
|
|
304
|
+
y1 = Math.max(y1, y);
|
|
305
|
+
};
|
|
306
|
+
if (outline.length) for (const o of outline) if (o.type === "circle") {
|
|
307
|
+
eat(o.c[0] - o.r, o.c[1] - o.r);
|
|
308
|
+
eat(o.c[0] + o.r, o.c[1] + o.r);
|
|
309
|
+
} else {
|
|
310
|
+
eat(o.a[0], o.a[1]);
|
|
311
|
+
eat(o.b[0], o.b[1]);
|
|
312
|
+
if (o.type === "arc") eat(o.m[0], o.m[1]);
|
|
313
|
+
}
|
|
314
|
+
else {
|
|
315
|
+
for (const c of comps) for (const p of c.pads) eat(p.x, p.y);
|
|
316
|
+
x0 -= 3;
|
|
317
|
+
y0 -= 3;
|
|
318
|
+
x1 += 3;
|
|
319
|
+
y1 += 3;
|
|
320
|
+
}
|
|
321
|
+
return {
|
|
322
|
+
outline,
|
|
323
|
+
bbox: {
|
|
324
|
+
x0,
|
|
325
|
+
y0,
|
|
326
|
+
x1,
|
|
327
|
+
y1
|
|
328
|
+
},
|
|
329
|
+
cuLayers,
|
|
330
|
+
comps,
|
|
331
|
+
traces,
|
|
332
|
+
zones,
|
|
333
|
+
vias,
|
|
334
|
+
texts
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
//#endregion
|
|
338
|
+
//#region src/parse.ts
|
|
339
|
+
function parseBoard(text) {
|
|
340
|
+
return parseKicad(text);
|
|
341
|
+
}
|
|
342
|
+
//#endregion
|
|
343
|
+
//#region src/index.ts
|
|
344
|
+
/**
|
|
345
|
+
* @huaqiu/dsh-tool-pcb-viewer — DSH host 半边。
|
|
346
|
+
*
|
|
347
|
+
* 职责:
|
|
348
|
+
* - `pcb_preview` 工具:接收 .kicad_pcb 路径(绝对或相对会话工作目录),用
|
|
349
|
+
* @huaqiu/kicad-sexpr-parser + ./adapter.js 解析出板子统计信息,并把文件登记到
|
|
350
|
+
* 一个 key 上供客户端经 webServer 路由拉取。
|
|
351
|
+
* - 只读路由 /pcb-viewer/api/file?key=…:把登记的板文件文本发给浏览器半边
|
|
352
|
+
* (文件可能上百 MB,不走工具结果本体)。
|
|
353
|
+
* - /pcb-viewer/view + /pcb-viewer/standalone.js:「浏览器打开」整页。
|
|
354
|
+
* - systemPrompt 段:告诉模型何时调用 pcb_preview。
|
|
355
|
+
*
|
|
356
|
+
* 渲染全部发生在浏览器半边(lib/client.js,经 exports["./client"] + dsh.client 加载)。
|
|
357
|
+
*
|
|
358
|
+
* @module @huaqiu/dsh-tool-pcb-viewer
|
|
359
|
+
*/
|
|
360
|
+
/** Plugin id — matches package.json. */
|
|
361
|
+
const name = "@huaqiu/dsh-tool-pcb-viewer";
|
|
362
|
+
/**
|
|
363
|
+
* Cordis services this half depends on.
|
|
364
|
+
*/
|
|
365
|
+
const inject = [
|
|
366
|
+
"tools",
|
|
367
|
+
"systemPrompt",
|
|
368
|
+
"webServer",
|
|
369
|
+
"sessions"
|
|
370
|
+
];
|
|
371
|
+
const bundleRev = (() => {
|
|
372
|
+
try {
|
|
373
|
+
const f = path.join(path.dirname(fileURLToPath(import.meta.url)), "standalone.js");
|
|
374
|
+
const st = fs.statSync(f);
|
|
375
|
+
return `${st.size.toString(36)}-${Math.floor(st.mtimeMs).toString(36)}`;
|
|
376
|
+
} catch {
|
|
377
|
+
return "";
|
|
378
|
+
}
|
|
379
|
+
})();
|
|
380
|
+
const GUIDANCE = `## pcb_preview 工具
|
|
381
|
+
- 当用户给出 .kicad_pcb 文件路径、或要求查看/预览/渲染 PCB 板子时,调用 pcb_preview。
|
|
382
|
+
- path 支持绝对路径,或相对会话工作目录的路径。
|
|
383
|
+
- 调用成功后用户会在右侧面板看到实时的「2D 走线视图 + 3D 渲染」大屏,可点击放大到全屏。
|
|
384
|
+
- 不要在回复里粘贴文件内容;只需告诉用户已打开预览即可。`;
|
|
385
|
+
const registry = /* @__PURE__ */ new Map();
|
|
386
|
+
const REGISTRY_CAP = 32;
|
|
387
|
+
function registrySet(key, file) {
|
|
388
|
+
if (registry.size >= REGISTRY_CAP) {
|
|
389
|
+
const oldest = registry.keys().next().value;
|
|
390
|
+
if (oldest !== void 0) registry.delete(oldest);
|
|
391
|
+
}
|
|
392
|
+
registry.set(key, file);
|
|
393
|
+
}
|
|
394
|
+
function sessionCwdOf(ctx, exec) {
|
|
395
|
+
const e = exec;
|
|
396
|
+
const sid = e?.sessionId ?? e?.session?.id ?? e?.context?.sessionId;
|
|
397
|
+
const sessions = ctx.sessions;
|
|
398
|
+
const cwd = sid ? sessions?.get?.(sid)?.header?.cwd : null;
|
|
399
|
+
return typeof cwd === "string" && cwd ? cwd : null;
|
|
400
|
+
}
|
|
401
|
+
function resolveBoardPath(ctx, exec, input) {
|
|
402
|
+
const p = String(input ?? "").trim();
|
|
403
|
+
if (!p) throw Object.assign(/* @__PURE__ */ new Error("path is required"), { status: 400 });
|
|
404
|
+
if (path.isAbsolute(p)) return path.resolve(p);
|
|
405
|
+
const cwd = sessionCwdOf(ctx, exec) ?? process.cwd();
|
|
406
|
+
return path.resolve(cwd, p);
|
|
407
|
+
}
|
|
408
|
+
function sendJson(res, status, body) {
|
|
409
|
+
const text = JSON.stringify(body);
|
|
410
|
+
res.writeHead(status, {
|
|
411
|
+
"content-type": "application/json; charset=utf-8",
|
|
412
|
+
"content-length": Buffer.byteLength(text)
|
|
413
|
+
});
|
|
414
|
+
res.end(text);
|
|
415
|
+
}
|
|
416
|
+
function isTrustedRequest(req) {
|
|
417
|
+
const host = String(req.headers.host ?? "");
|
|
418
|
+
if (!/^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/.test(host)) return false;
|
|
419
|
+
const origin = req.headers.origin;
|
|
420
|
+
if (origin && !/^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/.test(String(origin))) return false;
|
|
421
|
+
return true;
|
|
422
|
+
}
|
|
423
|
+
async function readStats(filePath, config) {
|
|
424
|
+
const stat = await fs.promises.stat(filePath);
|
|
425
|
+
if (stat.size > config.maxFileBytes) throw Object.assign(/* @__PURE__ */ new Error(`file too large: ${(stat.size / 1048576).toFixed(1)}MB > ${(config.maxFileBytes / 1048576).toFixed(0)}MB`), { status: 413 });
|
|
426
|
+
const b = parseBoard(await fs.promises.readFile(filePath, "utf8"));
|
|
427
|
+
return {
|
|
428
|
+
sizeKB: Math.round(stat.size / 1024),
|
|
429
|
+
comps: b.comps.length,
|
|
430
|
+
pads: b.comps.reduce((s, c) => s + c.pads.length, 0),
|
|
431
|
+
traces: b.traces.length,
|
|
432
|
+
zones: b.zones.length,
|
|
433
|
+
vias: b.vias.length,
|
|
434
|
+
layers: b.cuLayers.length,
|
|
435
|
+
widthMM: +(b.bbox.x1 - b.bbox.x0).toFixed(1),
|
|
436
|
+
heightMM: +(b.bbox.y1 - b.bbox.y0).toFixed(1)
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
function apply(ctx, config = {}) {
|
|
440
|
+
if (!ctx.tools || typeof ctx.tools.register !== "function") throw new Error("@huaqiu/dsh-tool-pcb-viewer requires the DSH `tools` service (ctx.tools.register).");
|
|
441
|
+
const cfg = { maxFileBytes: config.maxFileBytes ?? 125829120 };
|
|
442
|
+
if (ctx.webServer && typeof ctx.webServer.register === "function") ctx.effect(() => ctx.webServer.register({
|
|
443
|
+
kind: "prefix",
|
|
444
|
+
path: "/pcb-viewer",
|
|
445
|
+
handler: (req, res) => {
|
|
446
|
+
if (!isTrustedRequest(req)) {
|
|
447
|
+
sendJson(res, 403, {
|
|
448
|
+
ok: false,
|
|
449
|
+
error: {
|
|
450
|
+
code: "forbidden",
|
|
451
|
+
message: "forbidden"
|
|
452
|
+
}
|
|
453
|
+
});
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
try {
|
|
457
|
+
const url = new URL(req.url ?? "/", "http://dsh.internal");
|
|
458
|
+
const p = url.pathname;
|
|
459
|
+
if (p === "/pcb-viewer/api/file") {
|
|
460
|
+
const key = url.searchParams.get("key") ?? "";
|
|
461
|
+
const file = registry.get(key);
|
|
462
|
+
if (!file) {
|
|
463
|
+
sendJson(res, 404, {
|
|
464
|
+
ok: false,
|
|
465
|
+
error: {
|
|
466
|
+
code: "not-found",
|
|
467
|
+
message: "unknown file key"
|
|
468
|
+
}
|
|
469
|
+
});
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
res.writeHead(200, { "content-type": "text/plain; charset=utf-8" });
|
|
473
|
+
fs.createReadStream(file).pipe(res);
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
if (p === "/pcb-viewer/view") {
|
|
477
|
+
const html = `<!doctype html>
|
|
478
|
+
<html lang="zh-CN"><head><meta charset="utf-8"/>
|
|
479
|
+
<meta name="viewport" content="width=device-width,initial-scale=1"/>
|
|
480
|
+
<title>PCB 3D</title>
|
|
481
|
+
<style>html,body{margin:0;height:100%;overflow:hidden;background:#090b0f}#app{position:fixed;inset:0}</style>
|
|
482
|
+
</head><body><div id="app"></div>
|
|
483
|
+
<script src="/pcb-viewer/standalone.js${bundleRev ? `?rev=${bundleRev}` : ""}"><\/script>
|
|
484
|
+
</body></html>`;
|
|
485
|
+
res.writeHead(200, {
|
|
486
|
+
"content-type": "text/html; charset=utf-8",
|
|
487
|
+
"cache-control": "no-store"
|
|
488
|
+
});
|
|
489
|
+
res.end(html);
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
if (p === "/pcb-viewer/standalone.js") {
|
|
493
|
+
const file = path.join(path.dirname(fileURLToPath(import.meta.url)), "standalone.js");
|
|
494
|
+
if (!fs.existsSync(file)) {
|
|
495
|
+
res.writeHead(503, { "content-type": "text/plain; charset=utf-8" });
|
|
496
|
+
res.end("standalone bundle missing — run: pnpm build");
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
res.writeHead(200, {
|
|
500
|
+
"content-type": "text/javascript; charset=utf-8",
|
|
501
|
+
"cache-control": "no-store"
|
|
502
|
+
});
|
|
503
|
+
fs.createReadStream(file).pipe(res);
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
sendJson(res, 404, {
|
|
507
|
+
ok: false,
|
|
508
|
+
error: {
|
|
509
|
+
code: "not-found",
|
|
510
|
+
message: "unknown pcb-viewer route"
|
|
511
|
+
}
|
|
512
|
+
});
|
|
513
|
+
} catch (error) {
|
|
514
|
+
sendJson(res, 500, {
|
|
515
|
+
ok: false,
|
|
516
|
+
error: {
|
|
517
|
+
code: "internal",
|
|
518
|
+
message: error instanceof Error ? error.message : String(error)
|
|
519
|
+
}
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}), "dsh-tool-pcb-viewer: /pcb-viewer routes");
|
|
524
|
+
ctx.systemPrompt.section({
|
|
525
|
+
name: "tool:pcb_preview",
|
|
526
|
+
order: 107,
|
|
527
|
+
text: GUIDANCE
|
|
528
|
+
});
|
|
529
|
+
const tool = defineTool({
|
|
530
|
+
name: "pcb_preview",
|
|
531
|
+
description: "Render a KiCad .kicad_pcb file as an interactive 2D layout + 3D board view embedded in the conversation. Use whenever the user gives a .kicad_pcb path or asks to view/preview/render a PCB board.",
|
|
532
|
+
parameters: { path: {
|
|
533
|
+
type: "string",
|
|
534
|
+
required: true,
|
|
535
|
+
description: "Absolute path, or path relative to the session working directory"
|
|
536
|
+
} },
|
|
537
|
+
output: {
|
|
538
|
+
schema: { type: "json" },
|
|
539
|
+
render: (_args, value) => {
|
|
540
|
+
const v = value;
|
|
541
|
+
if (!v || v.ok !== true) return [{
|
|
542
|
+
type: "text",
|
|
543
|
+
text: JSON.stringify(value, null, 2)
|
|
544
|
+
}];
|
|
545
|
+
const s = v.stats ?? {};
|
|
546
|
+
return [{
|
|
547
|
+
type: "text",
|
|
548
|
+
text: `PCB 预览已打开:${v.name}(${s.widthMM}×${s.heightMM}mm,${s.layers} 层,${s.comps} 器件 / ${s.pads} 焊盘 / ${s.traces} 走线 / ${s.vias} 过孔)。右侧面板可见 2D 走线 + 3D 渲染,点击可放大。`
|
|
549
|
+
}, {
|
|
550
|
+
type: "text",
|
|
551
|
+
text: JSON.stringify(v)
|
|
552
|
+
}];
|
|
553
|
+
}
|
|
554
|
+
},
|
|
555
|
+
presentCall(args) {
|
|
556
|
+
const p = typeof args?.path === "string" ? args.path : "";
|
|
557
|
+
return {
|
|
558
|
+
card: "generic",
|
|
559
|
+
title: p ? `PCB 预览:${path.basename(p)}` : "PCB 预览",
|
|
560
|
+
kind: "read",
|
|
561
|
+
...p ? { locations: [{ path: p }] } : {}
|
|
562
|
+
};
|
|
563
|
+
},
|
|
564
|
+
presentResult(_args, result) {
|
|
565
|
+
let info = null;
|
|
566
|
+
for (const block of result?.content ?? []) {
|
|
567
|
+
if (block?.type !== "text") continue;
|
|
568
|
+
try {
|
|
569
|
+
const parsed = JSON.parse(block.text);
|
|
570
|
+
if (parsed && parsed.ok === true && parsed.stats) {
|
|
571
|
+
info = parsed;
|
|
572
|
+
break;
|
|
573
|
+
}
|
|
574
|
+
} catch {}
|
|
575
|
+
}
|
|
576
|
+
if (!info || !info.stats) return void 0;
|
|
577
|
+
const s = info.stats;
|
|
578
|
+
return {
|
|
579
|
+
card: "generic",
|
|
580
|
+
title: `PCB 预览:${info.name}`,
|
|
581
|
+
content: [{
|
|
582
|
+
type: "text",
|
|
583
|
+
text: `${s.widthMM}×${s.heightMM}mm · ${s.layers} 层 · ${s.comps} 器件 · ${s.pads} 焊盘 · ${s.traces} 走线 · ${s.vias} 过孔`
|
|
584
|
+
}]
|
|
585
|
+
};
|
|
586
|
+
},
|
|
587
|
+
async execute(args, exec) {
|
|
588
|
+
const filePath = resolveBoardPath(ctx, exec, (args && typeof args === "object" ? args : {}).path);
|
|
589
|
+
if (!/\.kicad_pcb$/i.test(filePath)) return {
|
|
590
|
+
ok: false,
|
|
591
|
+
error: `not a .kicad_pcb file: ${filePath}`
|
|
592
|
+
};
|
|
593
|
+
if (!fs.existsSync(filePath)) return {
|
|
594
|
+
ok: false,
|
|
595
|
+
error: `file not found: ${filePath}`
|
|
596
|
+
};
|
|
597
|
+
const stats = await readStats(filePath, cfg);
|
|
598
|
+
const key = `pcb-${randomUUID()}`;
|
|
599
|
+
registrySet(key, filePath);
|
|
600
|
+
return {
|
|
601
|
+
ok: true,
|
|
602
|
+
name: path.basename(filePath),
|
|
603
|
+
path: filePath,
|
|
604
|
+
key,
|
|
605
|
+
viewUrl: `/pcb-viewer/api/file?key=${key}`,
|
|
606
|
+
stats
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
});
|
|
610
|
+
const unregister = ctx.tools.register(tool);
|
|
611
|
+
return () => {
|
|
612
|
+
if (typeof unregister === "function") unregister();
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
//#endregion
|
|
616
|
+
export { apply, inject, name };
|