@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
|
@@ -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
|
+
}
|