@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/package.json
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@huaqiu/dsh-tool-pcb-viewer",
|
|
3
|
+
"version": "0.4.1",
|
|
4
|
+
"description": "KiCad .kicad_pcb → interactive 2D-layout + 3D board viewer DSH plugin: a pcb_preview tool, an in-conversation turn-tail card (live 3D thumbnail + actions), a big-screen split panel, and a standalone full-page route. Parses via @huaqiu/kicad-sexpr-parser through a model adapter.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./lib/index.mjs",
|
|
7
|
+
"types": "./lib/index.d.mts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./lib/index.d.mts",
|
|
11
|
+
"default": "./lib/index.mjs"
|
|
12
|
+
},
|
|
13
|
+
"./client": {
|
|
14
|
+
"default": "./lib/client.js"
|
|
15
|
+
},
|
|
16
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
17
|
+
"./package.json": "./package.json"
|
|
18
|
+
},
|
|
19
|
+
"dsh": {
|
|
20
|
+
"bundle": {
|
|
21
|
+
"patch": "./cordis.patch.yml"
|
|
22
|
+
},
|
|
23
|
+
"client": {
|
|
24
|
+
"platform": "web",
|
|
25
|
+
"inject": [
|
|
26
|
+
"@deepseek-ai/dsh-client-runtime"
|
|
27
|
+
]
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"peerDependencies": {
|
|
31
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
32
|
+
"@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.0",
|
|
33
|
+
"@deepseek-ai/dsh-tools": "^0.1.0-rc.0",
|
|
34
|
+
"react": "^18"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@huaqiu/kicad-sexpr-parser": "^0.1.1",
|
|
38
|
+
"three": "^0.185.1"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@types/react": "^18.3.31",
|
|
42
|
+
"@types/three": "^0.185.4",
|
|
43
|
+
"react": "^18.3.1"
|
|
44
|
+
},
|
|
45
|
+
"files": [
|
|
46
|
+
"lib",
|
|
47
|
+
"src",
|
|
48
|
+
"cordis.patch.yml"
|
|
49
|
+
],
|
|
50
|
+
"keywords": [
|
|
51
|
+
"dsh",
|
|
52
|
+
"deepseek-harness",
|
|
53
|
+
"plugin",
|
|
54
|
+
"kicad",
|
|
55
|
+
"pcb",
|
|
56
|
+
"3d",
|
|
57
|
+
"viewer",
|
|
58
|
+
"eda"
|
|
59
|
+
],
|
|
60
|
+
"license": "MIT",
|
|
61
|
+
"scripts": {
|
|
62
|
+
"build": "tsdown",
|
|
63
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
64
|
+
"test": "vitest run"
|
|
65
|
+
}
|
|
66
|
+
}
|
package/src/adapter.ts
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* adapter.ts — convert the published `@huaqiu/kicad-sexpr-parser` model
|
|
3
|
+
* (`I_KicadPCB`, nested protobuf-style) into the viewer's flat `BoardModel`.
|
|
4
|
+
*
|
|
5
|
+
* The renderer never sees the upstream parser's shape; this is the single
|
|
6
|
+
* translation seam. Pad positions are emitted in ABSOLUTE board coordinates
|
|
7
|
+
* (footprint position + rotation applied), matching the coordinate contract
|
|
8
|
+
* the viewer was built and verified against.
|
|
9
|
+
*
|
|
10
|
+
* All upstream types come from the parser's `boardProto` namespace export —
|
|
11
|
+
* never from deep `dist/proto/*` paths (not in the package's exports map).
|
|
12
|
+
* NOTE: this engine is not the default (see ./parse.ts); keep it working and
|
|
13
|
+
* keep it in parity — it is the path we switch back to when it wins on speed.
|
|
14
|
+
*
|
|
15
|
+
* @module @huaqiu/dsh-tool-pcb-viewer/adapter
|
|
16
|
+
*/
|
|
17
|
+
import { BoardParser, boardProto } from '@huaqiu/kicad-sexpr-parser'
|
|
18
|
+
import type {
|
|
19
|
+
BoardModel, BoardComp, BoardPad, BoardTrace, BoardZone, BoardVia, OutlineSeg, BoardText,
|
|
20
|
+
} from './model.js'
|
|
21
|
+
|
|
22
|
+
type I_KicadPCB = boardProto.I_KicadPCB
|
|
23
|
+
type I_Footprint = boardProto.I_Footprint
|
|
24
|
+
type I_Pad = boardProto.I_Pad
|
|
25
|
+
|
|
26
|
+
const rad = (deg: number) => (deg * Math.PI) / 180
|
|
27
|
+
|
|
28
|
+
/** rotate a local point by `deg` (KiCad CCW) then translate by the footprint origin. */
|
|
29
|
+
function xform(lx: number, ly: number, fp: { x: number; y: number; rot: number }): [number, number] {
|
|
30
|
+
const r = rad(fp.rot)
|
|
31
|
+
const cs = Math.cos(r)
|
|
32
|
+
const sn = Math.sin(r)
|
|
33
|
+
return [fp.x + lx * cs - ly * sn, fp.y + lx * sn + ly * cs]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Net name for a pad. Original pipeline priority: pad net NUMBER → top-level net
|
|
38
|
+
* table lookup first, inline name only as a fallback (inline names can be stale).
|
|
39
|
+
*/
|
|
40
|
+
function padNet(pad: I_Pad, nets: ReadonlyMap<number, string>): string {
|
|
41
|
+
const net = pad.net as { number?: number; name?: string } | undefined
|
|
42
|
+
if (!net) return ''
|
|
43
|
+
if (typeof net.number === 'number') return nets.get(net.number) ?? net.name ?? ''
|
|
44
|
+
return net.name ?? ''
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function adaptPad(pad: I_Pad, fp: { x: number; y: number; rot: number }, nets: ReadonlyMap<number, string>): BoardPad {
|
|
48
|
+
const lx = pad.at?.position?.x ?? 0
|
|
49
|
+
const ly = pad.at?.position?.y ?? 0
|
|
50
|
+
const padRot = pad.at?.rotation ?? 0
|
|
51
|
+
const [x, y] = xform(lx, ly, fp)
|
|
52
|
+
const totalRot = ((fp.rot + padRot) % 180 + 180) % 180
|
|
53
|
+
const swap = Math.abs(totalRot - 90) < 45
|
|
54
|
+
const w = pad.size?.x ?? 1
|
|
55
|
+
const l = pad.size?.y ?? 1
|
|
56
|
+
const layers = pad.layers ?? []
|
|
57
|
+
const th = pad.type === 'thru_hole'
|
|
58
|
+
return {
|
|
59
|
+
x: +x.toFixed(4),
|
|
60
|
+
y: +y.toFixed(4),
|
|
61
|
+
w: +(swap ? l : w).toFixed(4),
|
|
62
|
+
l: +(swap ? w : l).toFixed(4),
|
|
63
|
+
shape: pad.shape ?? 'rect',
|
|
64
|
+
net: padNet(pad, nets),
|
|
65
|
+
top: th || layers.includes('F.Cu') || layers.includes('*.Cu'),
|
|
66
|
+
bottom: th || layers.includes('B.Cu') || layers.includes('*.Cu'),
|
|
67
|
+
th,
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function adaptFootprint(fp: I_Footprint, nets: ReadonlyMap<number, string>): BoardComp {
|
|
72
|
+
const props = fp.properties ?? {}
|
|
73
|
+
const ref =
|
|
74
|
+
props.Reference ??
|
|
75
|
+
(fp.properties_kicad_8 ?? []).find((p) => p?.name === 'Reference')?.value ??
|
|
76
|
+
''
|
|
77
|
+
const x = fp.at?.position?.x ?? 0
|
|
78
|
+
const y = fp.at?.position?.y ?? 0
|
|
79
|
+
const rot = fp.at?.rotation ?? 0
|
|
80
|
+
return {
|
|
81
|
+
ref,
|
|
82
|
+
fp: fp.library_link ?? '',
|
|
83
|
+
x: +x.toFixed(4),
|
|
84
|
+
y: +y.toFixed(4),
|
|
85
|
+
rot: +rot.toFixed(2),
|
|
86
|
+
layer: fp.layer ?? 'F.Cu',
|
|
87
|
+
pads: (fp.pads ?? []).map((p) => adaptPad(p, { x, y, rot }, nets)),
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Edge.Cuts → outline segments. Upstream drawing discriminators (verified
|
|
93
|
+
* against the parser's real key sets):
|
|
94
|
+
* gr_line → start/end, NO fill key
|
|
95
|
+
* gr_rect → start/end + fill (rect → 4 edges, same vertex order as the JS parser)
|
|
96
|
+
* gr_arc → start/mid/end
|
|
97
|
+
* gr_circle → center/end (radius = |end − center|; there is NO `radius` field)
|
|
98
|
+
*/
|
|
99
|
+
function adaptOutline(drawings: NonNullable<I_KicadPCB['drawings']>): OutlineSeg[] {
|
|
100
|
+
const out: OutlineSeg[] = []
|
|
101
|
+
for (const d of drawings ?? []) {
|
|
102
|
+
if (!d) continue
|
|
103
|
+
const g = d as boardProto.I_Line | boardProto.I_Arc | boardProto.I_Circle | boardProto.I_Rect | boardProto.I_Poly
|
|
104
|
+
if (g.layer !== 'Edge.Cuts') continue
|
|
105
|
+
if ('center' in g && 'end' in g) {
|
|
106
|
+
// gr_circle
|
|
107
|
+
const c = g.center, e = g.end
|
|
108
|
+
out.push({ type: 'circle', c: [c.x, c.y], r: Math.hypot(e.x - c.x, e.y - c.y) })
|
|
109
|
+
} else if ('mid' in g && 'start' in g && 'end' in g) {
|
|
110
|
+
// gr_arc
|
|
111
|
+
out.push({ type: 'arc', a: [g.start.x, g.start.y], m: [g.mid.x, g.mid.y], b: [g.end.x, g.end.y] })
|
|
112
|
+
} else if ('start' in g && 'end' in g) {
|
|
113
|
+
const a = g.start, b = g.end
|
|
114
|
+
if ('fill' in g) {
|
|
115
|
+
// gr_rect (fill key distinguishes it from gr_line) → 4 edges
|
|
116
|
+
out.push(
|
|
117
|
+
{ type: 'line', a: [a.x, a.y], b: [b.x, a.y] },
|
|
118
|
+
{ type: 'line', a: [b.x, a.y], b: [b.x, b.y] },
|
|
119
|
+
{ type: 'line', a: [b.x, b.y], b: [a.x, b.y] },
|
|
120
|
+
{ type: 'line', a: [a.x, b.y], b: [a.x, a.y] },
|
|
121
|
+
)
|
|
122
|
+
} else {
|
|
123
|
+
// gr_line
|
|
124
|
+
out.push({ type: 'line', a: [a.x, a.y], b: [b.x, b.y] })
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return out
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Board-level silkscreen text (gr_text only). I_Text.layer is an OBJECT
|
|
133
|
+
* `{ name, knockout }` — not a string. Dimensions (I_Dimension) nest their text
|
|
134
|
+
* under `.gr_text` and never carry a top-level `text` string, but we exclude
|
|
135
|
+
* them defensively anyway.
|
|
136
|
+
*/
|
|
137
|
+
function adaptTexts(drawings: NonNullable<I_KicadPCB['drawings']>): BoardText[] {
|
|
138
|
+
const out: BoardText[] = []
|
|
139
|
+
for (const d of drawings ?? []) {
|
|
140
|
+
if (!d) continue
|
|
141
|
+
if ('gr_text' in d) continue // I_Dimension — its text lives nested, not a board-level silk item
|
|
142
|
+
if (!('text' in d) || typeof d.text !== 'string' || !d.text) continue
|
|
143
|
+
const g = d as boardProto.I_GrText
|
|
144
|
+
const layer = typeof g.layer === 'string' ? g.layer : g.layer?.name ?? ''
|
|
145
|
+
if (!layer.includes('SilkS')) continue
|
|
146
|
+
out.push({
|
|
147
|
+
text: g.text,
|
|
148
|
+
x: g.at?.position?.x ?? 0,
|
|
149
|
+
y: g.at?.position?.y ?? 0,
|
|
150
|
+
rot: g.at?.rotation ?? 0,
|
|
151
|
+
size: g.effects?.font?.size?.x ?? 1,
|
|
152
|
+
layer,
|
|
153
|
+
})
|
|
154
|
+
}
|
|
155
|
+
return out
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** copper layer names in stack order: F.Cu, In1..InN, B.Cu. (I_Layer.canonical_name — snake_case!) */
|
|
159
|
+
function cuLayerOrder(layers: I_KicadPCB['layers']): string[] {
|
|
160
|
+
const names = (layers ?? [])
|
|
161
|
+
.map((l) => l?.canonical_name ?? '')
|
|
162
|
+
.filter((n): n is string => typeof n === 'string' && n.endsWith('.Cu'))
|
|
163
|
+
const rank = (s: string) => (s === 'F.Cu' ? 0 : s === 'B.Cu' ? 100 : 1 + (parseInt(s.replace(/\D+/g, ''), 10) || 0))
|
|
164
|
+
names.sort((a, b) => rank(a) - rank(b))
|
|
165
|
+
return names.length >= 2 ? names : ['F.Cu', 'B.Cu']
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Adapt a parsed `.kicad_pcb` (`I_KicadPCB`) into the flat board model.
|
|
170
|
+
* Semantic parity with the retired parseKicad pipeline is enforced by
|
|
171
|
+
* test/adapter.test.ts (golden values from real fixtures).
|
|
172
|
+
*/
|
|
173
|
+
export function adaptBoard(board: I_KicadPCB): BoardModel {
|
|
174
|
+
const nets = new Map<number, string>()
|
|
175
|
+
for (const n of board.nets ?? []) if (n && typeof n.number === 'number') nets.set(n.number, n.name ?? '')
|
|
176
|
+
|
|
177
|
+
const comps = (board.footprints ?? []).map((fp) => adaptFootprint(fp, nets))
|
|
178
|
+
|
|
179
|
+
const traces: BoardTrace[] = (board.segments ?? []).map((s) => {
|
|
180
|
+
if ('mid' in s) {
|
|
181
|
+
return { pts: [[s.start.x, s.start.y], [s.mid.x, s.mid.y], [s.end.x, s.end.y]], w: s.width ?? 0.25, layer: s.layer ?? 'F.Cu', arc: true }
|
|
182
|
+
}
|
|
183
|
+
return { pts: [[s.start.x, s.start.y], [s.end.x, s.end.y]], w: s.width ?? 0.25, layer: s.layer ?? 'F.Cu' }
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
// zones: EVERY polygon child becomes a zone (not just the first); arc
|
|
187
|
+
// vertices are filtered (the JS parser's `xy[0]==='xy'` test) — otherwise a
|
|
188
|
+
// NaN coordinate would nuke the whole pour on the canvas.
|
|
189
|
+
const zones: BoardZone[] = []
|
|
190
|
+
for (const z of board.zones ?? []) {
|
|
191
|
+
for (const poly of z.polygons ?? []) {
|
|
192
|
+
const pts: [number, number][] = []
|
|
193
|
+
for (const p of poly?.pts ?? []) {
|
|
194
|
+
if (typeof (p as { x?: unknown }).x === 'number' && typeof (p as { y?: unknown }).y === 'number') {
|
|
195
|
+
const pt = p as { x: number; y: number }
|
|
196
|
+
pts.push([pt.x, pt.y])
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if (pts.length >= 3) {
|
|
200
|
+
zones.push({
|
|
201
|
+
pts,
|
|
202
|
+
layer: z.layer ?? 'F.Cu',
|
|
203
|
+
layers: z.layers ?? (z.layer ? [z.layer] : ['F.Cu']),
|
|
204
|
+
net: z.net_name ?? '',
|
|
205
|
+
})
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const vias: BoardVia[] = (board.vias ?? []).map((v) => ({
|
|
211
|
+
x: +(v.at?.position?.x ?? 0).toFixed(3),
|
|
212
|
+
y: +(v.at?.position?.y ?? 0).toFixed(3),
|
|
213
|
+
size: v.size ?? 0.8,
|
|
214
|
+
drill: v.drill ?? 0.4,
|
|
215
|
+
}))
|
|
216
|
+
|
|
217
|
+
const outline = adaptOutline(board.drawings ?? [])
|
|
218
|
+
const texts = adaptTexts(board.drawings ?? [])
|
|
219
|
+
|
|
220
|
+
// bbox: outline extents, else pads/extents fallback
|
|
221
|
+
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity
|
|
222
|
+
const eat = (x: number, y: number) => { x0 = Math.min(x0, x); y0 = Math.min(y0, y); x1 = Math.max(x1, x); y1 = Math.max(y1, y) }
|
|
223
|
+
if (outline.length) {
|
|
224
|
+
for (const o of outline) {
|
|
225
|
+
if (o.type === 'circle') { eat(o.c[0] - o.r, o.c[1] - o.r); eat(o.c[0] + o.r, o.c[1] + o.r) }
|
|
226
|
+
else { eat(o.a[0], o.a[1]); eat(o.b[0], o.b[1]); if (o.type === 'arc') eat(o.m[0], o.m[1]) }
|
|
227
|
+
}
|
|
228
|
+
} else {
|
|
229
|
+
for (const c of comps) for (const p of c.pads) eat(p.x, p.y)
|
|
230
|
+
if (x0 === Infinity) { x0 = 0; y0 = 0; x1 = 10; y1 = 10 }
|
|
231
|
+
x0 -= 3; y0 -= 3; x1 += 3; y1 += 3
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return { outline, bbox: { x0, y0, x1, y1 }, cuLayers: cuLayerOrder(board.layers), comps, traces, zones, vias, texts }
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export { BoardParser }
|