@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,286 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// parseKicad.ts — parse a .kicad_pcb file (KiCad 7/8/9/10) into the board model
|
|
3
|
+
// the renderer consumes. Self-contained: no dependencies, works in browser and
|
|
4
|
+
// Node.
|
|
5
|
+
//
|
|
6
|
+
// const board = parseKicad(fileText) → BoardModel
|
|
7
|
+
//
|
|
8
|
+
// Why this parser is the default engine: it stays linear as board size grows.
|
|
9
|
+
// The upstream path (./adapter.ts) scales worse on multi-megabyte boards, so it
|
|
10
|
+
// is kept as an alternative rather than the default. Both produce the same
|
|
11
|
+
// BoardModel and test/parity.test.ts keeps them in lockstep, which makes
|
|
12
|
+
// re-evaluating the choice a one-line edit in ./parse.ts.
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
import type { BoardModel, BoardComp, BoardPad, BoardTrace, BoardZone, BoardVia, OutlineSeg, BoardText } from '../model.js'
|
|
15
|
+
|
|
16
|
+
type Sexp = string | Sexp[]
|
|
17
|
+
|
|
18
|
+
// ---------- S-expression tokenizer + parser ----------
|
|
19
|
+
function tokenize(src: string): string[] {
|
|
20
|
+
const toks: string[] = []
|
|
21
|
+
let i = 0
|
|
22
|
+
const n = src.length
|
|
23
|
+
while (i < n) {
|
|
24
|
+
const c = src[i]
|
|
25
|
+
if (c === ' ' || c === '\t' || c === '\n' || c === '\r') { i++; continue }
|
|
26
|
+
if (c === ';') { while (i < n && src[i] !== '\n') i++; continue }
|
|
27
|
+
if (c === '(' || c === ')') { toks.push(c); i++; continue }
|
|
28
|
+
if (c === '"') {
|
|
29
|
+
let j = i + 1
|
|
30
|
+
let s = ''
|
|
31
|
+
while (j < n && src[j] !== '"') {
|
|
32
|
+
if (src[j] === '\\') {
|
|
33
|
+
// KiCad escapes: \n \t \" \\ — decode them like the upstream parser does
|
|
34
|
+
// (the old literal-copy behaviour turned "\n" into "n").
|
|
35
|
+
const e = src[j + 1]
|
|
36
|
+
s += e === 'n' ? '\n' : e === 't' ? '\t' : e === 'r' ? '\r' : (e ?? '')
|
|
37
|
+
j += 2
|
|
38
|
+
continue
|
|
39
|
+
}
|
|
40
|
+
s += src[j]!
|
|
41
|
+
j++
|
|
42
|
+
}
|
|
43
|
+
toks.push('"' + s); i = j + 1; continue
|
|
44
|
+
}
|
|
45
|
+
// bare atom
|
|
46
|
+
let j = i
|
|
47
|
+
while (j < n && !' \t\n\r()'.includes(src[j]!)) j++
|
|
48
|
+
toks.push(src.slice(i, j)); i = j
|
|
49
|
+
}
|
|
50
|
+
return toks
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function parseSexp(toks: string[]): Sexp {
|
|
54
|
+
let pos = 0
|
|
55
|
+
function parse(): Sexp {
|
|
56
|
+
if (toks[pos] === '(') {
|
|
57
|
+
pos++
|
|
58
|
+
const list: Sexp[] = []
|
|
59
|
+
while (pos < toks.length && toks[pos] !== ')') list.push(parse())
|
|
60
|
+
pos++ // consume ')'
|
|
61
|
+
return list
|
|
62
|
+
}
|
|
63
|
+
return toks[pos++] ?? ''
|
|
64
|
+
}
|
|
65
|
+
const roots: Sexp[] = []
|
|
66
|
+
while (pos < toks.length) roots.push(parse())
|
|
67
|
+
return roots[0] ?? []
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ---------- helpers ----------
|
|
71
|
+
const num = (v: Sexp | undefined): number => (typeof v === 'string' ? parseFloat(v) : NaN)
|
|
72
|
+
const atom = (v: Sexp | undefined): string => (typeof v === 'string' ? v.replace(/^"/, '') : '')
|
|
73
|
+
/** find first child list whose head matches */
|
|
74
|
+
function child(list: Sexp[], head: string): Sexp[] | null {
|
|
75
|
+
for (const c of list) if (Array.isArray(c) && c[0] === head) return c
|
|
76
|
+
return null
|
|
77
|
+
}
|
|
78
|
+
function children(list: Sexp[], head: string): Sexp[][] {
|
|
79
|
+
const out: Sexp[][] = []
|
|
80
|
+
for (const c of list) if (Array.isArray(c) && c[0] === head) out.push(c)
|
|
81
|
+
return out
|
|
82
|
+
}
|
|
83
|
+
/** (at x y [rot]) → {x,y,rot} */
|
|
84
|
+
function parseAt(node: Sexp[] | null): { x: number; y: number; rot: number } {
|
|
85
|
+
if (!node) return { x: 0, y: 0, rot: 0 }
|
|
86
|
+
return { x: num(node[1]), y: num(node[2]), rot: node[3] !== undefined ? num(node[3]) : 0 }
|
|
87
|
+
}
|
|
88
|
+
const rad = (d: number) => (d * Math.PI) / 180
|
|
89
|
+
/** rotate local point by deg then translate */
|
|
90
|
+
function xform(lx: number, ly: number, fp: { x: number; y: number; rot: number }): [number, number] {
|
|
91
|
+
const r = rad(fp.rot), cs = Math.cos(r), sn = Math.sin(r)
|
|
92
|
+
return [fp.x + lx * cs - ly * sn, fp.y + lx * sn + ly * cs]
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ---------- main ----------
|
|
96
|
+
export function parseKicad(src: string): BoardModel {
|
|
97
|
+
const root = parseSexp(tokenize(src))
|
|
98
|
+
if (!Array.isArray(root)) throw new Error('not a valid .kicad_pcb')
|
|
99
|
+
|
|
100
|
+
// --- net id → name map (top-level (net id "name")) ---
|
|
101
|
+
const netName = new Map<number, string>()
|
|
102
|
+
for (const n of children(root, 'net')) netName.set(num(n[1]), atom(n[2]))
|
|
103
|
+
|
|
104
|
+
// --- copper layer order ---
|
|
105
|
+
let cuLayers: string[] = []
|
|
106
|
+
const layersNode = child(root, 'layers')
|
|
107
|
+
if (layersNode) {
|
|
108
|
+
for (const L of layersNode.slice(1)) {
|
|
109
|
+
const nm = Array.isArray(L) ? atom(L[1]) : ''
|
|
110
|
+
if (nm.endsWith('.Cu')) cuLayers.push(nm)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
// canonical order: F.Cu, In1..InN, B.Cu
|
|
114
|
+
cuLayers.sort((a, b) => {
|
|
115
|
+
const rank = (s: string) => (s === 'F.Cu' ? 0 : s === 'B.Cu' ? 100 : 1 + (parseInt(s.replace(/\D+/g, '')) || 0))
|
|
116
|
+
return rank(a) - rank(b)
|
|
117
|
+
})
|
|
118
|
+
if (cuLayers.length < 2) cuLayers = ['F.Cu', 'B.Cu']
|
|
119
|
+
|
|
120
|
+
// --- board outline (Edge.Cuts graphics) ---
|
|
121
|
+
const outline: OutlineSeg[] = []
|
|
122
|
+
const collectCuts = (nodes: Sexp[][], kind: 'line' | 'arc' | 'rect' | 'circle') => {
|
|
123
|
+
for (const g of nodes) {
|
|
124
|
+
const layerN = child(g, 'layer')
|
|
125
|
+
if (!layerN || atom(layerN[1]) !== 'Edge.Cuts') continue
|
|
126
|
+
if (kind === 'line') {
|
|
127
|
+
const st = child(g, 'start')!, en = child(g, 'end')!
|
|
128
|
+
outline.push({ type: 'line', a: [num(st[1]), num(st[2])], b: [num(en[1]), num(en[2])] })
|
|
129
|
+
} else if (kind === 'arc') {
|
|
130
|
+
const st = child(g, 'start')!, md = child(g, 'mid')!, en = child(g, 'end')!
|
|
131
|
+
outline.push({ type: 'arc', a: [num(st[1]), num(st[2])], m: [num(md[1]), num(md[2])], b: [num(en[1]), num(en[2])] })
|
|
132
|
+
} else if (kind === 'rect') {
|
|
133
|
+
const st = child(g, 'start')!, en = child(g, 'end')!
|
|
134
|
+
const a: [number, number] = [num(st[1]), num(st[2])]
|
|
135
|
+
const b: [number, number] = [num(en[1]), num(en[2])]
|
|
136
|
+
outline.push(
|
|
137
|
+
{ type: 'line', a, b: [b[0], a[1]] },
|
|
138
|
+
{ type: 'line', a: [b[0], a[1]], b },
|
|
139
|
+
{ type: 'line', a: b, b: [a[0], b[1]] },
|
|
140
|
+
{ type: 'line', a: [a[0], b[1]], b: a },
|
|
141
|
+
)
|
|
142
|
+
} else {
|
|
143
|
+
const cN = child(g, 'center')!, eN = child(g, 'end')!
|
|
144
|
+
const c: [number, number] = [num(cN[1]), num(cN[2])]
|
|
145
|
+
const e: [number, number] = [num(eN[1]), num(eN[2])]
|
|
146
|
+
outline.push({ type: 'circle', c, r: Math.hypot(e[0] - c[0], e[1] - c[1]) })
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
// document order (not per-kind): the stitching downstream is order-sensitive,
|
|
151
|
+
// and this keeps both parse engines feeding it identical input.
|
|
152
|
+
for (const node of root) {
|
|
153
|
+
if (!Array.isArray(node)) continue
|
|
154
|
+
if (node[0] === 'gr_line') collectCuts([node], 'line')
|
|
155
|
+
else if (node[0] === 'gr_arc') collectCuts([node], 'arc')
|
|
156
|
+
else if (node[0] === 'gr_rect') collectCuts([node], 'rect')
|
|
157
|
+
else if (node[0] === 'gr_circle') collectCuts([node], 'circle')
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// --- footprints ---
|
|
161
|
+
const comps: BoardComp[] = []
|
|
162
|
+
for (const fpNode of children(root, 'footprint')) {
|
|
163
|
+
const fp = atom(fpNode[1])
|
|
164
|
+
const layerNode = child(fpNode, 'layer')
|
|
165
|
+
const layer = layerNode ? atom(layerNode[1]) : 'F.Cu'
|
|
166
|
+
const at = parseAt(child(fpNode, 'at'))
|
|
167
|
+
// reference
|
|
168
|
+
let ref = ''
|
|
169
|
+
for (const p of children(fpNode, 'property')) if (atom(p[1]) === 'Reference') ref = atom(p[2])
|
|
170
|
+
if (!ref) { const fpText = child(fpNode, 'fp_text'); if (fpText && atom(fpText[1]) === 'reference') ref = atom(fpText[2]) }
|
|
171
|
+
|
|
172
|
+
const pads: BoardPad[] = []
|
|
173
|
+
for (const pad of children(fpNode, 'pad')) {
|
|
174
|
+
const pAt = parseAt(child(pad, 'at'))
|
|
175
|
+
const sizeN = child(pad, 'size')
|
|
176
|
+
const w = sizeN ? num(sizeN[1]) : 1
|
|
177
|
+
const l = sizeN ? num(sizeN[2]) : 1
|
|
178
|
+
const layersN = child(pad, 'layers')
|
|
179
|
+
const layers = layersN ? layersN.slice(1).map(atom) : []
|
|
180
|
+
const netN = child(pad, 'net')
|
|
181
|
+
const net = netN ? (netName.get(num(netN[1])) ?? atom(netN[2]) ?? '') : ''
|
|
182
|
+
const th = pad[2] === 'thru_hole'
|
|
183
|
+
const shape = typeof pad[3] === 'string' ? pad[3] : 'rect'
|
|
184
|
+
// absolute position: pad local (possibly with own rotation) transformed by footprint
|
|
185
|
+
const [ax, ay] = xform(pAt.x, pAt.y, at)
|
|
186
|
+
// pad own rotation + footprint rotation decides w/l orientation
|
|
187
|
+
const totalRot = at.rot + pAt.rot
|
|
188
|
+
const swap = Math.abs(((totalRot % 180) + 180) % 180 - 90) < 45
|
|
189
|
+
pads.push({
|
|
190
|
+
x: +ax.toFixed(4), y: +ay.toFixed(4),
|
|
191
|
+
w: +(swap ? l : w).toFixed(4), l: +(swap ? w : l).toFixed(4),
|
|
192
|
+
shape, net,
|
|
193
|
+
top: th || layers.includes('F.Cu') || layers.includes('*.Cu'),
|
|
194
|
+
bottom: th || layers.includes('B.Cu') || layers.includes('*.Cu'),
|
|
195
|
+
th,
|
|
196
|
+
})
|
|
197
|
+
}
|
|
198
|
+
comps.push({ ref, x: +at.x.toFixed(4), y: +at.y.toFixed(4), rot: +at.rot.toFixed(2), layer, fp, pads })
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// --- track segments (document order: segments and arcs interleaved, matching
|
|
202
|
+
// the upstream parser and the file's own draw order) ---
|
|
203
|
+
const traces: BoardTrace[] = []
|
|
204
|
+
for (const node of root) {
|
|
205
|
+
if (!Array.isArray(node)) continue
|
|
206
|
+
if (node[0] === 'segment') {
|
|
207
|
+
const st = child(node, 'start')!, en = child(node, 'end')!
|
|
208
|
+
const layerN = child(node, 'layer')
|
|
209
|
+
const widthN = child(node, 'width')
|
|
210
|
+
traces.push({
|
|
211
|
+
pts: [[num(st[1]), num(st[2])], [num(en[1]), num(en[2])]],
|
|
212
|
+
w: widthN ? num(widthN[1]) : 0.25,
|
|
213
|
+
layer: layerN ? atom(layerN[1]) : 'F.Cu',
|
|
214
|
+
})
|
|
215
|
+
} else if (node[0] === 'arc') {
|
|
216
|
+
const st = child(node, 'start')!, mid = child(node, 'mid')!, en = child(node, 'end')!
|
|
217
|
+
const layerN = child(node, 'layer')
|
|
218
|
+
const widthN = child(node, 'width')
|
|
219
|
+
traces.push({
|
|
220
|
+
arc: true,
|
|
221
|
+
pts: [[num(st[1]), num(st[2])], [num(mid[1]), num(mid[2])], [num(en[1]), num(en[2])]],
|
|
222
|
+
w: widthN ? num(widthN[1]) : 0.25,
|
|
223
|
+
layer: layerN ? atom(layerN[1]) : 'F.Cu',
|
|
224
|
+
})
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// --- zones (copper pours) ---
|
|
229
|
+
const zones: BoardZone[] = []
|
|
230
|
+
for (const z of children(root, 'zone')) {
|
|
231
|
+
const layerN = child(z, 'layer')
|
|
232
|
+
const layersN = child(z, 'layers')
|
|
233
|
+
const netN = child(z, 'net_name')
|
|
234
|
+
const layerNames = layersN ? layersN.slice(1).map(atom) : [layerN ? atom(layerN[1]) : 'F.Cu']
|
|
235
|
+
for (const poly of children(z, 'polygon')) {
|
|
236
|
+
const ptsN = child(poly, 'pts')
|
|
237
|
+
if (!ptsN) continue
|
|
238
|
+
const pts: [number, number][] = []
|
|
239
|
+
for (const xy of ptsN.slice(1)) {
|
|
240
|
+
if (Array.isArray(xy) && xy[0] === 'xy') pts.push([num(xy[1]), num(xy[2])])
|
|
241
|
+
}
|
|
242
|
+
if (pts.length >= 3) zones.push({ pts, layer: layerNames[0] ?? 'F.Cu', layers: layerNames, net: netN ? atom(netN[1]) : '' })
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// --- vias ---
|
|
247
|
+
const vias: BoardVia[] = []
|
|
248
|
+
for (const v of children(root, 'via')) {
|
|
249
|
+
const at = parseAt(child(v, 'at'))
|
|
250
|
+
const sizeN = child(v, 'size')
|
|
251
|
+
const drillN = child(v, 'drill')
|
|
252
|
+
vias.push({
|
|
253
|
+
x: +at.x.toFixed(3), y: +at.y.toFixed(3),
|
|
254
|
+
size: sizeN ? num(sizeN[1]) : 0.8,
|
|
255
|
+
drill: drillN ? num(drillN[1]) : 0.4,
|
|
256
|
+
})
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// --- silkscreen text on the board (gr_text on *.SilkS) ---
|
|
260
|
+
const texts: BoardText[] = []
|
|
261
|
+
for (const t of children(root, 'gr_text')) {
|
|
262
|
+
const layerN = child(t, 'layer')
|
|
263
|
+
const lyr = layerN ? atom(layerN[1]) : ''
|
|
264
|
+
if (!lyr.includes('SilkS')) continue
|
|
265
|
+
const at = parseAt(child(t, 'at'))
|
|
266
|
+
const effects = child(t, 'effects')
|
|
267
|
+
const font = effects ? child(effects, 'font') : null
|
|
268
|
+
const sizeN = font ? child(font, 'size') : null
|
|
269
|
+
texts.push({ text: atom(t[1]), x: at.x, y: at.y, rot: at.rot, size: sizeN ? num(sizeN[1]) : 1, layer: lyr })
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// --- bbox: prefer outline, else pads/extents ---
|
|
273
|
+
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity
|
|
274
|
+
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) }
|
|
275
|
+
if (outline.length) {
|
|
276
|
+
for (const o of outline) {
|
|
277
|
+
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) }
|
|
278
|
+
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]) }
|
|
279
|
+
}
|
|
280
|
+
} else {
|
|
281
|
+
for (const c of comps) for (const p of c.pads) eat(p.x, p.y)
|
|
282
|
+
x0 -= 3; y0 -= 3; x1 += 3; y1 += 3
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return { outline, bbox: { x0, y0, x1, y1 }, cuLayers, comps, traces, zones, vias, texts }
|
|
286
|
+
}
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// buildBoard.ts — build an animated, explodable 3D board from PARSED KiCad data.
|
|
3
|
+
// Fully data-driven: arbitrary Edge.Cuts outline, N copper layers, real traces.
|
|
4
|
+
// const board = parseKicad(text)
|
|
5
|
+
// const b = buildBoard(board) // → { group, update(u, speed), boardW, boardH, comps, bbox, dispose() }
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
import * as THREE from 'three'
|
|
8
|
+
import { RoundedBoxGeometry } from 'three/addons/geometries/RoundedBoxGeometry.js'
|
|
9
|
+
import { M, grain } from './materials.js'
|
|
10
|
+
import { buildComponent } from './components.js'
|
|
11
|
+
import { makeBoardTextures } from './textures.js'
|
|
12
|
+
import { outlineParts } from '../pcb/outline.js'
|
|
13
|
+
import type { BoardModel, OutlineSeg } from '../model.js'
|
|
14
|
+
|
|
15
|
+
/** A 2D point in board millimetres (tuple so it round-trips through mirroring). */
|
|
16
|
+
type Pt = [number, number]
|
|
17
|
+
|
|
18
|
+
/** One sheet of the physical layer stack, before it becomes a mesh. */
|
|
19
|
+
type SheetKind = 'mask' | 'copper' | 'fr4' | 'inner'
|
|
20
|
+
interface Sheet {
|
|
21
|
+
id: string
|
|
22
|
+
t: number
|
|
23
|
+
kind: SheetKind
|
|
24
|
+
tex?: THREE.Texture | null
|
|
25
|
+
}
|
|
26
|
+
/** The resolved sheet plus the mesh it produced: z/ex/delay are all assigned here. */
|
|
27
|
+
interface LayerObj extends Sheet {
|
|
28
|
+
mesh: THREE.Mesh
|
|
29
|
+
baseZ: number
|
|
30
|
+
z: number
|
|
31
|
+
ex: number
|
|
32
|
+
delay: number
|
|
33
|
+
}
|
|
34
|
+
/** Per-component animation state, keys mirrored from buildComponent's userData. */
|
|
35
|
+
interface CompEntry {
|
|
36
|
+
g: THREE.Object3D
|
|
37
|
+
hx: number
|
|
38
|
+
hy: number
|
|
39
|
+
baseZ: number
|
|
40
|
+
qBase: THREE.Quaternion
|
|
41
|
+
lift: number
|
|
42
|
+
trigger: number
|
|
43
|
+
tiltAxis: THREE.Vector3
|
|
44
|
+
tiltMax: number
|
|
45
|
+
ref: string
|
|
46
|
+
}
|
|
47
|
+
/** The runtime board handle returned by buildBoard. */
|
|
48
|
+
export interface BuiltBoard {
|
|
49
|
+
group: THREE.Group
|
|
50
|
+
update(u: number, speed: number): void
|
|
51
|
+
boardW: number
|
|
52
|
+
boardH: number
|
|
53
|
+
comps: CompEntry[]
|
|
54
|
+
bbox: BoardModel['bbox']
|
|
55
|
+
layerCount: number
|
|
56
|
+
dispose(): void
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// deterministic pseudo-random + easing
|
|
60
|
+
function rnd(i: number, salt = 0): number { const x = Math.sin(i * 127.1 + salt * 311.7 + 13.7) * 43758.5453; return x - Math.floor(x) }
|
|
61
|
+
const clamp01 = (v: number): number => Math.min(1, Math.max(0, v))
|
|
62
|
+
const smooth = (v: number): number => { v = clamp01(v); return v * v * (3 - 2 * v) }
|
|
63
|
+
const easeOutCubic = (p: number): number => 1 - Math.pow(1 - p, 3)
|
|
64
|
+
const easeInOutCubic = (p: number): number => (p < 0.5 ? 4 * p * p * p : 1 - Math.pow(-2 * p + 2, 3) / 2)
|
|
65
|
+
|
|
66
|
+
// Keep the arc's `m` only when present — the typed counterpart of the original
|
|
67
|
+
// `...(o.m ? { m: [...] } : {})`.
|
|
68
|
+
function withMid(
|
|
69
|
+
line: { type: 'line'; a: Pt; b: Pt },
|
|
70
|
+
arc: { type: 'arc'; a: Pt; m: Pt; b: Pt } | null,
|
|
71
|
+
): OutlineSeg {
|
|
72
|
+
if (!arc) return { ...line, type: 'line' }
|
|
73
|
+
const { m, ...rest } = arc
|
|
74
|
+
return { ...rest, type: 'arc', m }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Mirror a parsed board across the X axis (y → −y), rotations reversed.
|
|
78
|
+
function mirrorY(b: BoardModel): BoardModel {
|
|
79
|
+
const fy = (y: number) => -y
|
|
80
|
+
return {
|
|
81
|
+
...b,
|
|
82
|
+
bbox: { x0: b.bbox.x0, y0: -b.bbox.y1, x1: b.bbox.x1, y1: -b.bbox.y0 },
|
|
83
|
+
// The `o.type === 'arc'` test narrows the union, so `o.m` reads exactly where
|
|
84
|
+
// the original read it; `withMid` is the typed `...(o.m ? { m: [...] } : {})`
|
|
85
|
+
// (so `m` enters the object only when the segment actually carries one).
|
|
86
|
+
// (each `o` is narrowed to a discriminated local first — a `{...o}` spread
|
|
87
|
+
// widens the discriminant, so the branches need their literal type spelled out)
|
|
88
|
+
outline: (b.outline || []).map((o): OutlineSeg => {
|
|
89
|
+
if (o.type === 'circle') return { ...o, type: 'circle', c: [o.c[0], fy(o.c[1])] as Pt }
|
|
90
|
+
const a = [o.a[0], fy(o.a[1])] as Pt, b = [o.b[0], fy(o.b[1])] as Pt
|
|
91
|
+
return o.type === 'arc' ? withMid({ ...o, type: 'line', a, b }, { ...o, type: 'arc', a, b, m: [o.m[0], fy(o.m[1])] as Pt })
|
|
92
|
+
: withMid({ ...o, type: 'line', a, b }, null)
|
|
93
|
+
}),
|
|
94
|
+
comps: b.comps.map((c) => ({
|
|
95
|
+
...c, y: fy(c.y), rot: -c.rot,
|
|
96
|
+
pads: c.pads.map((p) => ({ ...p, y: fy(p.y) })),
|
|
97
|
+
})),
|
|
98
|
+
traces: b.traces.map((t) => ({ ...t, pts: t.pts.map(([x, y]): Pt => [x, fy(y)]) })),
|
|
99
|
+
zones: b.zones.map((z) => ({ ...z, pts: z.pts.map(([x, y]): Pt => [x, fy(y)]) })),
|
|
100
|
+
vias: b.vias.map((v) => ({ ...v, y: fy(v.y) })),
|
|
101
|
+
texts: (b.texts || []).map((t) => ({ ...t, y: fy(t.y), rot: -(t.rot || 0) })),
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function buildBoard(board: BoardModel, opts: { textures?: boolean } = {}): BuiltBoard {
|
|
106
|
+
// Mirror the whole model in Y before building. KiCad's +y points DOWN (screen
|
|
107
|
+
// coords); this pipeline maps +y to the scene's far side (screen-up), which
|
|
108
|
+
// renders the board upside-down vs KiCad's front view (verified against
|
|
109
|
+
// `kicad-cli pcb render`). Pre-mirroring makes the render match KiCad exactly
|
|
110
|
+
// while keeping every downstream transform proper and self-consistent
|
|
111
|
+
// (components still land on pads — everything mirrors together).
|
|
112
|
+
board = mirrorY(board)
|
|
113
|
+
const B = board.bbox
|
|
114
|
+
const W = B.x1 - B.x0, H = B.y1 - B.y0
|
|
115
|
+
const CX = (B.x0 + B.x1) / 2, CY = (B.y0 + B.y1) / 2
|
|
116
|
+
|
|
117
|
+
const group = new THREE.Group()
|
|
118
|
+
const disposables: { dispose?: () => void }[] = []
|
|
119
|
+
const track = <T extends { dispose?: () => void }>(...objs: T[]): T => { disposables.push(...objs); return objs[0] as T }
|
|
120
|
+
|
|
121
|
+
// ---------- outline shape ----------
|
|
122
|
+
const parts = outlineParts(board)
|
|
123
|
+
const poly = parts.ring
|
|
124
|
+
const shape = new THREE.Shape()
|
|
125
|
+
poly.forEach((p, i) => { const x = p[0] - CX, y = p[1] - CY; if (i === 0) shape.moveTo(x, y); else shape.lineTo(x, y) })
|
|
126
|
+
shape.closePath()
|
|
127
|
+
// Edge.Cuts circles → real holes in the extruded/filled board shape.
|
|
128
|
+
for (const h of parts.holes) {
|
|
129
|
+
if (h.length < 3) continue
|
|
130
|
+
const hp = new THREE.Path()
|
|
131
|
+
h.forEach((p, i) => { const x = p[0] - CX, y = p[1] - CY; if (i === 0) hp.moveTo(x, y); else hp.lineTo(x, y) })
|
|
132
|
+
hp.closePath()
|
|
133
|
+
shape.holes.push(hp)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// planar UVs matching the texture mapping: u=(x-x0)/W, v=(y1-y)/H
|
|
137
|
+
function planarUV<T extends THREE.BufferGeometry>(geo: T): T {
|
|
138
|
+
const pos = geo.attributes.position as THREE.BufferAttribute
|
|
139
|
+
const uv = new Float32Array(pos.count * 2)
|
|
140
|
+
for (let i = 0; i < pos.count; i++) {
|
|
141
|
+
const wx = pos.getX(i) + CX, wy = pos.getY(i) + CY
|
|
142
|
+
uv[i * 2] = (wx - B.x0) / W
|
|
143
|
+
uv[i * 2 + 1] = (B.y1 - wy) / H
|
|
144
|
+
}
|
|
145
|
+
geo.setAttribute('uv', new THREE.BufferAttribute(uv, 2))
|
|
146
|
+
return geo
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ---------- textures ----------
|
|
150
|
+
// opts.textures === false:轻量档 —— 纯色材质,跳过全部 canvas 纹理绘制
|
|
151
|
+
// (大板的 1.5s 建模成本主要在纹理上;缩略图/预渲染用它几乎零成本)。
|
|
152
|
+
const T = opts.textures === false ? null : makeBoardTextures(board)
|
|
153
|
+
|
|
154
|
+
// ---------- copper energize shader (uBrushX/uEnergy), shared by outer copper ----------
|
|
155
|
+
const brushUniforms = { uBrushX: { value: 0 }, uEnergy: { value: 0 } }
|
|
156
|
+
function copperMat(tex: THREE.Texture | null | undefined): THREE.MeshStandardMaterial {
|
|
157
|
+
if (!tex) return new THREE.MeshStandardMaterial({ color: 0xa9682f, metalness: 0.8, roughness: 0.45, side: THREE.DoubleSide })
|
|
158
|
+
const mat = new THREE.MeshStandardMaterial({ map: tex, metalness: 0.78, roughness: 0.42, side: THREE.DoubleSide })
|
|
159
|
+
mat.onBeforeCompile = (sh: THREE.WebGLProgramParametersWithUniforms) => {
|
|
160
|
+
sh.uniforms.uBrushX = brushUniforms.uBrushX
|
|
161
|
+
sh.uniforms.uEnergy = brushUniforms.uEnergy
|
|
162
|
+
sh.vertexShader = sh.vertexShader
|
|
163
|
+
.replace('#include <common>', '#include <common>\nvarying float vLocalX;')
|
|
164
|
+
.replace('#include <begin_vertex>', '#include <begin_vertex>\nvLocalX = (modelMatrix * vec4(position, 1.0)).x;')
|
|
165
|
+
sh.fragmentShader = sh.fragmentShader
|
|
166
|
+
.replace('#include <common>', '#include <common>\nvarying float vLocalX;\nuniform float uBrushX;\nuniform float uEnergy;')
|
|
167
|
+
.replace('#include <emissivemap_fragment>', `#include <emissivemap_fragment>
|
|
168
|
+
{
|
|
169
|
+
float d = vLocalX - uBrushX;
|
|
170
|
+
float band = exp(-d * d * 0.0035);
|
|
171
|
+
float behind = smoothstep(6.0, -4.0, d);
|
|
172
|
+
totalEmissiveRadiance += vec3(0.22, 0.7, 0.62) * (band * 1.1 + behind * 0.28) * uEnergy;
|
|
173
|
+
}`)
|
|
174
|
+
}
|
|
175
|
+
return mat
|
|
176
|
+
}
|
|
177
|
+
function maskMat(tex: THREE.Texture | null | undefined): THREE.MeshPhysicalMaterial {
|
|
178
|
+
if (!tex) return new THREE.MeshPhysicalMaterial({ color: 0x0f5e30, roughness: 0.5, roughnessMap: grain, metalness: 0.0, clearcoat: 0.55, clearcoatRoughness: 0.32, side: THREE.DoubleSide })
|
|
179
|
+
return new THREE.MeshPhysicalMaterial({ map: tex, roughness: 0.5, roughnessMap: grain, metalness: 0.0, clearcoat: 0.55, clearcoatRoughness: 0.32, side: THREE.DoubleSide })
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ---------- layer stack (2 masks + n copper + (n-1) FR4 cores), total 1.6mm ----------
|
|
183
|
+
const cu = board.cuLayers // ['F.Cu', ...inners..., 'B.Cu'] top→bottom
|
|
184
|
+
const n = cu.length
|
|
185
|
+
const MASK_T = 0.15, CU_T = 0.09, CU_IN_T = 0.06
|
|
186
|
+
const fr4Total = Math.max(0.4, 1.6 - 2 * MASK_T - CU_T * 2 - CU_IN_T * Math.max(0, n - 2))
|
|
187
|
+
const coreT = fr4Total / Math.max(1, n - 1)
|
|
188
|
+
|
|
189
|
+
// build sheets top→bottom: mask, cu(F), [core, cu(In_i)]..., core, cu(B), mask
|
|
190
|
+
const sheets: Sheet[] = []
|
|
191
|
+
sheets.push({ id: 'fmask', t: MASK_T, kind: 'mask', tex: T ? T.maskTop : null })
|
|
192
|
+
sheets.push({ id: 'F.Cu', t: CU_T, kind: 'copper', tex: T ? T.copperTop : null })
|
|
193
|
+
for (let i = 1; i < n - 1; i++) {
|
|
194
|
+
sheets.push({ id: 'core' + i, t: coreT, kind: 'fr4' })
|
|
195
|
+
sheets.push({ id: cu[i] as string, t: CU_IN_T, kind: 'inner', tex: (T && T.inner.get(cu[i] as string)) || null })
|
|
196
|
+
}
|
|
197
|
+
sheets.push({ id: 'coreB', t: coreT, kind: 'fr4' })
|
|
198
|
+
sheets.push({ id: 'B.Cu', t: CU_T, kind: 'copper', tex: T ? T.copperBot : null })
|
|
199
|
+
sheets.push({ id: 'bmask', t: MASK_T, kind: 'mask', tex: T ? T.maskBot : null })
|
|
200
|
+
|
|
201
|
+
// assign z so the stack is centered on z=0 (top surface at +0.8)
|
|
202
|
+
// (z/ex/delay live on LayerObj, built below — the mesh loop needs them anyway)
|
|
203
|
+
let zc = 0.8
|
|
204
|
+
const zs: number[] = []
|
|
205
|
+
for (const s of sheets) { zs.push(zc - s.t / 2); zc -= s.t }
|
|
206
|
+
|
|
207
|
+
// explode offsets: middle sheet stays, spacing grows outward; stagger delays inward
|
|
208
|
+
const mid = (sheets.length - 1) / 2
|
|
209
|
+
const exs: number[] = []
|
|
210
|
+
const delays: number[] = []
|
|
211
|
+
sheets.forEach((s, k) => {
|
|
212
|
+
exs.push((k - mid) * 9)
|
|
213
|
+
delays.push((1 - Math.abs(k - mid) / mid) * 0.18)
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
const layerObjs: LayerObj[] = []
|
|
217
|
+
for (let si = 0; si < sheets.length; si++) {
|
|
218
|
+
const s = sheets[si] as Sheet
|
|
219
|
+
const sz = zs[si] as number, sex = exs[si] as number, sdelay = delays[si] as number
|
|
220
|
+
let mesh: THREE.Mesh
|
|
221
|
+
let mat: THREE.Material
|
|
222
|
+
if (s.kind === 'fr4') {
|
|
223
|
+
const geo = track(new THREE.ExtrudeGeometry(shape, { depth: s.t, bevelEnabled: false }))
|
|
224
|
+
mesh = new THREE.Mesh(geo, M.fr4)
|
|
225
|
+
mesh.position.z = sz - s.t / 2 // extrude goes +z from shape plane
|
|
226
|
+
} else {
|
|
227
|
+
const geo = track(planarUV(new THREE.ShapeGeometry(shape, 4)))
|
|
228
|
+
const tex = s.tex
|
|
229
|
+
const m = s.kind === 'mask' ? maskMat(tex) : s.kind === 'copper' ? copperMat(tex)
|
|
230
|
+
: (tex
|
|
231
|
+
? new THREE.MeshStandardMaterial({ map: tex, metalness: 0.55, roughness: 0.5, side: THREE.DoubleSide })
|
|
232
|
+
: new THREE.MeshStandardMaterial({ color: 0x8a5a28, metalness: 0.55, roughness: 0.5, side: THREE.DoubleSide }))
|
|
233
|
+
track(m)
|
|
234
|
+
mat = m
|
|
235
|
+
mesh = new THREE.Mesh(geo, mat)
|
|
236
|
+
mesh.position.z = sz + (s.kind === 'mask' ? (s.id === 'fmask' ? s.t / 2 : -s.t / 2) : 0)
|
|
237
|
+
}
|
|
238
|
+
mesh.castShadow = true
|
|
239
|
+
mesh.receiveShadow = true
|
|
240
|
+
group.add(mesh)
|
|
241
|
+
layerObjs.push({ ...s, z: sz, ex: sex, delay: sdelay, mesh, baseZ: mesh.position.z })
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ---------- components ----------
|
|
245
|
+
const comps: CompEntry[] = []
|
|
246
|
+
board.comps.forEach((c, i) => {
|
|
247
|
+
let g: THREE.Object3D
|
|
248
|
+
try { g = buildComponent(c, i) } catch (e) { console.warn('component build failed', c.ref, e); return }
|
|
249
|
+
const isBot = c.layer === 'B.Cu'
|
|
250
|
+
const hx = c.x - CX
|
|
251
|
+
const hy = c.y - CY
|
|
252
|
+
// Bottom parts hang below the board. Pads are absolute, so the XY mapping is
|
|
253
|
+
// IDENTICAL to top parts — never mirror positions about the anchor (a PI flip
|
|
254
|
+
// through the anchor flings offset-anchor parts, like the CM5 bottom modules,
|
|
255
|
+
// clear off the board). We simply hang the body below and drop it downward on explode.
|
|
256
|
+
const ud = (g.userData || {}) as { h?: number; w?: number; l?: number }
|
|
257
|
+
const bodyH = ud.h || 2
|
|
258
|
+
const baseZ = isBot ? -(0.82 + bodyH) : 0.82
|
|
259
|
+
g.position.set(hx, hy, baseZ)
|
|
260
|
+
const qBase = new THREE.Quaternion()
|
|
261
|
+
qBase.setFromEuler(new THREE.Euler(0, 0, THREE.MathUtils.degToRad(c.rot), 'XYZ'))
|
|
262
|
+
g.quaternion.copy(qBase)
|
|
263
|
+
|
|
264
|
+
const size = Math.max(ud.w || 2, ud.l || 2)
|
|
265
|
+
const lift = (isBot ? -1 : 1) * (40 + size * 0.55 + rnd(i, 6) * 7)
|
|
266
|
+
const trigger = clamp01(0.02 + 0.86 * ((hx + W / 2) / W) + (rnd(i, 8) - 0.5) * 0.06)
|
|
267
|
+
const tiltAxis = new THREE.Vector3(rnd(i, 11) - 0.5, rnd(i, 12) - 0.5, 0).normalize()
|
|
268
|
+
const tiltMax = (rnd(i, 14) - 0.5) * 0.22
|
|
269
|
+
|
|
270
|
+
group.add(g)
|
|
271
|
+
comps.push({ g, hx, hy, baseZ, qBase, lift, trigger, tiltAxis, tiltMax, ref: c.ref })
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
// ---------- vias ----------
|
|
275
|
+
let vias: THREE.InstancedMesh | null = null
|
|
276
|
+
if (board.vias.length) {
|
|
277
|
+
const viaGeo = track(new THREE.CylinderGeometry(0.32, 0.32, 1, 8))
|
|
278
|
+
viaGeo.rotateX(Math.PI / 2)
|
|
279
|
+
vias = new THREE.InstancedMesh(viaGeo, M.via, board.vias.length)
|
|
280
|
+
vias.instanceMatrix.setUsage(THREE.DynamicDrawUsage)
|
|
281
|
+
group.add(vias)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ---------- animation ----------
|
|
285
|
+
const tmpQ = new THREE.Quaternion()
|
|
286
|
+
const tmpM = new THREE.Matrix4()
|
|
287
|
+
|
|
288
|
+
function update(u: number, speed: number) {
|
|
289
|
+
const energy = smooth(clamp01(speed * 2.2)) * 0.9 + 0.08
|
|
290
|
+
brushUniforms.uEnergy.value = energy
|
|
291
|
+
brushUniforms.uBrushX.value = THREE.MathUtils.lerp(-W / 2 - 10, W / 2 + 10, u)
|
|
292
|
+
|
|
293
|
+
for (const L of layerObjs) {
|
|
294
|
+
const p = easeInOutCubic(clamp01((u - L.delay) / (1 - L.delay)))
|
|
295
|
+
L.mesh.position.z = L.baseZ + L.ex * p
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
for (const c of comps) {
|
|
299
|
+
const p = smooth(clamp01((u * 1.2 - c.trigger) / 0.35))
|
|
300
|
+
const e = easeOutCubic(p)
|
|
301
|
+
c.g.position.set(c.hx, c.hy, c.baseZ + c.lift * e)
|
|
302
|
+
tmpQ.copy(c.qBase)
|
|
303
|
+
if (e > 0.001 && Math.abs(c.tiltMax) > 0.001) {
|
|
304
|
+
const q = new THREE.Quaternion().setFromAxisAngle(c.tiltAxis, c.tiltMax * e)
|
|
305
|
+
tmpQ.multiply(q)
|
|
306
|
+
}
|
|
307
|
+
c.g.quaternion.copy(tmpQ)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (vias) {
|
|
311
|
+
const viaTop = 0.8, viaBot = -0.8
|
|
312
|
+
const vh = Math.max(0.1, viaTop - viaBot)
|
|
313
|
+
for (let i = 0; i < board.vias.length; i++) {
|
|
314
|
+
const v = board.vias[i]
|
|
315
|
+
if (!v) continue
|
|
316
|
+
tmpM.makeScale(v.size / 0.64, v.size / 0.64, vh)
|
|
317
|
+
tmpM.setPosition(v.x - CX, v.y - CY, (viaTop + viaBot) / 2)
|
|
318
|
+
vias.setMatrixAt(i, tmpM)
|
|
319
|
+
}
|
|
320
|
+
vias.instanceMatrix.needsUpdate = true
|
|
321
|
+
M.via.opacity = 1 - 0.85 * smooth(clamp01((u - 0.55) / 0.4))
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
update(0, 0)
|
|
325
|
+
|
|
326
|
+
function dispose() {
|
|
327
|
+
for (const d of disposables) d.dispose && d.dispose()
|
|
328
|
+
if (T) for (const t of [T.maskTop, T.maskBot, T.copperTop, T.copperBot, ...(T.inner ? [...T.inner.values()] : [])] as (THREE.Texture | null | undefined)[]) t && t.dispose && t.dispose()
|
|
329
|
+
group.traverse((o) => { if ((o as THREE.Mesh).isMesh) { const m = o as THREE.Mesh; m.geometry && m.geometry.dispose && m.geometry.dispose() } })
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return { group, update, boardW: W, boardH: H, comps, bbox: B, layerCount: n, dispose }
|
|
333
|
+
}
|