@windypro-rourou/dsh-eda 0.1.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.
@@ -0,0 +1,789 @@
1
+ // ============================================================================
2
+ // dsh-lichuang-eda · 第一版「嘉立创 EDA 标准版 (EasyEDA Standard) 原理图 JSON」生成器
3
+ // ============================================================================
4
+ // 纯 ESM、零 npm 依赖。可直接 `node src/json-gen.js` 运行 main(),
5
+ // 生成 src/output/demo.json(平面单片 docType=1 形式)、
6
+ // src/output/demo-project.json(docType=5 工程包装形式)与
7
+ // src/output/demo-netlist.json(网表推导 + 一致性自检结果)。
8
+ // 另导出 validateSchematic(obj) 结构 lint 与 deriveNetlist(sheet) 网表推导,
9
+ // 自动化测试见 src/json-gen.test.js(node --test,Node 自带)。
10
+ // 除 main() 使用 node:fs / node:path 外,其余函数均为纯 JS,可在浏览器复用。
11
+ //
12
+ // 格式依据(全部来自官方/真实样本,未凭空编造字段):
13
+ // 1. easyeda/easyeda-documents 仓库 Open-File-Format/common.md、schematic.md
14
+ // (官方开放文件格式文档:head/canvas/shape 及各 shape 前缀串格式)
15
+ // 2. docs.easyeda.com「EasyEDA Schematic File Format」「Common Information」
16
+ // 3. 真实编辑器导出样本(editorVersion 6.5.34,2023,KiCad 官方 QA 数据,
17
+ // qa/data/pcbnew/plugins/easyeda/SCH_ESP32-PICO-D4 smart watch_2023-09-02.json):
18
+ // 平面单片 dataStr = { head(对象), canvas, shape, BBox, colors };
19
+ // 导线 W / 网络标签 N / 网络标志 F / 节点 J / 元件 LIB 全部位于 shape 数组。
20
+ // (当前标准版格式没有 wire/component/net/designer 顶层数组,
21
+ // 任务描述中的这些字段名来自旧版「文件对象」格式或早期文档,见 docs/json-format.md)
22
+ // ============================================================================
23
+
24
+ // ----------------------------------------------------------------------------
25
+ // 工具
26
+ // ----------------------------------------------------------------------------
27
+
28
+ let __gid = 0
29
+ /** 生成形如 gge1/gge2 的自增 id(真实文件同样使用 gge+数字 作为普通图形 id) */
30
+ export function nextId() {
31
+ __gid += 1
32
+ return `gge${__gid}`
33
+ }
34
+
35
+ /** 生成 32 位十六进制 uuid(head.uuid 使用),不依赖 node:crypto,保持纯 JS 可运行 */
36
+ export function rand32Hex() {
37
+ const chars = '0123456789abcdef'
38
+ let s = ''
39
+ for (let i = 0; i < 32; i++) s += chars[Math.floor(Math.random() * 16)]
40
+ return s
41
+ }
42
+
43
+ /** 清洗文本字段,防止 ~ ` ^^ 等分隔符破坏格式 */
44
+ function sanitize(text) {
45
+ return String(text ?? '').replace(/[~`^]/g, '_')
46
+ }
47
+
48
+ /** 数值格式化:保留最多两位小数 */
49
+ const num = (v) => String(Math.round(Number(v) * 100) / 100)
50
+
51
+ // ----------------------------------------------------------------------------
52
+ // 顶层字段构造
53
+ // ----------------------------------------------------------------------------
54
+
55
+ /**
56
+ * head(对象形态,与真实 v6 编辑器导出逐字段一致)
57
+ * docType "1" = 原理图单片(schematic sheet)
58
+ */
59
+ export function makeHead(opts = {}) {
60
+ return {
61
+ docType: '1',
62
+ editorVersion: opts.editorVersion || '6.5.34',
63
+ newgId: true,
64
+ c_para: { 'Prefix Start': '1' },
65
+ c_spiceCmd: 'null',
66
+ hasIdFlag: true,
67
+ uuid: rand32Hex(),
68
+ x: '0',
69
+ y: '0',
70
+ portOfADImportHack: '',
71
+ importFlag: 0,
72
+ transformList: '',
73
+ }
74
+ }
75
+
76
+ /**
77
+ * canvas 串:CA~视宽~视高~背景~网格可见~网格色~网格大小~画布宽~画布高~网格样式~吸附~单位~ALT吸附~原点x~原点y
78
+ * 与真实样本格式一致(15 段)。
79
+ */
80
+ export function makeCanvas(width = 1200, height = 800, grid = 10) {
81
+ return [
82
+ 'CA', width, height, '#FFFFFF', 'yes', '#CCCCCC', grid,
83
+ width, height, 'line', grid, 'pixel', 5, 0, 0,
84
+ ].join('~')
85
+ }
86
+
87
+ // ----------------------------------------------------------------------------
88
+ // shape 前缀串构造(v6 形态,均带尾部 ~0 锁定字段,与真实导出一致)
89
+ // ----------------------------------------------------------------------------
90
+
91
+ /**
92
+ * 导线:W~x1 y1 x2 y2 …~颜色~线宽~线型~填充~id~locked
93
+ * points 为扁平坐标数组 [x1,y1,x2,y2,…](与 design.wires 一致)
94
+ */
95
+ export function wireShape(points, opts = {}) {
96
+ const id = opts.id || nextId()
97
+ const pts = []
98
+ for (let i = 0; i < points.length; i += 2) {
99
+ pts.push(`${num(points[i])} ${num(points[i + 1])}`)
100
+ }
101
+ return `W~${pts.join(' ')}~${opts.color || '#0099FF'}~${opts.width ?? 1}~0~none~${id}~0`
102
+ }
103
+
104
+ /** 网络标签:N~x~y~旋转~颜色~名称~id~对齐~文本x~文本y~字体~字号~locked */
105
+ export function netlabelShape(x, y, name, opts = {}) {
106
+ const id = opts.id || nextId()
107
+ return (
108
+ `N~${num(x)}~${num(y)}~0~${opts.color || '#880000'}~${sanitize(name)}~${id}` +
109
+ `~start~${num(x + 2)}~${num(y - 2.5)}~Times New Roman~7pt~0`
110
+ )
111
+ }
112
+
113
+ /** 节点(连接点圆点):J~x~y~半径~颜色~id~locked */
114
+ export function junctionShape(x, y, opts = {}) {
115
+ const id = opts.id || nextId()
116
+ return `J~${num(x)}~${num(y)}~2.5~${opts.color || '#CC0000'}~${id}~0`
117
+ }
118
+
119
+ /**
120
+ * 网络标志(电源符号),内置 GND 接地标志(part_netLabel_gnD,与真实样本相同的
121
+ * 图形:竖线 + 4 条渐短横线,画在 pin dot 上方;文本画在下方)。
122
+ * F~partid~x~y~旋转~id~~locked^^pinDotX~pinDotY^^名称~色~文本x~文本y~旋转~对齐~可见~字体~字号~flagId^^PL~…(图形)
123
+ */
124
+ export function netflagShape(x, y, name = 'GND', opts = {}) {
125
+ const id = opts.id || nextId()
126
+ const dx = Number(x)
127
+ const dy = Number(y)
128
+ // 图形段:竖线(dot 上方 10px)与 4 条横线(逐级收窄),与真实 GND 标志一致
129
+ const lines = [
130
+ `PL~${num(dx)} ${num(dy - 10)} ${num(dx)} ${num(dy)}~#000000~1~0~transparent~${id}_p1~0`,
131
+ `PL~${num(dx - 9)} ${num(dy - 10)} ${num(dx + 9)} ${num(dy - 10)}~#000000~1~0~transparent~${id}_p2~0`,
132
+ `PL~${num(dx - 6)} ${num(dy - 12)} ${num(dx + 6)} ${num(dy - 12)}~#000000~1~0~transparent~${id}_p3~0`,
133
+ `PL~${num(dx - 3)} ${num(dy - 14)} ${num(dx + 3)} ${num(dy - 14)}~#000000~1~0~transparent~${id}_p4~0`,
134
+ `PL~${num(dx - 1)} ${num(dy - 16)} ${num(dx + 1)} ${num(dy - 16)}~#000000~1~0~transparent~${id}_p5~0`,
135
+ ]
136
+ const mark =
137
+ `${sanitize(name)}~#000000~${num(dx - 13)}~${num(dy + 26)}~0~start~1~Times New Roman~9pt~flag_${id}`
138
+ return [
139
+ `F~part_netLabel_gnD~${num(dx)}~${num(dy)}~0~${id}~~0`,
140
+ `${num(dx)}~${num(dy)}`,
141
+ mark,
142
+ ...lines,
143
+ ].join('^^')
144
+ }
145
+
146
+ /**
147
+ * 引脚(元件符号内):P~show~电气类型~spice引脚号~x~y~旋转~id~locked
148
+ * ^^ 引脚点 x~y
149
+ * ^^ 引脚线 path~颜色
150
+ * ^^ 名称(可见~x~y~旋转~名称~对齐~字体~字号~颜色)
151
+ * ^^ 编号(同上)
152
+ * ^^ 圆点(可见~x~y)
153
+ * ^^ 时钟符号(可见~path)
154
+ * 与真实 v6 引脚串逐段一致。rotation: 0=朝右, 180=朝左(引脚点在外侧)。
155
+ */
156
+ export function pinShape({ x, y, rotation = 0, number = 1, name = '', id, pinLength = 20, color = '#880000' }) {
157
+ const px = Number(x)
158
+ const py = Number(y)
159
+ let path
160
+ if (rotation === 0) path = `M ${num(px - pinLength)} ${num(py)} h ${pinLength}`
161
+ else if (rotation === 180) path = `M ${num(px + pinLength)} ${num(py)} h -${pinLength}`
162
+ else if (rotation === 90) path = `M ${num(px)} ${num(py - pinLength)} v ${pinLength}`
163
+ else if (rotation === 270) path = `M ${num(px)} ${num(py + pinLength)} v -${pinLength}`
164
+ else throw new Error(`pinShape: 不支持的旋转 ${rotation}`)
165
+
166
+ const nameVisible = name ? 1 : 0
167
+ const nameX = rotation === 0 ? px - 14 : px + 14
168
+ const numX = rotation === 0 ? px - 6 : px + 6
169
+ // 名称/编号文本:可见~x~y~旋转~文本~对齐~字体~字号~颜色(v6 尾部带颜色)
170
+ const nameSec =
171
+ `${nameVisible}~${num(nameX)}~${num(py - 4)}~0~${sanitize(name) || number}~start~~~#000000`
172
+ const numSec = `0~${num(numX)}~${num(py - 4)}~0~${number}~end~~~#000000`
173
+ // 圆点与时钟(隐藏装饰,结构与真实样本一致)
174
+ const dotX = rotation === 0 ? px + 13 : px - 13
175
+ const clockDir = rotation === 0 ? -1 : 1
176
+ const cx = px + clockDir * 3
177
+ const clockSec = `0~M ${num(cx)} ${num(py - 3)} L ${num(cx + clockDir * 3)} ${num(py)} L ${num(cx)} ${num(py + 3)}`
178
+ return [
179
+ `P~show~0~${number}~${num(px)}~${num(py)}~${rotation}~${id}~0`,
180
+ `${num(px)}~${num(py)}`,
181
+ `${path}~${color}`,
182
+ nameSec,
183
+ numSec,
184
+ `0~${num(dotX)}~${num(py)}`,
185
+ clockSec,
186
+ ].join('^^')
187
+ }
188
+
189
+ /** 折线(符号内图形):PL~x1 y1 x2 y2 …~颜色~线宽~线型~填充~id~locked */
190
+ export function polylineShape(points, opts = {}) {
191
+ const id = opts.id || nextId()
192
+ const pts = points.map((p) => `${num(p[0])} ${num(p[1])}`).join(' ')
193
+ return `PL~${pts}~${opts.color || '#A00000'}~1~0~none~${id}~0`
194
+ }
195
+
196
+ /** 矩形(符号内图形/标注):R~x~y~rx~ry~宽~高~描边色~线宽~线型~填充~id~locked~ */
197
+ export function rectShape(x, y, w, h, opts = {}) {
198
+ const id = opts.id || nextId()
199
+ return `R~${num(x)}~${num(y)}~~~${num(w)}~${num(h)}~${opts.color || '#000000'}~1~0~none~${id}~0~`
200
+ }
201
+
202
+ /** 文本(元件内 T~N=值 / T~P=位号;画布上 T~L=标注) */
203
+ export function textShape(mark, x, y, text, opts = {}) {
204
+ const id = opts.id || nextId()
205
+ return (
206
+ `T~${mark}~${num(x)}~${num(y)}~0~#000080~Arial~~~~~comment~${sanitize(text)}` +
207
+ `~1~start~${id}~0~pinpart`
208
+ )
209
+ }
210
+
211
+ // ----------------------------------------------------------------------------
212
+ // 元件符号(LIB 条目)构造
213
+ // ----------------------------------------------------------------------------
214
+
215
+ /**
216
+ * 电阻符号(两脚,水平):本体矩形 60×20,脚长 20,引脚间距 100;
217
+ * 位号在上、值在下。返回完整 LIB 串。
218
+ * LIB 头字段(按官方文档与真实样本交集):
219
+ * LIB~x~y~c_para(反引号键值对)~旋转~importFlag~id
220
+ * 子图形以 #@$ 连接。
221
+ */
222
+ export function buildResistorLib(center, value, designator, opts = {}) {
223
+ const cx = Number(center.x)
224
+ const cy = Number(center.y)
225
+ const pinLength = 20
226
+ const pin1x = cx + 50 // 右侧脚(旋转 0,引脚点朝外)
227
+ const pin2x = cx - 50 // 左侧脚(旋转 180)
228
+ const body = rectShape(cx - 30, cy - 10, 60, 20, { color: '#000000' })
229
+ const p1 = pinShape({ x: pin1x, y: cy, rotation: 0, number: 1, name: '1', id: nextId(), pinLength, color: '#880000' })
230
+ const p2 = pinShape({ x: pin2x, y: cy, rotation: 180, number: 2, name: '2', id: nextId(), pinLength, color: '#880000' })
231
+ const tValue = textShape('N', cx, cy + 26, value || '?')
232
+ const tRef = textShape('P', cx, cy - 26, designator)
233
+ const cPara =
234
+ `package\`NONE\`nameAlias\`Value\`Value\`${sanitize(value || '?')}\`spicePre\`R\`spiceSymbolName\`resistor\``
235
+ const children = [tRef, tValue, body, p1, p2]
236
+ return `LIB~${num(cx)}~${num(cy)}~${cPara}~0~0~${nextId()}#@$` + children.join('#@$')
237
+ }
238
+
239
+ /**
240
+ * LED 符号(两脚,水平,阳极朝右):三角形 + 阴极竖线,阳极脚在右(旋转 0),
241
+ * 阴极脚在左(旋转 180)。位号在上、值在下。
242
+ */
243
+ export function buildLedLib(center, value, designator, opts = {}) {
244
+ const cx = Number(center.x)
245
+ const cy = Number(center.y)
246
+ const pinLength = 20
247
+ const anodeX = cx + 40 // 阳极(右)
248
+ const cathodeX = cx - 40 // 阴极(左)
249
+ // 三角形:底边在左 (cx-20),顶点在右 (cx+20),闭合
250
+ const tri = polylineShape(
251
+ [
252
+ [cx - 20, cy - 10],
253
+ [cx + 20, cy],
254
+ [cx - 20, cy + 10],
255
+ [cx - 20, cy - 10],
256
+ ],
257
+ { color: '#A00000' },
258
+ )
259
+ // 阴极竖线(三角形底边左侧)
260
+ const bar = polylineShape(
261
+ [
262
+ [cx - 25, cy - 8],
263
+ [cx - 25, cy + 8],
264
+ ],
265
+ { color: '#A00000' },
266
+ )
267
+ const p1 = pinShape({ x: anodeX, y: cy, rotation: 0, number: 1, name: 'A', id: nextId(), pinLength, color: '#880000' })
268
+ const p2 = pinShape({ x: cathodeX, y: cy, rotation: 180, number: 2, name: 'K', id: nextId(), pinLength, color: '#880000' })
269
+ const tValue = textShape('N', cx, cy + 26, value || 'LED')
270
+ const tRef = textShape('P', cx, cy - 26, designator)
271
+ const cPara =
272
+ `package\`NONE\`nameAlias\`Value\`Value\`${sanitize(value || 'LED')}\`spicePre\`D\`spiceSymbolName\`led\``
273
+ const children = [tRef, tValue, tri, bar, p1, p2]
274
+ return `LIB~${num(cx)}~${num(cy)}~${cPara}~0~0~${nextId()}#@$` + children.join('#@$')
275
+ }
276
+
277
+ /** 类型 → 符号构造器 */
278
+ const SYMBOL_BUILDERS = {
279
+ resistor: buildResistorLib,
280
+ led: buildLedLib,
281
+ }
282
+
283
+ // ----------------------------------------------------------------------------
284
+ // 顶层生成入口
285
+ // ----------------------------------------------------------------------------
286
+
287
+ /**
288
+ * 生成一份符合嘉立创 EDA 标准版(EasyEDA Standard v6)格式的平面单片原理图对象。
289
+ *
290
+ * design 形如:
291
+ * {
292
+ * name: 'demo',
293
+ * components: [ { ref:'R1', type:'resistor', value:'10k', pos:{x:400,y:300} }, ... ],
294
+ * wires: [ [x1,y1,x2,y2, ...], ... ], // 折线坐标(须与引脚点/网络点重合才连通)
295
+ * nets: [ { name:'5V', points:[[x,y],...] }, ... ], // GND 名 → 接地标志,其余 → 网络标签
296
+ * }
297
+ *
298
+ * 返回对象:{ head, canvas, shape, BBox, colors }(与真实 v6 平面单片逐键一致)。
299
+ */
300
+ export function generateSchematic(design = {}) {
301
+ const d = design
302
+ const components = d.components || []
303
+ const wires = d.wires || []
304
+ const nets = d.nets || []
305
+
306
+ const shape = []
307
+ const allCoords = []
308
+
309
+ // 1) 元件(LIB 条目)
310
+ for (const comp of components) {
311
+ const builder = SYMBOL_BUILDERS[comp.type]
312
+ if (!builder) {
313
+ throw new Error(
314
+ `generateSchematic: 不支持的元件类型 "${comp.type}"(可选:${Object.keys(SYMBOL_BUILDERS).join(', ')})`,
315
+ )
316
+ }
317
+ shape.push(builder(comp.pos, comp.value, comp.ref))
318
+ allCoords.push([comp.pos.x, comp.pos.y])
319
+ }
320
+
321
+ // 2) 导线
322
+ for (const w of wires) {
323
+ shape.push(wireShape(w))
324
+ for (let i = 0; i < w.length; i += 2) allCoords.push([w[i], w[i + 1]])
325
+ }
326
+
327
+ // 3) 网络(网络标签 / 接地标志)
328
+ for (const net of nets) {
329
+ for (const [nx, ny] of net.points) {
330
+ shape.push(
331
+ net.name === 'GND' ? netflagShape(nx, ny, 'GND') : netlabelShape(nx, ny, net.name),
332
+ )
333
+ allCoords.push([nx, ny])
334
+ }
335
+ }
336
+
337
+ // BBox:按内容坐标外扩 60
338
+ const xs = allCoords.map((p) => p[0])
339
+ const ys = allCoords.map((p) => p[1])
340
+ const minX = Math.min(...xs) - 60
341
+ const minY = Math.min(...ys) - 60
342
+ const maxX = Math.max(...xs) + 60
343
+ const maxY = Math.max(...ys) + 60
344
+ const BBox = { x: minX, y: minY, width: maxX - minX, height: maxY - minY }
345
+
346
+ return {
347
+ head: makeHead(),
348
+ canvas: makeCanvas(1200, 800, 10),
349
+ shape,
350
+ BBox,
351
+ colors: {},
352
+ }
353
+ }
354
+
355
+ /**
356
+ * 将平面单片包装为 docType=5 的工程导出形式(与真实编辑器导出
357
+ * SCH_*.json 的结构完全一致:editorVersion/docType/title/description/colors/schematics)。
358
+ */
359
+ export function wrapAsProject(sheet, meta = {}) {
360
+ return {
361
+ editorVersion: (sheet.head && sheet.head.editorVersion) || '6.5.34',
362
+ docType: 5,
363
+ title: meta.title || 'demo',
364
+ description: meta.description || '',
365
+ colors: sheet.colors || {},
366
+ schematics: [
367
+ {
368
+ docType: '1',
369
+ title: meta.title || 'Sheet_1',
370
+ description: meta.description || '',
371
+ dataStr: sheet,
372
+ },
373
+ ],
374
+ }
375
+ }
376
+
377
+ // ----------------------------------------------------------------------------
378
+ // 网表推导与一致性自检(从生成的 sheet 反推网络表,验证电路是否闭合)
379
+ // ----------------------------------------------------------------------------
380
+
381
+ const round2 = (v) => Math.round(Number(v) * 100) / 100
382
+
383
+ /**
384
+ * 从原理图对象(shape 数组)推导网表并做一致性检查。
385
+ *
386
+ * 解析:LIB(元件位号 T~P + 引脚 P^^ 引脚点)、W(导线折线)、N(网络标签)、
387
+ * F(网络标志,如 GND)、J(节点)。
388
+ * 以坐标点(四舍五入到 0.01)为节点、导线线段为边做连通图,每个连通分量 = 一个网络。
389
+ *
390
+ * 一致性规则(与 docs/json-format.md §7.3 一致):
391
+ * 1. 悬空导线端点:导线端点若不与任何其他导线、引脚点、标签、节点重合 → 报错;
392
+ * 2. 孤立导线:某导线所在连通分量没有任何引脚/标签 → 报错;
393
+ * 3. 未闭合网络:无标签网络必须 ≥2 个引脚(1 个引脚 = 悬空引脚/断开连接)→ 报错;
394
+ * 有标签网络(如 5V/GND 电源标签)允许 1 个引脚(标签表示外部连接);
395
+ * 4. 同名标签冲突:同一网络含多个不同名称的标签 → 报错;
396
+ * 5. 位号重复 → 报错。
397
+ *
398
+ * @param {object} sheet generateSchematic() 的输出(或任意符合格式的 sheet)
399
+ * @returns {{ components:Array, nets:Array, checks:{ok:boolean, errors:string[], warnings:string[]} }}
400
+ */
401
+ export function deriveNetlist(sheet) {
402
+ const errors = []
403
+ const warnings = []
404
+ const shape = Array.isArray(sheet && sheet.shape) ? sheet.shape : []
405
+ const keyOf = (x, y) => `${round2(x)},${round2(y)}`
406
+
407
+ // ---- 1) 解析 ----
408
+ const comps = [] // { designator, pins:[{number,key}] }
409
+ const wires = [] // [{ keys:[...] }]
410
+ const labels = [] // [{ name, key }]
411
+ const junctions = [] // [key]
412
+
413
+ for (const s of shape) {
414
+ if (typeof s !== 'string') continue
415
+ const cmd = s.split('~')[0]
416
+ if (cmd === 'LIB') {
417
+ const [head, ...children] = s.split('#@$')
418
+ const hf = head.split('~')
419
+ let designator = null
420
+ const pins = []
421
+ for (const c of children) {
422
+ if (c.startsWith('T~P')) designator = c.split('~')[12] || null
423
+ else if (c.startsWith('P~')) {
424
+ const secs = c.split('^^')
425
+ const h = secs[0].split('~')
426
+ const dot = secs[1] ? secs[1].split('~') : []
427
+ if (dot.length >= 2) pins.push({ number: h[3], key: keyOf(dot[0], dot[1]) })
428
+ }
429
+ }
430
+ comps.push({ designator: designator || `U${comps.length + 1}`, pins, x: Number(hf[1]), y: Number(hf[2]) })
431
+ } else if (cmd === 'W') {
432
+ const pts = String(s.split('~')[1] || '').trim().split(/\s+/).map(Number)
433
+ const keys = []
434
+ for (let i = 0; i + 1 < pts.length; i += 2) keys.push(keyOf(pts[i], pts[i + 1]))
435
+ if (keys.length >= 2) wires.push({ keys })
436
+ } else if (cmd === 'N') {
437
+ const f = s.split('~')
438
+ labels.push({ name: f[5], key: keyOf(f[1], f[2]) })
439
+ } else if (cmd === 'F') {
440
+ const secs = s.split('^^')
441
+ const dot = secs[1] ? secs[1].split('~') : []
442
+ const mark = secs[2] ? secs[2].split('~') : []
443
+ if (dot.length >= 2 && mark.length >= 1) {
444
+ labels.push({ name: mark[0], key: keyOf(dot[0], dot[1]) })
445
+ }
446
+ } else if (cmd === 'J') {
447
+ const f = s.split('~')
448
+ junctions.push(keyOf(f[1], f[2]))
449
+ }
450
+ }
451
+
452
+ // ---- 2) 位号唯一性 ----
453
+ const seenDesignators = new Set()
454
+ for (const c of comps) {
455
+ if (seenDesignators.has(c.designator)) errors.push(`位号重复: ${c.designator}`)
456
+ seenDesignators.add(c.designator)
457
+ }
458
+
459
+ // ---- 3) 建图 ----
460
+ const adj = new Map() // key -> Set(key)
461
+ const segCount = new Map() // key -> 作为线段端点的次数
462
+ const pinAt = new Map() // key -> [{designator, number}]
463
+ const labelAt = new Map() // key -> name
464
+ const wireVertices = new Set()
465
+
466
+ const bump = (m, k) => m.set(k, (m.get(k) || 0) + 1)
467
+ const link = (a, b) => {
468
+ if (a === b) return
469
+ if (!adj.has(a)) adj.set(a, new Set())
470
+ if (!adj.has(b)) adj.set(b, new Set())
471
+ adj.get(a).add(b)
472
+ adj.get(b).add(a)
473
+ bump(segCount, a)
474
+ bump(segCount, b)
475
+ }
476
+
477
+ for (const w of wires) {
478
+ for (const k of w.keys) wireVertices.add(k)
479
+ for (let i = 0; i + 1 < w.keys.length; i++) link(w.keys[i], w.keys[i + 1])
480
+ }
481
+ for (const c of comps) {
482
+ for (const p of c.pins) {
483
+ const arr = pinAt.get(p.key) || []
484
+ arr.push({ designator: c.designator, number: p.number })
485
+ pinAt.set(p.key, arr)
486
+ }
487
+ }
488
+ for (const l of labels) labelAt.set(l.key, l.name)
489
+
490
+ // ---- 4) 悬空导线端点 ----
491
+ for (const w of wires) {
492
+ for (const k of w.keys) {
493
+ const touching =
494
+ (segCount.get(k) || 0) >= 2 || pinAt.has(k) || labelAt.has(k) || junctions.includes(k)
495
+ if (!touching) errors.push(`悬空导线端点: (${k.replace(',', ', ')})`)
496
+ }
497
+ }
498
+
499
+ // ---- 5) 连通分量 → 网络 ----
500
+ const visited = new Set()
501
+ const allNodes = new Set([...adj.keys(), ...pinAt.keys(), ...labelAt.keys()])
502
+ const nets = []
503
+ let unnamedSeq = 0
504
+ for (const start of allNodes) {
505
+ if (visited.has(start)) continue
506
+ const stack = [start]
507
+ visited.add(start)
508
+ const nodes = new Set()
509
+ while (stack.length) {
510
+ const n = stack.pop()
511
+ nodes.add(n)
512
+ for (const m of adj.get(n) || []) {
513
+ if (!visited.has(m)) {
514
+ visited.add(m)
515
+ stack.push(m)
516
+ }
517
+ }
518
+ }
519
+ const pins = []
520
+ for (const n of nodes) for (const p of pinAt.get(n) || []) pins.push(p)
521
+ const lab = []
522
+ for (const n of nodes) if (labelAt.has(n)) lab.push(labelAt.get(n))
523
+ const uniqueLabs = [...new Set(lab)]
524
+ const netIssues = []
525
+ if (uniqueLabs.length > 1) {
526
+ netIssues.push(`同一网络含多个不同名称的标签: ${uniqueLabs.join(' / ')}`)
527
+ }
528
+ if (uniqueLabs.length >= 1) {
529
+ if (pins.length < 1) netIssues.push(`网络 ${uniqueLabs[0]}(标签)没有任何引脚`)
530
+ } else {
531
+ unnamedSeq += 1
532
+ if (pins.length < 2) {
533
+ const desc = pins.map((p) => `${p.designator} 引脚 ${p.number}`).join(', ')
534
+ netIssues.push(
535
+ `网络 Net${unnamedSeq} 未闭合:只有 ${pins.length} 个引脚且无网络标签(${desc || '无引脚'})`,
536
+ )
537
+ }
538
+ }
539
+ // 孤立导线:该分量有导线顶点但没有任何引脚/标签
540
+ const hasWireVertex = [...nodes].some((n) => wireVertices.has(n))
541
+ if (hasWireVertex && pins.length === 0 && uniqueLabs.length === 0) {
542
+ netIssues.push(`孤立导线:导线未连接任何元件引脚或标签`)
543
+ }
544
+ errors.push(...netIssues)
545
+ const name = uniqueLabs[0] || `Net${unnamedSeq}`
546
+ const sorted = [...pins].sort((a, b) => (a.designator < b.designator ? -1 : a.designator > b.designator ? 1 : Number(a.number) - Number(b.number)))
547
+ nets.push({
548
+ name,
549
+ label: uniqueLabs[0] || null,
550
+ pins: sorted,
551
+ wireVertices: [...nodes].filter((n) => wireVertices.has(n)).length,
552
+ closed: netIssues.length === 0,
553
+ })
554
+ }
555
+
556
+ // 有引脚但整个图里没有任何导线/标签时也要能体现(上面已覆盖:无标签+1 引脚 → 未闭合)
557
+
558
+ return {
559
+ components: comps.map((c) => ({
560
+ designator: c.designator,
561
+ pins: c.pins.map((p) => ({ number: p.number })),
562
+ })),
563
+ nets,
564
+ checks: { ok: errors.length === 0, errors, warnings },
565
+ }
566
+ }
567
+
568
+ // ----------------------------------------------------------------------------
569
+ // 结构 lint:validateSchematic(obj) → { ok, errors[] }
570
+ // 检查顶层/必需字段与字段类型(对齐 docs/json-format.md §2~§5 确认的结构)
571
+ // ----------------------------------------------------------------------------
572
+
573
+ const KNOWN_SHAPE_PREFIXES = new Set([
574
+ 'LIB', 'W', 'N', 'F', 'J', 'T', 'PL', 'PG', 'PT', 'R', 'E', 'C', 'I', 'B', 'BE', 'O', 'A', 'AR', 'P',
575
+ ])
576
+
577
+ // 各前缀的“段数”(~ 分隔),v6 形态(含尾部 locked 字段),依据真实样本
578
+ const SHAPE_FIELD_COUNTS = { W: 8, N: 13, J: 6, T: 18, R: 14, PL: 8, O: 7, E: 11, C: 9, I: 8, A: 8, AR: 8, PG: 7, PT: 7, B: 7, BE: 7 }
579
+
580
+ // 各前缀主 id 所在段下标(用于唯一性检查)
581
+ const ID_IDX = { W: 6, N: 6, J: 5, F: 5, T: 15, PL: 6, R: 11, E: 9, O: 3, P: 7, LIB: 6 }
582
+
583
+ function idOfShapeString(s) {
584
+ const cmd = s.split('~')[0]
585
+ const idx = ID_IDX[cmd]
586
+ if (idx === undefined) return null
587
+ return s.split('~')[idx] || null
588
+ }
589
+
590
+ /**
591
+ * 结构 lint:校验顶层字段、head 各字段、canvas 段数、shape 条目的前缀/段数/坐标、
592
+ * BBox、colors,以及全文件 id 唯一性。
593
+ * @param {unknown} obj 待校验对象
594
+ * @returns {{ ok: boolean, errors: string[] }}
595
+ */
596
+ export function validateSchematic(obj) {
597
+ const errors = []
598
+ if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) {
599
+ return { ok: false, errors: ['顶层必须是 JSON 对象'] }
600
+ }
601
+
602
+ // ---- 顶层键 ----
603
+ const keys = Object.keys(obj).sort()
604
+ const expected = ['BBox', 'canvas', 'colors', 'head', 'shape']
605
+ if (keys.join(',') !== expected.join(',')) {
606
+ errors.push(`顶层键必须恰为 ${expected.join(',')}(当前: ${keys.join(',') || '空'})`)
607
+ }
608
+
609
+ // ---- head ----
610
+ const h = obj.head
611
+ if (!h || typeof h !== 'object' || Array.isArray(h)) {
612
+ errors.push('head 必须是对象')
613
+ } else {
614
+ const reqHead = {
615
+ docType: 'string',
616
+ editorVersion: 'string',
617
+ newgId: 'boolean',
618
+ c_para: 'object',
619
+ c_spiceCmd: 'string',
620
+ hasIdFlag: 'boolean',
621
+ uuid: 'string',
622
+ x: 'string',
623
+ y: 'string',
624
+ portOfADImportHack: 'string',
625
+ importFlag: 'number',
626
+ transformList: 'string',
627
+ }
628
+ for (const [k, t] of Object.entries(reqHead)) {
629
+ if (!(k in h)) errors.push(`head 缺少必需字段 ${k}`)
630
+ else if (typeof h[k] !== t) errors.push(`head.${k} 类型应为 ${t},实际 ${typeof h[k]}`)
631
+ }
632
+ if (typeof h.docType === 'string' && h.docType !== '1') {
633
+ errors.push(`head.docType 应为 "1"(原理图单片),实际 "${h.docType}"`)
634
+ }
635
+ if (typeof h.uuid === 'string' && !/^[0-9a-f]{32}$/.test(h.uuid)) {
636
+ errors.push('head.uuid 应为 32 位十六进制字符串')
637
+ }
638
+ }
639
+
640
+ // ---- canvas ----
641
+ if (typeof obj.canvas !== 'string') {
642
+ errors.push('canvas 必须是字符串')
643
+ } else {
644
+ const f = obj.canvas.split('~')
645
+ if (f[0] !== 'CA') errors.push('canvas 必须以 "CA" 开头')
646
+ if (f.length !== 15) errors.push(`canvas 应为 15 段,实际 ${f.length} 段`)
647
+ else if (f[11] !== 'pixel') errors.push('canvas 第 12 段(单位)应为 pixel')
648
+ }
649
+
650
+ // ---- shape ----
651
+ if (!Array.isArray(obj.shape)) {
652
+ errors.push('shape 必须是数组')
653
+ } else {
654
+ if (obj.shape.length === 0) errors.push('shape 不能为空')
655
+ const allIds = []
656
+ for (let i = 0; i < obj.shape.length; i++) {
657
+ const s = obj.shape[i]
658
+ if (typeof s !== 'string' || s.length === 0) {
659
+ errors.push(`shape[${i}] 应为非空字符串`)
660
+ continue
661
+ }
662
+ const cmd = s.split('~')[0]
663
+ if (!KNOWN_SHAPE_PREFIXES.has(cmd)) errors.push(`shape[${i}] 未知前缀 "${cmd}"`)
664
+ if (s.includes('NaN')) errors.push(`shape[${i}] 含 NaN 坐标`)
665
+ const n = s.split('~').length
666
+ const expect = SHAPE_FIELD_COUNTS[cmd]
667
+ if (expect && n !== expect) errors.push(`shape[${i}] ${cmd} 应为 ${expect} 段,实际 ${n} 段`)
668
+ if (cmd === 'F' && n < 8) errors.push(`shape[${i}] F 头应 ≥8 段`)
669
+ if (cmd === 'LIB') {
670
+ if (!s.includes('#@$')) errors.push(`shape[${i}] LIB 缺少 #@$ 子图形连接符`)
671
+ else if (n < 7) errors.push(`shape[${i}] LIB 头应 ≥7 段`)
672
+ }
673
+ const id = idOfShapeString(s)
674
+ if (id) allIds.push(id)
675
+ // LIB 子图形 id(T/R/PL/P 等)
676
+ if (cmd === 'LIB') {
677
+ for (const c of s.split('#@$')) {
678
+ const cid = idOfShapeString(c)
679
+ if (cid) allIds.push(cid)
680
+ }
681
+ }
682
+ }
683
+ const seen = new Set()
684
+ const dups = []
685
+ for (const id of allIds) {
686
+ if (seen.has(id) && !dups.includes(id)) dups.push(id)
687
+ seen.add(id)
688
+ }
689
+ if (dups.length) errors.push(`存在重复 id: ${dups.join(', ')}`)
690
+ }
691
+
692
+ // ---- BBox ----
693
+ const b = obj.BBox
694
+ if (!b || typeof b !== 'object' || Array.isArray(b)) {
695
+ errors.push('BBox 必须是对象')
696
+ } else {
697
+ for (const k of ['x', 'y', 'width', 'height']) {
698
+ if (typeof b[k] !== 'number' || !Number.isFinite(b[k])) {
699
+ errors.push(`BBox.${k} 应为有限数字`)
700
+ }
701
+ }
702
+ }
703
+
704
+ // ---- colors ----
705
+ if (!obj.colors || typeof obj.colors !== 'object' || Array.isArray(obj.colors)) {
706
+ errors.push('colors 应为对象(可为空对象)')
707
+ }
708
+
709
+ return { ok: errors.length === 0, errors }
710
+ }
711
+
712
+ // ----------------------------------------------------------------------------
713
+ // 内置示例电路:R1(10k) + LED1 串联,5V → R1 → LED → GND
714
+ // ----------------------------------------------------------------------------
715
+
716
+ export const DEFAULT_DESIGN = {
717
+ name: 'demo',
718
+ components: [
719
+ { ref: 'R1', type: 'resistor', value: '10k', pos: { x: 400, y: 300 } },
720
+ { ref: 'LED1', type: 'led', value: 'red', pos: { x: 700, y: 300 } },
721
+ ],
722
+ // R1 右引脚 (450,300) → 向下 → 向右 → 向上 → LED1 阳极 (740,300),绕开元件本体
723
+ wires: [[450, 300, 450, 380, 740, 380, 740, 300]],
724
+ nets: [
725
+ { name: '5V', points: [[350, 300]] }, // R1 左引脚 (350,300)
726
+ { name: 'GND', points: [[660, 300]] }, // LED1 阴极引脚 (660,300)
727
+ ],
728
+ }
729
+
730
+ // ----------------------------------------------------------------------------
731
+ // 直接运行:node src/json-gen.js → 写出 src/output/demo.json 与 demo-project.json
732
+ // ----------------------------------------------------------------------------
733
+
734
+ export async function main() {
735
+ const fs = await import('node:fs')
736
+ const path = await import('node:path')
737
+ const { fileURLToPath } = await import('node:url')
738
+ const __dirname = fileURLToPath(new URL('.', import.meta.url))
739
+ const outDir = path.join(__dirname, 'output')
740
+ fs.mkdirSync(outDir, { recursive: true })
741
+
742
+ const sheet = generateSchematic(DEFAULT_DESIGN)
743
+ const demoPath = path.join(outDir, 'demo.json')
744
+ const projPath = path.join(outDir, 'demo-project.json')
745
+ const nlPath = path.join(outDir, 'demo-netlist.json')
746
+ fs.writeFileSync(demoPath, JSON.stringify(sheet, null, 2) + '\n', 'utf8')
747
+ fs.writeFileSync(projPath, JSON.stringify(wrapAsProject(sheet, { title: 'demo' }), null, 2) + '\n', 'utf8')
748
+
749
+ // 网表推导 + 一致性自检
750
+ const netlist = deriveNetlist(sheet)
751
+ fs.writeFileSync(
752
+ nlPath,
753
+ JSON.stringify({ source: 'demo.json', generatedBy: 'dsh-lichuang-eda json-gen.js deriveNetlist', ...netlist }, null, 2) + '\n',
754
+ 'utf8',
755
+ )
756
+
757
+ // 自检:JSON.parse 往返 + 结构断言 + 网表
758
+ const round = JSON.parse(JSON.stringify(sheet))
759
+ const shapePrefixes = {}
760
+ for (const s of round.shape) {
761
+ const p = s.split('~')[0]
762
+ shapePrefixes[p] = (shapePrefixes[p] || 0) + 1
763
+ }
764
+ const ok =
765
+ round.head.docType === '1' &&
766
+ typeof round.canvas === 'string' &&
767
+ round.canvas.startsWith('CA~') &&
768
+ Array.isArray(round.shape) &&
769
+ round.shape.length > 0 &&
770
+ round.BBox &&
771
+ typeof round.colors === 'object'
772
+
773
+ console.log(`[json-gen] 已生成: ${demoPath}`)
774
+ console.log(`[json-gen] 已生成: ${projPath}`)
775
+ console.log(`[json-gen] 已生成: ${nlPath}`)
776
+ console.log(`[json-gen] shape 条目数: ${sheet.shape.length},前缀分布: ${JSON.stringify(shapePrefixes)}`)
777
+ console.log(`[json-gen] 网表: ${netlist.nets.length} 个网络 (${netlist.nets.map((n) => `${n.name}[${n.pins.length}脚]`).join(', ')}),一致性自检: ${netlist.checks.ok ? '通过' : '失败'}`)
778
+ console.log(`[json-gen] 结构自检(JSON.parse 往返+顶层键): ${ok ? '通过' : '失败'}`)
779
+ return { demoPath, projPath, nlPath, shapeCount: sheet.shape.length, netlist, ok }
780
+ }
781
+
782
+ // 入口检测:node src/json-gen.js 直接运行
783
+ const isMain =
784
+ typeof process !== 'undefined' &&
785
+ process.argv[1] &&
786
+ (process.argv[1].endsWith('src/json-gen.js') || process.argv[1].endsWith('json-gen.js'))
787
+ if (isMain) {
788
+ await main()
789
+ }