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