@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/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 }
@@ -0,0 +1,2 @@
1
+ declare const demoPcb: string
2
+ export default demoPcb