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