@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,480 @@
1
+ // Parametric 3D component template library for the PCB renderer.
2
+ //
3
+ // Each parsed component `c` carries absolute board-coord pads; this module
4
+ // re-derives them in the footprint's LOCAL frame (rot = 0) and builds a
5
+ // three.js Group whose base sits at z = 0, +z out of the board, x right,
6
+ // y down — matching KiCad's local axes. The caller positions/rotates it.
7
+ import * as THREE from 'three'
8
+ import { RoundedBoxGeometry } from 'three/addons/geometries/RoundedBoxGeometry.js'
9
+ import { M } from './materials.js'
10
+ import type { BoardComp, BoardPad } from '../model.js'
11
+
12
+ // ---------- small shared helpers ----------
13
+
14
+ const clamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v))
15
+
16
+ // deterministic per-index pseudo-random in [0,1)
17
+ function rnd(i: number, salt = 0): number {
18
+ const x = Math.sin(i * 127.1 + salt * 311.7 + 7.7) * 43758.5453
19
+ return x - Math.floor(x)
20
+ }
21
+
22
+ // rounded box centered at z = z + h/2 (base sits on the given plane)
23
+ // 分级细分:小件的倒角在屏幕上不可见,却要付大量顶点(实测整板 512 万顶点)。
24
+ // <1.5mm → 直角盒(24 顶点)
25
+ // <4mm → 圆角盒 1 段
26
+ // 其余 → 圆角盒 3 段(原样)
27
+ function B_(w: number, l: number, h: number, mat: THREE.Material, z = 0): THREE.Mesh {
28
+ const W = Math.max(w, 0.05), L = Math.max(l, 0.05), H = Math.max(h, 0.05)
29
+ const big = Math.max(W, L)
30
+ const radius = Math.min(0.22, W * 0.18, L * 0.18, H * 0.45)
31
+ const geo = big < 1.5
32
+ ? new THREE.BoxGeometry(W, L, H)
33
+ : new RoundedBoxGeometry(W, L, H, big < 4 ? 1 : 3, radius)
34
+ const m = new THREE.Mesh(geo, mat)
35
+ m.position.z = z + h / 2
36
+ return m
37
+ }
38
+
39
+ // small beveled metal post, base at z
40
+ function pin(w: number, l: number, h: number, mat: THREE.Material = M.gold, z = 0): THREE.Mesh {
41
+ const r = Math.min(0.25, w * 0.3, l * 0.3)
42
+ const m = new THREE.Mesh(new RoundedBoxGeometry(Math.max(w, 0.05), Math.max(l, 0.05), Math.max(h, 0.05), 2, r), mat)
43
+ m.position.z = z + h / 2
44
+ return m
45
+ }
46
+
47
+ // ---------- name parsing ----------
48
+
49
+ const IMPERIAL: Record<string, [number, number]> = {
50
+ '0402': [1.0, 0.5], '0603': [1.6, 0.8], '0805': [2.0, 1.25], '1206': [3.2, 1.6],
51
+ '1210': [3.2, 2.5], '1812': [4.5, 3.2], '2010': [5.0, 2.5], '2512': [6.3, 3.2],
52
+ }
53
+
54
+ function parseImperial(fp: string): [number, number] | null {
55
+ const m = fp.match(/\b(0402|0603|0805|1206|1210|1812|2010|2512)\b/)
56
+ const k = m?.[1]
57
+ return (k && IMPERIAL[k]) ? IMPERIAL[k]! : null
58
+ }
59
+
60
+ function parseLW(fp: string): [number, number] | null {
61
+ const m = fp.match(/L([\d.]+)-W([\d.]+)/)
62
+ return (m && m[1] !== undefined && m[2] !== undefined) ? [parseFloat(m[1]), parseFloat(m[2])] : null
63
+ }
64
+
65
+ function parsePitch(fp: string): number | null {
66
+ const m = fp.match(/P([\d.]+)/)
67
+ return (m && m[1] !== undefined) ? parseFloat(m[1]) : null
68
+ }
69
+
70
+ // e.g. C_Ele_SMD_5x5.4mm → [5, 5.4]
71
+ function parseXMM(fp: string): [number, number] | null {
72
+ const m = fp.match(/(\d+(?:\.\d+)?)x(\d+(?:\.\d+)?)mm/)
73
+ return (m && m[1] !== undefined && m[2] !== undefined) ? [parseFloat(m[1]), parseFloat(m[2])] : null
74
+ }
75
+
76
+ // ---------- local frame ----------
77
+
78
+ interface LocalFrame {
79
+ pads: BoardPad[]
80
+ bbox: { minX: number; maxX: number; minY: number; maxY: number }
81
+ cx: number
82
+ cy: number
83
+ }
84
+
85
+ // Transform absolute pads into the footprint's local frame (rot = 0):
86
+ // local = P(-rot) · (pad − center) where P is the CCW rotation matrix — the exact
87
+ // inverse of the parser's absolute = anchor + P(rot) · local. (Using P(rot) here
88
+ // instead of P(-rot) was silently wrong for 90°/270° parts; 0°/180° accidentally
89
+ // work because P(θ)=P(-θ) there.)
90
+ function toLocal(c: BoardComp): LocalFrame {
91
+ const r = (c.rot * Math.PI) / 180
92
+ const cos = Math.cos(r), sin = Math.sin(r)
93
+ let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity
94
+ const pads = c.pads.map((p) => {
95
+ const dx = p.x - c.x, dy = p.y - c.y
96
+ const lx = cos * dx + sin * dy
97
+ const ly = -sin * dx + cos * dy
98
+ if (lx < minX) minX = lx
99
+ if (lx > maxX) maxX = lx
100
+ if (ly < minY) minY = ly
101
+ if (ly > maxY) maxY = ly
102
+ return { x: lx, y: ly, w: p.w, l: p.l, shape: p.shape, net: p.net, top: p.top, bottom: p.bottom, th: p.th }
103
+ })
104
+ if (!isFinite(minX)) { minX = -1; maxX = 1; minY = -1; maxY = 1 }
105
+ return { pads, bbox: { minX, maxX, minY, maxY }, cx: (minX + maxX) / 2, cy: (minY + maxY) / 2 }
106
+ }
107
+
108
+ const spanX = (b: LocalFrame['bbox']) => b.maxX - b.minX
109
+ const spanY = (b: LocalFrame['bbox']) => b.maxY - b.minY
110
+
111
+ // ---------- template builders ----------
112
+ // Each takes (g, c, L) where L is the local frame; adds meshes to g.
113
+
114
+ function buildWifi(g: THREE.Group, _c: BoardComp, _L: LocalFrame): void {
115
+ // ESP-WROOM-style module: green substrate + rounded metal RF shield + antenna strip
116
+ addM(g, B_(18, 13.5, 0.9, M.modGreen))
117
+ const shield = new THREE.Mesh(new RoundedBoxGeometry(16.4, 11.6, 3.2, 3, 0.7), M.shield)
118
+ shield.position.set(0, -1.2, 0.9 + 1.6)
119
+ addM(g, shield)
120
+ // subtle seam at the shield base
121
+ addM(g, B_(16.8, 12.0, 0.35, M.darkGray).translateY(-1.2))
122
+ // antenna strip along one short edge
123
+ addM(g, B_(16.4, 2.6, 0.9, new THREE.MeshStandardMaterial({ color: 0x1c2b20, roughness: 0.7 })).translateY(4.9))
124
+ }
125
+
126
+ function buildRelay(g: THREE.Group, _c: BoardComp, L: LocalFrame): void {
127
+ // Wide flat power relay (G5NB-class). TH pins span the long axis; body depth
128
+ // is capped at 10 so relays stacked 12mm apart don't collide.
129
+ const sx = spanX(L.bbox), sy = spanY(L.bbox)
130
+ // long axis = whichever pad spread is bigger
131
+ const alongX = sx >= sy
132
+ const bw = alongX ? sx + 3.5 : Math.min(sy + 5, 10)
133
+ const bl = alongX ? Math.min(sx + 5, 10) : sy + 3.5
134
+ const h = 13
135
+ addM(g, B_(bw, bl, h, M.black))
136
+ // cream top face, slightly inset, with a darker brand panel
137
+ addM(g, B_(bw * 0.94, bl * 0.88, 0.45, M.white, h - 0.1))
138
+ addM(g, B_(bw * 0.62, bl * 0.55, 0.2, new THREE.MeshStandardMaterial({ color: 0xcfc9bd, roughness: 0.6 }), h + 0.35))
139
+ // dark side-groove band at mid height
140
+ addM(g, B_(bw + 0.15, bl + 0.15, 1.3, M.darkGray, h * 0.3))
141
+ // pin tabs along the two long edges
142
+ const n = Math.max(2, Math.round(sx / 4.5))
143
+ for (let i = 0; i < n; i++) {
144
+ const t = n === 1 ? 0 : -0.75 + (1.5 * i) / (n - 1)
145
+ if (alongX) {
146
+ addM(g, pin(1.6, 1.2, 2.4).translateX(t * bw * 0.85).translateY(bl / 2))
147
+ addM(g, pin(1.6, 1.2, 2.4).translateX(t * bw * 0.85).translateY(-bl / 2))
148
+ } else {
149
+ addM(g, pin(1.2, 1.6, 2.4).translateX(bw / 2).translateY(t * bl * 0.85))
150
+ addM(g, pin(1.2, 1.6, 2.4).translateX(-bw / 2).translateY(t * bl * 0.85))
151
+ }
152
+ }
153
+ }
154
+
155
+ function buildUsbTypeC(g: THREE.Group, _c: BoardComp, _L: LocalFrame): void {
156
+ // Pill-shaped metal shroud + black inner body + gold solder tabs at ±x edges
157
+ addM(g, new THREE.Mesh(new RoundedBoxGeometry(9.2, 7.5, 3.0, 4, 1.3), M.silver).translateZ(1.5))
158
+ addM(g, B_(8.0, 6.4, 2.4, M.black, 0.6))
159
+ addM(g, B_(1.2, 7.5, 0.8, M.gold).translateX(-4.0))
160
+ addM(g, B_(1.2, 7.5, 0.8, M.gold).translateX(4.0))
161
+ }
162
+
163
+ function buildTfCard(g: THREE.Group, _c: BoardComp, _L: LocalFrame): void {
164
+ // Compact micro-SD / TF card reader: metal shell, dark slot, gold contacts, eject nub
165
+ const shellMat = new THREE.MeshStandardMaterial({ color: 0x9aa1a8, metalness: 0.85, roughness: 0.4 })
166
+ addM(g, B_(23, 11, 4.6, shellMat))
167
+ addM(g, B_(19, 7.5, 0.4, new THREE.MeshStandardMaterial({ color: 0x14161a, roughness: 0.85 }), 4.6))
168
+ addM(g, B_(3.2, 6.5, 0.3, M.gold).translateX(-6.5).translateZ(4.4))
169
+ addM(g, B_(1.6, 8, 0.6, M.darkGray).translateX(10.2).translateZ(4.6))
170
+ }
171
+
172
+ function buildStandoff(g: THREE.Group, _c: BoardComp, _L: LocalFrame): void {
173
+ // Steel standoff: cylinder + hex head on top
174
+ const cyl = new THREE.Mesh(new THREE.CylinderGeometry(1.6, 1.6, 3, 14), M.steel)
175
+ cyl.position.z = 1.5
176
+ addM(g, cyl)
177
+ const head = new THREE.Mesh(new THREE.CylinderGeometry(1.7, 1.7, 1.2, 6), M.steel)
178
+ head.position.z = 3.6
179
+ addM(g, head)
180
+ }
181
+
182
+ function buildElcoCap(g: THREE.Group, c: BoardComp, _L: LocalFrame): void {
183
+ // Electrolytic cap: black cylinder + polarity band + silver dome with vent scores
184
+ const xm = parseXMM(c.fp)
185
+ const d = xm ? xm[0] : 6.3
186
+ const h = xm ? xm[1] : 7
187
+ const r = d / 2
188
+ const body = new THREE.Mesh(new THREE.CylinderGeometry(r, r, h, 24), M.black)
189
+ body.position.z = h / 2
190
+ addM(g, body)
191
+ // polarity band — partial cylinder shell on one side
192
+ const bandMat = new THREE.MeshStandardMaterial({ color: 0x3a3f45, roughness: 0.5 })
193
+ const band = new THREE.Mesh(new THREE.CylinderGeometry(r + 0.01, r + 0.01, h, 24, 1, true, -0.5, 1.0), bandMat)
194
+ band.position.z = h / 2
195
+ addM(g, band)
196
+ // silver top disc + squashed-sphere dome
197
+ const disc = new THREE.Mesh(new THREE.CylinderGeometry(r, r, 0.35, 24), M.silver)
198
+ disc.position.z = h + 0.17
199
+ addM(g, disc)
200
+ const dome = new THREE.Mesh(new THREE.SphereGeometry(r, 24, 10, 0, Math.PI * 2, 0, Math.PI / 2), M.silver)
201
+ dome.scale.set(1, 0.28, 1)
202
+ dome.position.z = h + 0.2
203
+ addM(g, dome)
204
+ // two thin vent score lines on the dome
205
+ const scoreMat = new THREE.MeshStandardMaterial({ color: 0x7d838b, roughness: 0.4 })
206
+ for (let i = 0; i < 2; i++) {
207
+ const line = new THREE.Mesh(new THREE.BoxGeometry(d * 0.92, 0.18, 0.12), scoreMat)
208
+ line.position.z = h + r * 0.24
209
+ line.rotation.z = (i * Math.PI) / 2
210
+ addM(g, line)
211
+ }
212
+ }
213
+
214
+ function buildTactSwitch(g: THREE.Group, _c: BoardComp, L: LocalFrame): void {
215
+ // Tact button: silver base plate + black square button
216
+ const sx = spanX(L.bbox), sy = spanY(L.bbox)
217
+ const bw = Math.max(sx, 4.5), bl = Math.max(sy, 4.5)
218
+ addM(g, B_(bw, bl, 1.2, M.silver))
219
+ addM(g, B_(Math.min(4, bw * 0.75), Math.min(5, bl * 0.85), 2.6, M.black, 1.2))
220
+ }
221
+
222
+ function buildFpc(g: THREE.Group, _c: BoardComp, L: LocalFrame): void {
223
+ // FPC/FFC connector: dark-gray body + black actuator bar on top
224
+ const sx = spanX(L.bbox), sy = spanY(L.bbox)
225
+ const bw = Math.max(sx + 1.5, 8), bl = Math.max(sy + 1.5, 6)
226
+ addM(g, B_(bw, bl, 4.5, M.darkGray))
227
+ addM(g, B_(bw * 0.9, 2, 1.0, M.black, 3.8).translateY(bl * 0.28))
228
+ }
229
+
230
+ function buildDcJack(g: THREE.Group, _c: BoardComp, _L: LocalFrame): void {
231
+ // DC barrel jack: black body + silver center pin ring
232
+ addM(g, B_(8, 5.5, 4.5, M.black))
233
+ addM(g, B_(3, 3, 1.0, M.silver, 4.5))
234
+ }
235
+
236
+ function buildTo92(g: THREE.Group, c: BoardComp, _L: LocalFrame): void {
237
+ // TO-92: black half-cylinder-ish body + silver fin disc on top
238
+ const lw = parseLW(c.fp)
239
+ const bw = lw ? lw[0] : 4.9
240
+ const bl = lw ? lw[1] : 3.7
241
+ addM(g, B_(bw, bl, 3.0, M.black))
242
+ const fin = new THREE.Mesh(new THREE.CylinderGeometry(Math.min(bw, bl) * 0.52, Math.min(bw, bl) * 0.52, 0.6, 16), M.silver)
243
+ fin.rotation.x = Math.PI / 2
244
+ fin.position.set(0, 0, 3.3)
245
+ addM(g, fin)
246
+ }
247
+
248
+ function buildSot23(g: THREE.Group, c: BoardComp, _L: LocalFrame): void {
249
+ // SOT-23: tiny black body + 3 thin gull pins (2 one side, 1 other)
250
+ const lw = parseLW(c.fp)
251
+ const bw = lw ? lw[0] : 2.9
252
+ const bl = lw ? lw[1] : 1.3
253
+ addM(g, B_(bw, bl, 1.0, M.black))
254
+ addM(g, pin(bw * 0.9, 0.5, 0.3).translateY(bl / 2 + 0.35))
255
+ addM(g, pin(bw * 0.9, 0.5, 0.3).translateY(-bl / 2 - 0.35))
256
+ }
257
+
258
+ // Gull-wing IC: black body from L/W (or pad-bbox inset), pin rows on the two
259
+ // long sides, silver pin-1 dot at one corner. If pads cluster on all four
260
+ // sides it's a QFP → delegate to buildQfn.
261
+ function buildIc(g: THREE.Group, c: BoardComp, L: LocalFrame): void {
262
+ const lw = parseLW(c.fp)
263
+ const bw = lw ? lw[0] : spanX(L.bbox) * 0.85
264
+ const bl = lw ? lw[1] : spanY(L.bbox) * 0.85
265
+ const h = 1.5
266
+ // detect 4-side pad clusters → QFP
267
+ const xs = L.pads.map((p) => p.x), ys = L.pads.map((p) => p.y)
268
+ const fourSides = L.pads.length >= 8 &&
269
+ xs.some((x) => x < -bw * 0.25) && xs.some((x) => x > bw * 0.25) &&
270
+ ys.some((y) => y < -bl * 0.25) && ys.some((y) => y > bl * 0.25)
271
+ if (fourSides) { buildQfn(g, c, L); return }
272
+
273
+ addM(g, B_(bw, bl, h, M.black))
274
+ const nSide = Math.max(1, Math.floor(L.pads.length / 2))
275
+ for (let i = 0; i < nSide; i++) {
276
+ const t = nSide === 1 ? 0 : -0.85 + (1.7 * i) / (nSide - 1)
277
+ addM(g, pin(0.45, 0.9, 0.35).translateX(t * bw * 0.42).translateY(bl / 2 + 0.3))
278
+ addM(g, pin(0.45, 0.9, 0.35).translateX(t * bw * 0.42).translateY(-bl / 2 - 0.3))
279
+ }
280
+ // silver pin-1 dot at one corner
281
+ const dot = new THREE.Mesh(new THREE.CylinderGeometry(0.4, 0.4, 0.1, 12), M.silver)
282
+ dot.position.set(-bw / 2 + 0.8, -bl / 2 + 0.8, h)
283
+ addM(g, dot)
284
+ }
285
+
286
+ function buildQfn(g: THREE.Group, c: BoardComp, L: LocalFrame): void {
287
+ // Flat square-ish IC: black body, pins/pads on 4 sides (QFP) or just body+dot (QFN/BGA)
288
+ const lw = parseLW(c.fp)
289
+ const bw = lw ? lw[0] : spanX(L.bbox) * 0.85
290
+ const bl = lw ? lw[1] : spanY(L.bbox) * 0.85
291
+ const h = 1.0
292
+ addM(g, B_(bw, bl, h, M.black))
293
+ const isQfp = /QFP/.test(c.fp)
294
+ if (isQfp) {
295
+ const nSide = Math.max(1, Math.floor(L.pads.length / 4))
296
+ for (let i = 0; i < nSide; i++) {
297
+ const t = nSide === 1 ? 0 : -0.85 + (1.7 * i) / (nSide - 1)
298
+ addM(g, pin(0.45, 0.9, 0.3).translateX(t * bw * 0.42).translateY(bl / 2 + 0.3))
299
+ addM(g, pin(0.45, 0.9, 0.3).translateX(t * bw * 0.42).translateY(-bl / 2 - 0.3))
300
+ addM(g, pin(0.9, 0.45, 0.3).translateX(bw / 2 + 0.3).translateY(t * bl * 0.42))
301
+ addM(g, pin(0.9, 0.45, 0.3).translateX(-bw / 2 - 0.3).translateY(t * bl * 0.42))
302
+ }
303
+ }
304
+ const dot = new THREE.Mesh(new THREE.CylinderGeometry(0.4, 0.4, 0.1, 12), M.silver)
305
+ dot.position.set(-bw / 2 + 0.8, -bl / 2 + 0.8, h)
306
+ addM(g, dot)
307
+ }
308
+
309
+ function buildDiode(g: THREE.Group, c: BoardComp, _L: LocalFrame): void {
310
+ // SMB/SMC/SMA/SOD: black body from L/W, silver end caps, cathode band at one end
311
+ const lw = parseLW(c.fp)
312
+ const bw = lw ? lw[0] : 4.6
313
+ const bl = lw ? lw[1] : 3.6
314
+ const h = 2.2
315
+ addM(g, B_(bw, bl, h, M.black))
316
+ addM(g, B_(0.8, bl + 0.1, h + 0.1, M.silver).translateX(-bw / 2 + 0.4))
317
+ addM(g, B_(0.5, bl * 0.96, h * 0.98, new THREE.MeshStandardMaterial({ color: 0x8a8f97, roughness: 0.5 })).translateX(bw / 2 - 1.0))
318
+ }
319
+
320
+ function buildLed(g: THREE.Group, c: BoardComp, L: LocalFrame): void {
321
+ // Small chip LED: amber emissive body from imperial code or pad span
322
+ const imp = parseImperial(c.fp)
323
+ let bw = imp ? imp[0] : null
324
+ let bl = imp ? imp[1] : null
325
+ if (bw == null || bl == null) {
326
+ bw = Math.max(spanX(L.bbox), 1.6)
327
+ bl = Math.max(spanY(L.bbox), 1.0)
328
+ }
329
+ addM(g, B_(bw, bl, 1.2, M.amber))
330
+ }
331
+
332
+ function buildInductor(g: THREE.Group, c: BoardComp, L: LocalFrame): void {
333
+ // Inductor: dark-gray body + silver wrap ends
334
+ const lw = parseLW(c.fp)
335
+ let bw = lw ? lw[0] : null
336
+ let bl = lw ? lw[1] : null
337
+ if (bw == null || bl == null) {
338
+ const imp = parseImperial(c.fp)
339
+ if (imp) { bw = imp[0]; bl = imp[1] }
340
+ else { bw = Math.max(spanX(L.bbox), 3); bl = Math.max(spanY(L.bbox), 2.5) }
341
+ }
342
+ addM(g, B_(bw, bl, 2.5, M.darkGray))
343
+ addM(g, B_(bw * 0.82, bl * 0.82, 0.3, M.silver, 2.5))
344
+ }
345
+
346
+ // Classic two-pad SMD chip: silver end caps + center body, color by ref prefix.
347
+ function buildChip(g: THREE.Group, c: BoardComp, L: LocalFrame, idx: number): void {
348
+ const ref = (c.ref || '').toUpperCase()
349
+ let mat: THREE.Material = M.black
350
+ let h = 0.55
351
+ if (ref.startsWith('C')) { mat = rnd(idx) < 0.65 ? M.tan : M.blueCap; h = 0.8 }
352
+ else if (ref.startsWith('L')) { mat = M.darkGray; h = 0.8 }
353
+ else if (ref.startsWith('D') || ref.startsWith('FB') || ref.startsWith('F')) { mat = M.black; h = 1.0 }
354
+
355
+ const imp = parseImperial(c.fp)
356
+ let bw: number, bl: number
357
+ if (imp) { bw = imp[0]; bl = imp[1] }
358
+ else {
359
+ // local pad span minus ~30% of a typical pad width
360
+ const pw = L.pads.length ? Math.max(...L.pads.map((p) => p.w)) : 1.2
361
+ bw = Math.max(spanX(L.bbox) + pw * 0.7, 1.6)
362
+ bl = Math.max(spanY(L.bbox) + pw * 0.5, 0.9)
363
+ }
364
+ const capW = clamp(bw * 0.28, 0.3, 1.2)
365
+ addM(g, B_(capW, bl, h, M.silver).translateX(-bw / 2 + capW / 2))
366
+ addM(g, B_(capW, bl, h, M.silver).translateX(bw / 2 - capW / 2))
367
+ addM(g, B_(bw - capW * 1.6, bl, h, mat))
368
+ }
369
+
370
+ // TH connectors: inline (one line of pads) or two-row box, gold pin stubs on top.
371
+ function buildThConnector(g: THREE.Group, c: BoardComp, L: LocalFrame): void {
372
+ const pitch = parsePitch(c.fp) || 2.54
373
+ const sx = spanX(L.bbox), sy = spanY(L.bbox)
374
+ const h = pitch >= 5 ? 7 : pitch >= 2.5 ? 6 : 3.5
375
+
376
+ // one line? (all pads share the same axis within tolerance)
377
+ const xs = L.pads.map((p) => p.x), ys = L.pads.map((p) => p.y)
378
+ const xTol = Math.max(1.2, pitch * 0.45), yTol = Math.max(1.2, pitch * 0.45)
379
+ const inlineX = Math.max(...ys) - Math.min(...ys) <= yTol // pads along X line
380
+ const inlineY = Math.max(...xs) - Math.min(...xs) <= xTol // pads along Y line
381
+
382
+ if (inlineX || inlineY) {
383
+ const alongX = inlineX
384
+ const span = alongX ? sx : sy
385
+ const bw = alongX ? span + pitch : pitch * 0.9
386
+ const bl = alongX ? pitch * 0.9 : span + pitch
387
+ addM(g, B_(bw, bl, h, M.darkGray))
388
+ for (const p of L.pads) {
389
+ if (alongX) addM(g, pin(1.2, 1.2, 0.8, M.gold, h).translateX(p.x - L.cx))
390
+ else addM(g, pin(1.2, 1.2, 0.8, M.gold, h).translateY(p.y - L.cy))
391
+ }
392
+ } else {
393
+ // two rows → box spanning both rows with two pin rows
394
+ const bw = sx + pitch * 0.9
395
+ const bl = sy + pitch * 0.9
396
+ addM(g, B_(bw, bl, h, M.darkGray))
397
+ for (const p of L.pads) {
398
+ addM(g, pin(1.2, 1.2, 0.8, M.gold, h).translateX(p.x - L.cx).translateY(p.y - L.cy))
399
+ }
400
+ }
401
+ }
402
+
403
+ function buildFallback(g: THREE.Group, _c: BoardComp, L: LocalFrame): void {
404
+ const bw = Math.max(spanX(L.bbox), 1.2)
405
+ const bl = Math.max(spanY(L.bbox), 1.2)
406
+ const np = L.pads.length
407
+ if (np > 8 && (bw > 10 || bl > 10)) {
408
+ // Large multi-pad footprint with no specific template (board-to-board connector,
409
+ // module socket, M.2, etc.): a hollow perimeter housing + gold contacts reads as
410
+ // a real connector — a solid slab this big looks wrong and covers the board.
411
+ const rail = Math.min(1.6, bw * 0.12, bl * 0.12)
412
+ const h = Math.min(4, 1.6 + np * 0.02)
413
+ addM(g, B_(bw, rail, h, M.darkGray).translateY(bl / 2 - rail / 2))
414
+ addM(g, B_(bw, rail, h, M.darkGray).translateY(-bl / 2 + rail / 2))
415
+ addM(g, B_(rail, bl - 2 * rail, h, M.darkGray).translateX(bw / 2 - rail / 2))
416
+ addM(g, B_(rail, bl - 2 * rail, h, M.darkGray).translateX(-bw / 2 + rail / 2))
417
+ for (const p of L.pads.slice(0, 48)) {
418
+ addM(g, pin(Math.min((p.w || 1) * 0.8, 1.2), Math.min((p.l || 1) * 0.8, 1.2), 0.5, M.gold, 0).translateX(p.x - L.cx).translateY(p.y - L.cy))
419
+ }
420
+ } else {
421
+ addM(g, B_(bw, bl, 1.0, M.darkGray))
422
+ }
423
+ }
424
+
425
+ // ---------- dispatch ----------
426
+
427
+ function addM(g: THREE.Group, m: THREE.Object3D): THREE.Object3D { g.add(m); return m }
428
+
429
+ export function buildComponent(c: BoardComp, idx = 0): THREE.Group {
430
+ const g = new THREE.Group()
431
+ const L = toLocal(c)
432
+ const fp = c.fp || ''
433
+ const ref = (c.ref || '').toUpperCase()
434
+
435
+ if (/^WIFIM|ESP-WROOM|WROOM/.test(fp)) buildWifi(g, c, L)
436
+ else if (/^RELAY/.test(fp)) buildRelay(g, c, L)
437
+ else if (/USB_TYPE-C|USB_C|USB-C/.test(fp)) buildUsbTypeC(g, c, L)
438
+ else if (/TF-SMD|TF-CARD|MicroSD/.test(fp)) buildTfCard(g, c, L)
439
+ else if (/^M2|^M3|螺丝|MountingHole|Standoff/.test(fp)) buildStandoff(g, c, L)
440
+ else if (/C_Ele|CP_Radial|Elco/.test(fp)) buildElcoCap(g, c, L)
441
+ else if (/Key_SMD|^SW_|^KEY|Tact/.test(fp)) buildTactSwitch(g, c, L)
442
+ else if (/FPC|FFC/.test(fp)) buildFpc(g, c, L)
443
+ else if (/DC-005|DC_JACK|Barrel/.test(fp)) buildDcJack(g, c, L)
444
+ else if (/TO-92/.test(fp)) buildTo92(g, c, L)
445
+ else if (/SOT-23/.test(fp)) buildSot23(g, c, L)
446
+ else if (/SOP|SOIC|SSOP|TSSOP|MSOP|ESOP|SOT-223|DIP|SOJ/.test(fp)) buildIc(g, c, L)
447
+ else if (/QFN|QFP|LQFP|TQFP|DFN|BGA/.test(fp)) buildQfn(g, c, L)
448
+ else if (/SMB|SMC|SMA|SOD|DO-214/.test(fp)) buildDiode(g, c, L)
449
+ else if (/^LED|^LD/.test(ref) || /LED/.test(fp)) buildLed(g, c, L)
450
+ else if (/IND|^L_|^L0/.test(fp) || /^L\d/.test(ref)) buildInductor(g, c, L)
451
+ else if (/^(R|C|L|D|FB|F)\d/.test(ref) || parseImperial(fp)) buildChip(g, c, L, idx)
452
+ else if (/CONN|^WJ|^HX|TerminalBlock|PinHeader|Header|JST|^XH|^PH/.test(fp)) buildThConnector(g, c, L)
453
+ else buildFallback(g, c, L)
454
+
455
+ // Bottom-side parts hang below the board: flip built geometry so it extends
456
+ // downward. Mesh z-positions negate (pivot at the pad plane z=0, NOT the
457
+ // anchor) and each mesh is rotated 180° about its own X so tops face down.
458
+ // XY is untouched — pad positions use the same absolute mapping as top parts.
459
+ if (c.layer === 'B.Cu') {
460
+ for (const ch of g.children) { ch.position.z = -ch.position.z; ch.rotation.x += Math.PI }
461
+ }
462
+
463
+ // Re-center on the pad centroid: many footprints (connectors, modules, board-edge
464
+ // parts) have their anchor far from the pad cluster. Templates build around the
465
+ // anchor (local origin), so without this the 3D body floats off its pads — the
466
+ // "big black boxes hanging off the board" bug. Shifting every child by the local
467
+ // pad-bbox center puts bodies on their pads; pins built at (p - L.c) land exactly
468
+ // on their pads too.
469
+ if (Math.abs(L.cx) > 1e-6 || Math.abs(L.cy) > 1e-6) {
470
+ for (const ch of g.children) { ch.position.x += L.cx; ch.position.y += L.cy }
471
+ }
472
+
473
+ // bounding dims in local frame (for the caller's explode-lift height)
474
+ const box = new THREE.Box3().setFromObject(g)
475
+ const size = box.getSize(new THREE.Vector3())
476
+ g.userData = { w: Math.max(size.x, 0.1), l: Math.max(size.y, 0.1), h: Math.max(size.z, 0.1) }
477
+
478
+ g.traverse((o) => { if ((o as THREE.Mesh).isMesh) { o.castShadow = true; o.receiveShadow = true } })
479
+ return g
480
+ }
@@ -0,0 +1,23 @@
1
+ // Shared PBR materials (tuned for a premium, realistic PCB look).
2
+ import * as THREE from 'three'
3
+ import { makeMaskGrainTexture } from './textures.js'
4
+
5
+ const grain = makeMaskGrainTexture()
6
+
7
+ export const M = {
8
+ fr4: new THREE.MeshStandardMaterial({ color: 0xdccaa0, roughness: 0.82, metalness: 0.0 }),
9
+ black: new THREE.MeshPhysicalMaterial({ color: 0x191a1e, roughness: 0.45, metalness: 0.06, clearcoat: 0.45, clearcoatRoughness: 0.35 }),
10
+ gold: new THREE.MeshStandardMaterial({ color: 0xe0b84e, metalness: 1.0, roughness: 0.22 }),
11
+ silver: new THREE.MeshPhysicalMaterial({ color: 0xd2d8df, metalness: 1.0, roughness: 0.26, anisotropy: 0.5 }),
12
+ shield: new THREE.MeshPhysicalMaterial({ color: 0xbcc1c7, metalness: 0.96, roughness: 0.32, anisotropy: 0.35 }),
13
+ modGreen: new THREE.MeshStandardMaterial({ color: 0x15271b, roughness: 0.72 }),
14
+ tan: new THREE.MeshStandardMaterial({ color: 0xa68f5a, roughness: 0.5 }),
15
+ blueCap: new THREE.MeshPhysicalMaterial({ color: 0x3950b4, roughness: 0.42, clearcoat: 0.3, clearcoatRoughness: 0.4 }),
16
+ amber: new THREE.MeshStandardMaterial({ color: 0xffb300, emissive: 0xff9500, emissiveIntensity: 0.6, roughness: 0.35 }),
17
+ darkGray: new THREE.MeshPhysicalMaterial({ color: 0x2d3036, roughness: 0.52, metalness: 0.18, clearcoat: 0.22, clearcoatRoughness: 0.45 }),
18
+ via: new THREE.MeshStandardMaterial({ color: 0xb87333, metalness: 0.9, roughness: 0.35, transparent: true, opacity: 1 }),
19
+ white: new THREE.MeshStandardMaterial({ color: 0xe9e7df, roughness: 0.55 }),
20
+ steel: new THREE.MeshStandardMaterial({ color: 0x8b9199, metalness: 0.9, roughness: 0.45 }),
21
+ }
22
+
23
+ export { grain }