@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.
@@ -0,0 +1,301 @@
1
+ // ---------------------------------------------------------------------------
2
+ // view2d.ts — KiCad-editor-style 2D top view of a parsed board (canvas 2D).
3
+ // Shows what the 3D mask hides: real traces, zones, vias, pads, silkscreen.
4
+ // const v2d = create2DView(canvas) → { setBoard(parsedBoard), redraw(), destroy() }
5
+ // Coordinate convention: KiCad +y is DOWN — same as canvas, so no mirroring needed.
6
+ // ---------------------------------------------------------------------------
7
+ import type { BoardModel } from '../model.js'
8
+ import type { OutlineParts } from '../pcb/outline.js'
9
+
10
+ type Pt = [number, number]
11
+ type Ctx = CanvasRenderingContext2D
12
+ type Board2D = BoardModel & { _outlineParts?: OutlineParts }
13
+
14
+ const LAYER_COLORS = [
15
+ '#d6483c', // F.Cu — red (KiCad convention)
16
+ '#3d6fd6', // B.Cu — blue
17
+ '#c9a227', '#2aa198', '#b58900', '#6c71c4', '#d33682', '#859900',
18
+ ]
19
+ export function layerColor(name: string, cuLayers: string[]): string {
20
+ if (name === 'F.Cu') return LAYER_COLORS[0]!
21
+ if (name === 'B.Cu') return LAYER_COLORS[1]!
22
+ const i = Math.max(0, cuLayers.indexOf(name) - 1)
23
+ return LAYER_COLORS[2 + (i % (LAYER_COLORS.length - 2))]!
24
+ }
25
+
26
+ function circleThrough3(a: Pt, m: Pt, b: Pt): { cx: number; cy: number; r: number } | null {
27
+ const d = 2 * (a[0] * (m[1] - b[1]) + m[0] * (b[1] - a[1]) + b[0] * (a[1] - m[1]))
28
+ if (Math.abs(d) < 1e-9) return null
29
+ const ux = ((a[0] ** 2 + a[1] ** 2) * (m[1] - b[1]) + (m[0] ** 2 + m[1] ** 2) * (b[1] - a[1]) + (b[0] ** 2 + b[1] ** 2) * (a[1] - m[1])) / d
30
+ const uy = ((a[0] ** 2 + a[1] ** 2) * (b[0] - m[0]) + (m[0] ** 2 + m[1] ** 2) * (a[0] - b[0]) + (b[0] ** 2 + b[1] ** 2) * (m[0] - a[0])) / d
31
+ return { cx: ux, cy: uy, r: Math.hypot(a[0] - ux, a[1] - uy) }
32
+ }
33
+
34
+ export interface View2D {
35
+ setBoard(parsed: BoardModel, outline: OutlineParts): void
36
+ redraw(): void
37
+ destroy(): void
38
+ }
39
+
40
+ export function create2DView(canvas: HTMLCanvasElement): View2D {
41
+ const ctx = canvas.getContext('2d')!
42
+ let board: Board2D | null = null
43
+ let scale = 1, ox = 0, oy = 0 // mm → px transform: px = mm*scale + o
44
+ let dragging = false, lx = 0, ly = 0
45
+ let destroyed = false
46
+ // 离屏缓存:同一缩放级别下平移只做一次 drawImage(大板平移从"全量重画"变"贴图")
47
+ let cache: HTMLCanvasElement | null = null
48
+ let cacheScale = -1, cacheOx = 0, cacheOy = 0
49
+
50
+ const px = (x: number) => (x - board!.bbox.x0) * scale + ox
51
+ const py = (y: number) => (y - board!.bbox.y0) * scale + oy
52
+
53
+ function fit() {
54
+ if (!board) return
55
+ const W = board.bbox.x1 - board.bbox.x0, H = board.bbox.y1 - board.bbox.y0
56
+ const pad = 28
57
+ // 面板可能被隐藏(0×0)——scale 必须保持正数,否则 arc() 会收到负半径
58
+ scale = Math.max(0.01, Math.min((canvas.width - pad * 2) / W, (canvas.height - pad * 2) / H))
59
+ ox = (canvas.width - W * scale) / 2
60
+ oy = (canvas.height - H * scale) / 2
61
+ }
62
+
63
+ function drawArc(ctx: Ctx, a: Pt, m: Pt, b: Pt, w: number, color: string) {
64
+ const c = circleThrough3(a, m, b)
65
+ ctx.strokeStyle = color
66
+ ctx.lineWidth = w
67
+ if (!c) { ctx.beginPath(); ctx.moveTo(px(a[0]), py(a[1])); ctx.lineTo(px(b[0]), py(b[1])); ctx.stroke(); return }
68
+ const a0 = Math.atan2(a[1] - c.cy, a[0] - c.cx)
69
+ const a1 = Math.atan2(b[1] - c.cy, b[0] - c.cx)
70
+ const am = Math.atan2(m[1] - c.cy, m[0] - c.cx)
71
+ const norm = (t: number) => ((t % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI)
72
+ const ccw = norm(a1 - a0), mid = norm(am - a0)
73
+ const anticw = mid <= ccw
74
+ ctx.beginPath()
75
+ ctx.arc(px(c.cx), py(c.cy), c.r * scale, a0, a1, anticw)
76
+ ctx.stroke()
77
+ }
78
+
79
+ function renderBoard(ctx: Ctx) {
80
+ if (!board) return
81
+ const W = canvas.width, H = canvas.height
82
+ ctx.clearRect(0, 0, W, H)
83
+ // pane background
84
+ ctx.fillStyle = '#0b0e13'
85
+ ctx.fillRect(0, 0, W, H)
86
+
87
+ // board body — ring plus any Edge.Cuts holes as extra subpaths. Filling and
88
+ // clipping with 'evenodd' punches the holes out of the body AND out of
89
+ // everything drawn after (zones/traces/pads all inherit this clip).
90
+ const parts = board._outlineParts
91
+ const poly = parts?.ring || []
92
+ const holes = parts?.holes || []
93
+ const bodyPath = new Path2D()
94
+ const sub = (pts: Pt[]) => {
95
+ pts.forEach((p, i) => { const X = px(p[0]), Y = py(p[1]); if (i === 0) bodyPath.moveTo(X, Y); else bodyPath.lineTo(X, Y) })
96
+ bodyPath.closePath()
97
+ }
98
+ sub(poly)
99
+ for (const h of holes) sub(h)
100
+ ctx.save()
101
+ ctx.fillStyle = '#123822'
102
+ ctx.fill(bodyPath, 'evenodd')
103
+ ctx.clip(bodyPath, 'evenodd')
104
+
105
+ // zones (translucent pours) — batched per layer colour
106
+ const zonePaths = new Map<string, Path2D>()
107
+ for (const z of board.zones || []) {
108
+ const key = z.layer || 'F.Cu'
109
+ let path = zonePaths.get(key)
110
+ if (!path) { path = new Path2D(); zonePaths.set(key, path) }
111
+ z.pts.forEach((p, i) => { const X = px(p[0]), Y = py(p[1]); if (i === 0) path.moveTo(X, Y); else path.lineTo(X, Y) })
112
+ path.closePath()
113
+ }
114
+ for (const [layer, path] of zonePaths) {
115
+ ctx.fillStyle = layerColor(layer, board.cuLayers) + '2e'
116
+ ctx.fill(path)
117
+ }
118
+
119
+ // traces by layer — BATCHED: one Path2D per (layer, width bucket) instead of a
120
+ // beginPath/stroke per trace. Big boards carry tens of thousands of segments
121
+ // and the per-call canvas state churn dominated the 2D view cost.
122
+ ctx.lineCap = 'round'
123
+ ctx.lineJoin = 'round'
124
+ const tracePaths = new Map<string, Path2D>()
125
+ const widthOf = new Map<string, number>()
126
+ for (const t of board.traces || []) {
127
+ if (t.arc && t.pts.length === 3) continue // arcs drawn individually (rare)
128
+ const w = Math.max(t.w * scale, 1)
129
+ const key = (t.layer || 'F.Cu') + '|' + Math.round(w * 2) // bucket to 0.5px
130
+ let path = tracePaths.get(key)
131
+ if (!path) {
132
+ path = new Path2D()
133
+ tracePaths.set(key, path)
134
+ widthOf.set(key, w)
135
+ }
136
+ const pts = t.pts
137
+ const p0 = pts[0]
138
+ if (!p0) continue
139
+ path.moveTo(px(p0[0]), py(p0[1]))
140
+ for (let i = 1; i < pts.length; i++) {
141
+ const p = pts[i]!
142
+ path.lineTo(px(p[0]), py(p[1]))
143
+ }
144
+ }
145
+ for (const [key, path] of tracePaths) {
146
+ const layer = key.slice(0, key.lastIndexOf('|'))
147
+ ctx.strokeStyle = layerColor(layer, board.cuLayers)
148
+ ctx.lineWidth = widthOf.get(key)!
149
+ ctx.stroke(path)
150
+ }
151
+ // arcs (few) drawn with the per-arc path helper
152
+ for (const t of board.traces || []) {
153
+ if (!(t.arc && t.pts.length === 3)) continue
154
+ drawArc(ctx, t.pts[0]!, t.pts[1]!, t.pts[2]!, Math.max(t.w * scale, 1), layerColor(t.layer, board.cuLayers))
155
+ }
156
+
157
+ // vias — batched rings + holes
158
+ const viaRing = new Path2D(), viaHole = new Path2D()
159
+ for (const v of board.vias || []) {
160
+ const X = px(v.x), Y = py(v.y)
161
+ const rr = Math.max((v.size / 2) * scale, 1.2), hr = Math.max((v.drill / 2) * scale, 0.5)
162
+ viaRing.moveTo(X + rr, Y); viaRing.arc(X, Y, rr, 0, Math.PI * 2)
163
+ viaHole.moveTo(X + hr, Y); viaHole.arc(X, Y, hr, 0, Math.PI * 2)
164
+ }
165
+ ctx.fillStyle = '#c98a3a'; ctx.fill(viaRing)
166
+ ctx.fillStyle = '#0b0e13'; ctx.fill(viaHole)
167
+
168
+ // pads — gold; top pads solid, bottom-only pads outlined.
169
+ // BATCHED into four Path2D buckets (rect/ellipse × filled/outlined) + one
170
+ // fill/stroke each, same reason as the traces above.
171
+ const padRectFill = new Path2D(), padRectStroke = new Path2D()
172
+ const padEllFill = new Path2D(), padEllStroke = new Path2D()
173
+ for (const c of board.comps || []) {
174
+ for (const p of c.pads || []) {
175
+ const X = px(p.x), Y = py(p.y)
176
+ const w = Math.max(p.w * scale, 1.6), l = Math.max(p.l * scale, 1.6)
177
+ const solid = p.top || p.th
178
+ if (p.shape === 'circle' || p.shape === 'oval') {
179
+ const target = solid ? padEllFill : padEllStroke
180
+ target.moveTo(X + w / 2, Y)
181
+ target.ellipse(X, Y, w / 2, l / 2, 0, 0, Math.PI * 2)
182
+ } else {
183
+ const target = solid ? padRectFill : padRectStroke
184
+ target.rect(X - w / 2, Y - l / 2, w, l)
185
+ }
186
+ }
187
+ }
188
+ ctx.fillStyle = '#e8c35a'
189
+ ctx.strokeStyle = '#e8c35a'
190
+ ctx.fill(padRectFill); ctx.fill(padEllFill)
191
+ ctx.lineWidth = 1
192
+ ctx.stroke(padRectStroke); ctx.stroke(padEllStroke)
193
+
194
+ // silkscreen board texts
195
+ ctx.fillStyle = 'rgba(235,242,246,0.85)'
196
+ ctx.textAlign = 'center'
197
+ ctx.textBaseline = 'middle'
198
+ for (const t of board.texts || []) {
199
+ if ((t.layer || '') !== 'F.SilkS') continue
200
+ ctx.save()
201
+ ctx.translate(px(t.x), py(t.y))
202
+ ctx.rotate(((t.rot || 0) * Math.PI) / 180)
203
+ ctx.font = `600 ${Math.max(t.size * scale, 6)}px ui-monospace, Menlo, monospace`
204
+ ctx.fillText(t.text || '', 0, 0)
205
+ ctx.restore()
206
+ }
207
+ // ref designators — font/style set once, per-label transform only
208
+ ctx.font = `600 ${Math.max(1 * scale, 5)}px ui-monospace, Menlo, monospace`
209
+ ctx.fillStyle = 'rgba(235,242,246,0.55)'
210
+ const refY = -Math.max(2.2 * scale, 7)
211
+ for (const c of board.comps || []) {
212
+ if (!c.ref || (c.layer || 'F.Cu') === 'B.Cu' || /^M[23]/.test(c.fp || '')) continue
213
+ ctx.save()
214
+ ctx.translate(px(c.x), py(c.y))
215
+ if (c.rot) ctx.rotate(((c.rot || 0) * Math.PI) / 180)
216
+ ctx.fillText(c.ref, 0, refY)
217
+ ctx.restore()
218
+ }
219
+ ctx.restore()
220
+
221
+ // outline stroke — the board edge and every cutout edge
222
+ ctx.strokeStyle = 'rgba(95,224,205,0.5)'
223
+ ctx.lineWidth = 1.2
224
+ ctx.stroke(bodyPath)
225
+ }
226
+
227
+ function redraw() {
228
+ if (!board || destroyed) return
229
+ if (canvas.width < 8 || canvas.height < 8) return // 隐藏中:不排版不绘制
230
+ const W = canvas.width, H = canvas.height
231
+ if (!cache || cache.width !== W || cache.height !== H) {
232
+ cache = document.createElement('canvas')
233
+ cache.width = W
234
+ cache.height = H
235
+ cacheScale = -1 // 尺寸变了,缓存失效
236
+ }
237
+ if (cacheScale !== scale) { // 只有缩放级别变了才重画
238
+ renderBoard(cache.getContext('2d')!)
239
+ cacheScale = scale; cacheOx = ox; cacheOy = oy
240
+ }
241
+ ctx.clearRect(0, 0, W, H)
242
+ ctx.fillStyle = '#0b0e13'
243
+ ctx.fillRect(0, 0, W, H)
244
+ ctx.drawImage(cache, Math.round(ox - cacheOx), Math.round(oy - cacheOy))
245
+ }
246
+
247
+ // pan / zoom
248
+ function onDown(e: PointerEvent) { dragging = true; lx = e.clientX; ly = e.clientY }
249
+ function onMove(e: PointerEvent) {
250
+ if (!dragging) return
251
+ ox += e.clientX - lx; oy += e.clientY - ly
252
+ lx = e.clientX; ly = e.clientY
253
+ redraw()
254
+ }
255
+ function onUp() { dragging = false }
256
+ function onWheel(e: WheelEvent) {
257
+ e.preventDefault()
258
+ const r = canvas.getBoundingClientRect()
259
+ const mx = e.clientX - r.left, my = e.clientY - r.top
260
+ const k = e.deltaY < 0 ? 1.12 : 0.89
261
+ scale = Math.min(Math.max(scale * k, 0.05), 200)
262
+ ox = mx - (mx - ox) * k
263
+ oy = my - (my - oy) * k
264
+ redraw()
265
+ }
266
+ function onResize() {
267
+ const r = canvas.getBoundingClientRect()
268
+ const dpr = Math.min(window.devicePixelRatio || 1, 2)
269
+ canvas.width = Math.max(1, Math.round(r.width * dpr))
270
+ canvas.height = Math.max(1, Math.round(r.height * dpr))
271
+ if (r.width < 8 || r.height < 8) return // 面板隐藏(如 3D-only 模式)
272
+ fit(); redraw()
273
+ }
274
+
275
+ canvas.addEventListener('pointerdown', onDown as EventListener)
276
+ window.addEventListener('pointermove', onMove as EventListener)
277
+ window.addEventListener('pointerup', onUp)
278
+ canvas.addEventListener('wheel', onWheel, { passive: false })
279
+ const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(onResize) : null
280
+ ro && ro.observe(canvas)
281
+
282
+ return {
283
+ setBoard(parsed: BoardModel, outline: OutlineParts) {
284
+ board = parsed
285
+ board._outlineParts = outline
286
+ cacheScale = -1 // 换板:缓存必须失效(否则同尺寸板可能残留上一块的内容)
287
+ cacheOx = 0
288
+ cacheOy = 0
289
+ onResize() // fits + redraws
290
+ },
291
+ redraw,
292
+ destroy() {
293
+ destroyed = true
294
+ canvas.removeEventListener('pointerdown', onDown as EventListener)
295
+ window.removeEventListener('pointermove', onMove as EventListener)
296
+ window.removeEventListener('pointerup', onUp)
297
+ canvas.removeEventListener('wheel', onWheel)
298
+ ro && ro.disconnect()
299
+ },
300
+ }
301
+ }