@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/src/viewer.ts ADDED
@@ -0,0 +1,556 @@
1
+ // ---------------------------------------------------------------------------
2
+ // viewer.ts — the whole product as a vanilla-JS, framework-free embeddable unit.
3
+ //
4
+ // import { createViewer } from '@huaqiu/dsh-tool-pcb-viewer/viewer' (library use)
5
+ // const v = createViewer(document.getElementById('app')!, { brand: 'HUAQIU · 华秋电路' })
6
+ // v.loadBoard(pcbText, 'name.kicad_pcb'); v.setExplode(0.5); v.setDemo(true); v.dispose()
7
+ //
8
+ // Layout: left = 2D layout view (traces visible, KiCad-editor style),
9
+ // right = 3D cinematic render (orbit / explode / auto-demo).
10
+ // No React, no build-step assumptions — plain ESM + three (peer dependency).
11
+ //
12
+ // Parsing goes through the published @huaqiu/kicad-sexpr-parser + ./adapter.js.
13
+ // ---------------------------------------------------------------------------
14
+ import * as THREE from 'three'
15
+ import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js'
16
+ import { RenderPass } from 'three/addons/postprocessing/RenderPass.js'
17
+ import { SSAOPass } from 'three/addons/postprocessing/SSAOPass.js'
18
+ import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js'
19
+ import { BokehPass } from 'three/addons/postprocessing/BokehPass.js'
20
+ import { ShaderPass } from 'three/addons/postprocessing/ShaderPass.js'
21
+ import { OutputPass } from 'three/addons/postprocessing/OutputPass.js'
22
+ import { parseBoard } from './parse.js'
23
+ import { buildBoard, type BuiltBoard } from './scene/buildBoard.js'
24
+ import { outlineParts } from './pcb/outline.js'
25
+ import { create2DView } from './scene/view2d.js'
26
+ import DEMO_PCB from './assets/demo.js'
27
+
28
+ const clamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v))
29
+
30
+ // 画质档位:后处理逐 pass 开关 + 像素比。大板自动降档(实测 SSAO 是最贵的 pass)。
31
+ interface Tier { ssao: boolean; bloom: boolean; bokeh: boolean; dpr: number }
32
+ const TIERS: Record<string, Tier> = {
33
+ high: { ssao: true, bloom: true, bokeh: true, dpr: 2 },
34
+ medium: { ssao: false, bloom: true, bokeh: false, dpr: 1.5 },
35
+ low: { ssao: false, bloom: false, bokeh: false, dpr: 1 },
36
+ }
37
+ const TIER_ORDER = ['auto', 'high', 'medium', 'low'] as const
38
+ type QualityMode = (typeof TIER_ORDER)[number]
39
+
40
+ /** 按板子规模自动选档:器件越多越省。 */
41
+ function autoTier(compCount: number): string {
42
+ if (compCount > 900) return 'low'
43
+ if (compCount > 450) return 'medium'
44
+ return 'high'
45
+ }
46
+ const easeLux = (p: number) => (p < 0.5 ? 8 * p * p * p * p : 1 - Math.pow(-2 * p + 2, 4) / 2)
47
+
48
+ function demoCurve(t: number): number {
49
+ const T = 20
50
+ t = t % T
51
+ if (t < 3) return 0
52
+ if (t < 8.5) return easeLux((t - 3) / 5.5)
53
+ if (t < 11.5) return 1
54
+ if (t < 17) return 1 - easeLux((t - 11.5) / 5.5)
55
+ return 0
56
+ }
57
+
58
+ function makeStudioEnv(): THREE.Scene {
59
+ const s = new THREE.Scene()
60
+ s.background = new THREE.Color(0x0a0c10)
61
+ const panel = (w: number, h: number, color: number, intensity: number, pos: THREE.Vector3, look: THREE.Vector3) => {
62
+ const m = new THREE.Mesh(new THREE.PlaneGeometry(w, h), new THREE.MeshBasicMaterial({ color: new THREE.Color(color).multiplyScalar(intensity), side: THREE.DoubleSide }))
63
+ m.position.copy(pos); m.lookAt(look); s.add(m)
64
+ }
65
+ const c = new THREE.Vector3(0, 0, 4)
66
+ panel(150, 90, 0xffffff, 5.5, new THREE.Vector3(60, -70, 120), c)
67
+ panel(120, 70, 0xbcd2ff, 2.4, new THREE.Vector3(-80, 90, 70), c)
68
+ panel(200, 60, 0x4fd8c4, 1.5, new THREE.Vector3(-40, 30, -140), c)
69
+ panel(300, 300, 0x1a2028, 0.7, new THREE.Vector3(0, 0, 220), c)
70
+ return s
71
+ }
72
+
73
+ const GradeShader = {
74
+ uniforms: { tDiffuse: { value: null as null } },
75
+ vertexShader: `varying vec2 vUv; void main(){ vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position,1.0); }`,
76
+ fragmentShader: `
77
+ uniform sampler2D tDiffuse; varying vec2 vUv;
78
+ float luma(vec3 c){ return dot(c, vec3(0.299,0.587,0.114)); }
79
+ void main(){
80
+ vec3 col = texture2D(tDiffuse, vUv).rgb;
81
+ float l = luma(col);
82
+ col *= mix(vec3(0.86,1.0,0.98), vec3(1.05,1.0,0.94), smoothstep(0.0, 0.7, l));
83
+ vec2 d = vUv - 0.5;
84
+ col *= mix(0.78, 1.0, smoothstep(1.0, 0.42, length(d) * 1.1));
85
+ gl_FragColor = vec4(col, 1.0);
86
+ }`,
87
+ }
88
+
89
+ const CSS = `
90
+ .k3v-root{position:relative;width:100%;height:100%;display:flex;background:#090b0f;overflow:hidden;font-family:ui-monospace,"SF Mono",Menlo,monospace}
91
+ .k3v-2d{width:38%;min-width:260px;border-right:1px solid rgba(95,224,205,.14);position:relative;background:#0b0e13}
92
+ .k3v-2d canvas{position:absolute;inset:0;width:100%;height:100%;display:block;cursor:grab}
93
+ .k3v-2d .k3v-pane-title{position:absolute;top:14px;left:16px;font-size:10px;letter-spacing:.3em;color:#5fe0cd;opacity:.75;pointer-events:none}
94
+ .k3v-3d{flex:1;position:relative}
95
+ .k3v-3d canvas{position:absolute;inset:0;width:100%;height:100%;display:block}
96
+ .k3v-hud{position:absolute;z-index:10;color:#eef2f6;user-select:none}
97
+ .k3v-brand{top:20px;left:24px;letter-spacing:.2em;font-size:13px;opacity:.92;pointer-events:none}
98
+ .k3v-brand b{color:#5fe0cd;font-weight:700;text-shadow:0 0 18px rgba(95,224,205,.35)}
99
+ .k3v-brand span{display:block;margin-top:4px;font-size:9px;letter-spacing:.3em;opacity:.5}
100
+ .k3v-ctrl{top:20px;right:24px;display:flex;align-items:center;gap:12px;font-size:11px;letter-spacing:.14em}
101
+ .k3v-bar{width:130px;height:2px;background:rgba(255,255,255,.12);position:relative;overflow:hidden}
102
+ .k3v-bar i{position:absolute;inset:0 auto 0 0;background:linear-gradient(90deg,#3fb8a6,#5fe0cd);width:0%;box-shadow:0 0 10px rgba(95,224,205,.5)}
103
+ .k3v-demo{background:rgba(95,224,205,.05);border:1px solid rgba(95,224,205,.45);color:#5fe0cd;font:inherit;letter-spacing:.16em;padding:7px 12px;cursor:pointer;transition:all .2s;backdrop-filter:blur(4px)}
104
+ .k3v-demo:hover{background:rgba(95,224,205,.14);box-shadow:0 0 16px rgba(95,224,205,.25)}
105
+ .k3v-quality{background:rgba(95,224,205,.05);border:1px solid rgba(95,224,205,.3);color:#9fe8dc;font:inherit;letter-spacing:.12em;padding:7px 10px;cursor:pointer;transition:all .2s;backdrop-filter:blur(4px);min-width:120px}
106
+ .k3v-quality:hover{background:rgba(95,224,205,.14);color:#5fe0cd}
107
+ .k3v-openwrap{top:56px;right:24px;display:flex;align-items:center;gap:10px;font-size:10px;letter-spacing:.14em}
108
+ .k3v-bname{color:#9fb2c4;opacity:.8;max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
109
+ .k3v-open{background:rgba(159,178,196,.06);border:1px solid rgba(159,178,196,.4);color:#cdd8e4;font:inherit;letter-spacing:.14em;padding:6px 10px;cursor:pointer;transition:all .2s;backdrop-filter:blur(4px)}
110
+ .k3v-open:hover{background:rgba(159,178,196,.16);border-color:#5fe0cd;color:#5fe0cd}
111
+ .k3v-sliderwrap{position:absolute;bottom:64px;left:50%;transform:translateX(-50%);display:flex;align-items:center;gap:14px;z-index:10;color:#eef2f6}
112
+ .k3v-sliderwrap label{font-size:9px;letter-spacing:.26em;opacity:.55}
113
+ .k3v-explode{-webkit-appearance:none;appearance:none;width:320px;height:2px;background:rgba(255,255,255,.14);outline:none;cursor:pointer}
114
+ .k3v-explode::-webkit-slider-thumb{-webkit-appearance:none;width:13px;height:13px;border-radius:50%;background:#5fe0cd;box-shadow:0 0 12px rgba(95,224,205,.7);cursor:pointer}
115
+ .k3v-explode::-moz-range-thumb{width:13px;height:13px;border:none;border-radius:50%;background:#5fe0cd;box-shadow:0 0 12px rgba(95,224,205,.7);cursor:pointer}
116
+ .k3v-hint{bottom:24px;left:50%;transform:translateX(-50%);font-size:10px;letter-spacing:.14em;opacity:.6;transition:opacity .8s;white-space:nowrap;pointer-events:none}
117
+ .k3v-hint.k3v-dim{opacity:.2}
118
+ .k3v-caption{top:84px;left:50%;transform:translateX(-50%);font-size:11px;letter-spacing:.3em;opacity:0;transition:opacity .6s;color:#aef0e4;text-shadow:0 0 14px rgba(95,224,205,.3);pointer-events:none}
119
+ .k3v-corner{position:absolute;width:22px;height:22px;z-index:9;border:1px solid rgba(95,224,205,.3);pointer-events:none}
120
+ .k3v-c-tl{top:12px;left:12px;border-right:none;border-bottom:none}
121
+ .k3v-c-tr{top:12px;right:12px;border-left:none;border-bottom:none}
122
+ .k3v-c-bl{bottom:12px;left:12px;border-right:none;border-top:none}
123
+ .k3v-c-br{bottom:12px;right:12px;border-left:none;border-top:none}
124
+ .k3v-root.k3v-dropping::after{content:'DROP .KICAD_PCB TO RENDER';position:absolute;inset:0;z-index:50;display:flex;align-items:center;justify-content:center;background:rgba(9,11,15,.72);color:#5fe0cd;font-weight:700;font-size:17px;letter-spacing:.3em;border:2px dashed rgba(95,224,205,.6);pointer-events:none}
125
+ /* view modes: split (default) / 2d only / 3d only */
126
+ .k3v-root.k3v-mode-2d .k3v-3d{display:none}
127
+ .k3v-root.k3v-mode-2d .k3v-2d{width:100%;border-right:none}
128
+ .k3v-root.k3v-mode-3d .k3v-2d{display:none}
129
+ @media (max-width:900px){.k3v-root.k3v-mode-split .k3v-2d{display:none}}
130
+ `
131
+
132
+ let styleInjected = false
133
+ function injectStyle(): void {
134
+ if (styleInjected || typeof document === 'undefined') return
135
+ const el = document.createElement('style')
136
+ el.setAttribute('data-k3v', '1')
137
+ el.textContent = CSS
138
+ document.head.appendChild(el)
139
+ styleInjected = true
140
+ }
141
+
142
+ declare global {
143
+ interface Window {
144
+ __dbg?: Record<string, unknown>
145
+ __PCB_TEXT__?: string
146
+ __PCB_NAME__?: string
147
+ }
148
+ }
149
+
150
+ /** Parse cache keyed by board identity — re-opening the same board (2D/3D switch,
151
+ * panel vs thumbnail) then skips the 1.3s parse on an 81MB file. */
152
+ const modelCache = new Map<string, ReturnType<typeof parseBoard>>()
153
+ const MODEL_CACHE_CAP = 6
154
+
155
+ function parseCached(text: string, key?: string) {
156
+ if (!key) return parseBoard(text)
157
+ const hit = modelCache.get(key)
158
+ if (hit) return hit
159
+ const model = parseBoard(text)
160
+ if (modelCache.size >= MODEL_CACHE_CAP) {
161
+ const oldest = modelCache.keys().next().value
162
+ if (oldest !== undefined) modelCache.delete(oldest)
163
+ }
164
+ modelCache.set(key, model)
165
+ return model
166
+ }
167
+
168
+ export interface ViewerOptions {
169
+ /** stable identity for the board text — enables the parse cache */
170
+ cacheKey?: string
171
+ brand?: string
172
+ brandSub?: string
173
+ board?: string
174
+ boardName?: string
175
+ mode?: 'split' | '2d' | '3d'
176
+ hud?: boolean
177
+ post?: boolean
178
+ interactive?: boolean
179
+ textures?: boolean
180
+ autoDemo?: boolean
181
+ onError?: (e: unknown) => void
182
+ }
183
+
184
+ export type ViewMode = 'split' | '2d' | '3d'
185
+ type BoardHandle = BuiltBoard
186
+
187
+ export interface ViewerApi {
188
+ loadBoard(text: string, name?: string): BoardHandle
189
+ setExplode(v: number): void
190
+ setDemo(on: boolean): void
191
+ setMode(mode: ViewMode): ViewMode
192
+ getMode(): ViewMode
193
+ setQuality(mode: string): void
194
+ getQuality(): { mode: string; tier: string }
195
+ dispose(): void
196
+ }
197
+
198
+ export function createViewer(container: HTMLElement, opts: ViewerOptions = {}): ViewerApi {
199
+ injectStyle()
200
+ // 轻量模式:缩略图等场景用 —— 不建 HUD、不走后处理、不绑交互(省 GPU/CPU)
201
+ const wantHud = opts.hud !== false
202
+ const wantPost = opts.post !== false
203
+ const wantInteractive = opts.interactive !== false
204
+ const brand = opts.brand ?? 'HUAQIU · 华秋电路'
205
+ const sub = opts.brandSub ?? 'PCB IRON-MAN DISASSEMBLY'
206
+
207
+ container.classList.add('k3v-root-host')
208
+ const root = document.createElement('div')
209
+ const initialMode: ViewMode = opts.mode === '2d' || opts.mode === '3d' ? opts.mode : 'split'
210
+ root.className = 'k3v-root k3v-mode-' + initialMode
211
+ root.innerHTML = (wantHud ? `
212
+ <div class="k3v-2d"><canvas class="k3v-2d-canvas"></canvas><div class="k3v-pane-title">2D LAYOUT · 走线视图</div></div>
213
+ <div class="k3v-3d">
214
+ <div class="k3v-hud k3v-brand"><b>${brand.split('·')[0]!.trim()}</b> · ${brand.split('·')[1]?.trim() ?? ''}<span>${sub}</span></div>
215
+ <div class="k3v-hud k3v-ctrl">
216
+ <button class="k3v-demo">▶ AUTO DEMO</button>
217
+ <button class="k3v-quality" title="画质档位(点击循环:AUTO → HIGH → MEDIUM → LOW)">— FPS · AUTO</button>
218
+ <div class="k3v-bar"><i></i></div><span class="k3v-pct">0%</span>
219
+ </div>
220
+ <div class="k3v-hud k3v-openwrap">
221
+ <span class="k3v-bname"></span>
222
+ <button class="k3v-open">+ 打开 .KICAD_PCB</button>
223
+ <input type="file" class="k3v-file" accept=".kicad_pcb" style="display:none" />
224
+ </div>
225
+ <div class="k3v-sliderwrap"><label>DISASSEMBLY</label><input type="range" class="k3v-explode" min="0" max="100" value="0" step="1" /></div>
226
+ <div class="k3v-hud k3v-hint">拖拽旋转 · 滚轮/滑杆拆解 · 左图可缩放 — DRAG TO ORBIT · SCROLL TO DISASSEMBLE</div>
227
+ <div class="k3v-hud k3v-caption"></div>
228
+ </div>
229
+ <div class="k3v-corner k3v-c-tl"></div><div class="k3v-corner k3v-c-tr"></div><div class="k3v-corner k3v-c-bl"></div><div class="k3v-corner k3v-c-br"></div>` : `
230
+ <div class="k3v-2d"><canvas class="k3v-2d-canvas"></canvas></div>
231
+ <div class="k3v-3d"></div>`)
232
+ container.appendChild(root)
233
+
234
+ const q = (sel: string) => root.querySelector(sel)
235
+ const pane3d = q('.k3v-3d') as HTMLElement
236
+ const canvas2d = q('.k3v-2d-canvas') as HTMLCanvasElement
237
+ const demoBtn = q('.k3v-demo') as HTMLButtonElement | null
238
+ const slider = q('.k3v-explode') as HTMLInputElement | null
239
+ const uBar = q('.k3v-bar i') as HTMLElement | null
240
+ const uPct = q('.k3v-pct') as HTMLElement | null
241
+ const qualityBtn = q('.k3v-quality') as HTMLButtonElement | null
242
+ const boardName = q('.k3v-bname') as HTMLElement | null
243
+ const openBtn = q('.k3v-open') as HTMLButtonElement | null
244
+ const fileInput = q('.k3v-file') as HTMLInputElement | null
245
+ const hint = q('.k3v-hint') as HTMLElement | null
246
+ const caption = q('.k3v-caption') as HTMLElement | null
247
+
248
+ // ---------- renderer / scene ----------
249
+ const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' })
250
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
251
+ renderer.shadowMap.enabled = true
252
+ renderer.shadowMap.type = THREE.PCFSoftShadowMap
253
+ renderer.toneMapping = THREE.ACESFilmicToneMapping
254
+ renderer.toneMappingExposure = 1.32
255
+ pane3d.appendChild(renderer.domElement)
256
+
257
+ const scene = new THREE.Scene()
258
+ scene.background = new THREE.Color(0x090b0f)
259
+ const fog = new THREE.Fog(0x090b0f, 360, 820)
260
+ scene.fog = fog
261
+ const pmrem = new THREE.PMREMGenerator(renderer)
262
+ scene.environment = pmrem.fromScene(makeStudioEnv(), 0.04).texture
263
+ if ('environmentIntensity' in scene) scene.environmentIntensity = 1.0
264
+
265
+ const camera = new THREE.PerspectiveCamera(30, 1, 1, 2000)
266
+ const target = new THREE.Vector3(0, -2, 7)
267
+ const INIT = { theta: -0.14, phi: THREE.MathUtils.degToRad(38), radius: 205 }
268
+ let theta = INIT.theta, phi = INIT.phi, radius = INIT.radius
269
+ let tTheta = INIT.theta, tPhi = INIT.phi, tRadius = INIT.radius
270
+
271
+ const hemi = new THREE.HemisphereLight(0x9fb2c4, 0x1a1f26, 0.95)
272
+ scene.add(hemi)
273
+ const key = new THREE.DirectionalLight(0xfff3e4, 1.6)
274
+ key.position.set(90, -70, 140)
275
+ key.castShadow = true
276
+ key.shadow.mapSize.set(2048, 2048)
277
+ key.shadow.radius = 6
278
+ key.shadow.camera.left = -95; key.shadow.camera.right = 95
279
+ key.shadow.camera.top = 95; key.shadow.camera.bottom = -95
280
+ key.shadow.camera.far = 520
281
+ key.shadow.bias = -0.0004
282
+ scene.add(key)
283
+ const fill = new THREE.DirectionalLight(0xcfe0ff, 0.8); fill.position.set(-70, 90, 60); scene.add(fill)
284
+ const rim = new THREE.DirectionalLight(0x4fd8c4, 0.45); rim.position.set(-130, 50, 20); scene.add(rim)
285
+ const ground = new THREE.Mesh(new THREE.CircleGeometry(360, 72), new THREE.MeshStandardMaterial({ color: 0x232a33, roughness: 0.32, metalness: 0.5 }))
286
+ ground.position.z = -1.6; ground.receiveShadow = true; scene.add(ground)
287
+
288
+ // ---------- state ----------
289
+ let u = 0, targetU = 0
290
+ let dragging = false, lastX = 0, lastY = 0
291
+ let velTheta = 0, velPhi = 0
292
+ let demoMode = false, demoT = 0
293
+ let movedBefore = false
294
+ const clock = new THREE.Clock()
295
+ let lastFps = 0
296
+ let fpsFrames = 0
297
+ let fpsT0 = 0
298
+
299
+ const v2d = create2DView(canvas2d)
300
+
301
+ // ---------- quality tiers ----------
302
+ let qualityMode: QualityMode = 'auto'
303
+ let activeTier = 'high'
304
+ let qualityReady = false // 后处理 pass 建好之前不能应用档位(TDZ)
305
+ let ssao: SSAOPass | null = null
306
+ let bloom: UnrealBloomPass | null = null
307
+ let bokeh: BokehPass | null = null
308
+ let composer: EffectComposer | null = null
309
+
310
+ function applyTier(tier: string): void {
311
+ activeTier = tier
312
+ const t = TIERS[tier]!
313
+ if (ssao) ssao.enabled = t.ssao
314
+ if (bloom) bloom.enabled = t.bloom
315
+ if (bokeh) bokeh.enabled = t.bokeh
316
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, t.dpr))
317
+ onResize()
318
+ if (qualityBtn) qualityBtn.textContent = (lastFps ? Math.round(lastFps) + ' FPS · ' : '') + qualityMode.toUpperCase()
319
+ }
320
+ function setQuality(mode: string): void {
321
+ qualityMode = (TIER_ORDER as readonly string[]).includes(mode) ? mode as QualityMode : 'auto'
322
+ applyTier(qualityMode === 'auto' ? autoTier(board?.comps?.length ?? 0) : qualityMode)
323
+ }
324
+
325
+ // ---------- board loading ----------
326
+ let board: BoardHandle | null = null
327
+ function loadBoard(text: string, name?: string | null): BoardHandle {
328
+ const parsed = parseCached(text, opts.cacheKey)
329
+ const nb = buildBoard(parsed, { textures: opts.textures !== false })
330
+ if (board) { scene.remove(board.group); board.dispose() }
331
+ board = nb
332
+ scene.add(board.group)
333
+ v2d.setBoard(parsed, outlineParts(parsed))
334
+ if (typeof window !== 'undefined' && window.__dbg) window.__dbg.board = board
335
+ const diag = Math.hypot(board.boardW, board.boardH)
336
+ if (wantHud) {
337
+ tRadius = radius = THREE.MathUtils.clamp(diag * 1.55, 90, 900)
338
+ } else {
339
+ // 缩略图:对准板面(target.z≈0)、更近,板子占满画面
340
+ target.set(0, 0, Math.max(1.5, diag * 0.05))
341
+ tRadius = radius = THREE.MathUtils.clamp(diag * 1.34, 60, 900)
342
+ tPhi = phi = THREE.MathUtils.degToRad(48)
343
+ tTheta = theta = -0.22
344
+ }
345
+ fog.near = diag * 3.2; fog.far = diag * 7.5
346
+ camera.far = diag * 20; camera.updateProjectionMatrix()
347
+ const s = diag * 0.75 + 20
348
+ key.shadow.camera.left = -s; key.shadow.camera.right = s
349
+ key.shadow.camera.top = s; key.shadow.camera.bottom = -s
350
+ key.shadow.camera.far = diag * 4
351
+ key.shadow.camera.updateProjectionMatrix()
352
+ key.position.set(diag * 0.45, -diag * 0.35, diag * 0.7)
353
+ ground.scale.setScalar(Math.max(1, diag / 230))
354
+ if (boardName) boardName.textContent = name || ''
355
+ u = 0; targetU = 0; movedBefore = false
356
+ if (qualityReady) applyTier(qualityMode === 'auto' ? autoTier(board.comps?.length ?? 0) : qualityMode)
357
+ return board
358
+ }
359
+
360
+ const winText = typeof window !== 'undefined' ? window.__PCB_TEXT__ : undefined
361
+ const winName = typeof window !== 'undefined' ? window.__PCB_NAME__ : undefined
362
+ const initialText = opts.board ?? winText ?? DEMO_PCB
363
+ const initialName = opts.boardName ?? winName ?? 'ESP-MCB V1.0 · DEMO'
364
+ const initialBoard = loadBoard(initialText, initialName)
365
+
366
+ // ---------- post pipeline ----------
367
+ if (wantPost) {
368
+ composer = new EffectComposer(renderer)
369
+ composer.addPass(new RenderPass(scene, camera))
370
+ ssao = new SSAOPass(scene, camera, 1280, 720)
371
+ ssao.kernelRadius = 30; ssao.minDistance = 0.015; ssao.maxDistance = 14
372
+ composer.addPass(ssao)
373
+ bloom = new UnrealBloomPass(new THREE.Vector2(1280, 720), 0.22, 0.85, 0.9)
374
+ composer.addPass(bloom)
375
+ bokeh = new BokehPass(scene, camera, { focus: 0.0, aperture: 0.000015, maxblur: 0.0012 })
376
+ composer.addPass(bokeh)
377
+ composer.addPass(new ShaderPass(GradeShader as unknown as ConstructorParameters<typeof ShaderPass>[0]))
378
+ composer.addPass(new OutputPass())
379
+ }
380
+ qualityReady = true
381
+ applyTier(qualityMode === 'auto' ? autoTier(initialBoard.comps?.length ?? 0) : qualityMode)
382
+
383
+ if (typeof window !== 'undefined') window.__dbg = { scene, camera, renderer, board, THREE, loadBoard: (t: string, n?: string) => loadBoard(t, n ?? 'custom'), parseKicad: (t: string) => parseBoard(t) }
384
+
385
+ // ---------- interaction (3D pane) ----------
386
+ const el = renderer.domElement
387
+ el.style.touchAction = 'none'; el.style.cursor = 'grab'
388
+ function onDown(e: PointerEvent) {
389
+ dragging = true; lastX = e.clientX; lastY = e.clientY; velTheta = 0; velPhi = 0
390
+ el.style.cursor = 'grabbing'
391
+ if (demoMode) setDemo(false)
392
+ hint && hint.classList.add('k3v-dim')
393
+ }
394
+ function onMove(e: PointerEvent) {
395
+ if (!dragging) return
396
+ const dx = e.clientX - lastX, dy = e.clientY - lastY
397
+ lastX = e.clientX; lastY = e.clientY
398
+ tTheta -= dx * 0.0052
399
+ tPhi = clamp(tPhi + dy * 0.0042, THREE.MathUtils.degToRad(6), THREE.MathUtils.degToRad(88))
400
+ velTheta = -dx * 0.0052; velPhi = dy * 0.0042
401
+ }
402
+ function onUp() { if (dragging) { dragging = false; el.style.cursor = 'grab' } }
403
+ if (wantInteractive) {
404
+ el.addEventListener('pointerdown', onDown as EventListener)
405
+ window.addEventListener('pointermove', onMove as EventListener)
406
+ window.addEventListener('pointerup', onUp)
407
+ window.addEventListener('pointercancel', onUp)
408
+ }
409
+ function onWheel(e: WheelEvent) { e.preventDefault(); if (demoMode) setDemo(false); targetU = clamp(targetU + e.deltaY * 0.0011, 0, 1); hint && hint.classList.add('k3v-dim') }
410
+ if (wantInteractive) el.addEventListener('wheel', onWheel, { passive: false })
411
+ function onDblClick() { tTheta = INIT.theta; tPhi = INIT.phi; tRadius = INIT.radius }
412
+ if (wantInteractive) el.addEventListener('dblclick', onDblClick)
413
+ if (slider) slider.addEventListener('input', () => { if (demoMode) setDemo(false); targetU = clamp(parseInt(slider.value, 10) / 100, 0, 1); hint && hint.classList.add('k3v-dim') })
414
+ function setDemo(on: boolean): void { demoMode = on; if (on) demoT = 0; if (demoBtn) demoBtn.textContent = on ? '⏸ STOP DEMO' : '▶ AUTO DEMO' }
415
+ if (demoBtn) demoBtn.addEventListener('click', () => setDemo(!demoMode))
416
+ if (qualityBtn) qualityBtn.addEventListener('click', () => {
417
+ const i = TIER_ORDER.indexOf(qualityMode)
418
+ setQuality(TIER_ORDER[(i + 1) % TIER_ORDER.length]!)
419
+ })
420
+
421
+ function readBoardFile(f: File): void {
422
+ const r = new FileReader()
423
+ r.onload = () => { try { loadBoard(String(r.result), f.name) } catch (e) { console.error(e); if (opts.onError) opts.onError(e); else alert('无法解析该文件 / failed to parse: ' + (e instanceof Error ? e.message : e)) } }
424
+ r.readAsText(f)
425
+ }
426
+ const onFile = () => { const f = fileInput?.files?.[0]; if (f) readBoardFile(f); if (fileInput) fileInput.value = '' }
427
+ if (fileInput) fileInput.addEventListener('change', onFile)
428
+ const onOpen = () => fileInput?.click()
429
+ if (openBtn) openBtn.addEventListener('click', onOpen)
430
+ const onDragOver = (e: DragEvent) => { e.preventDefault(); root.classList.add('k3v-dropping') }
431
+ const onDragLeave = (e: DragEvent) => { if (!e.relatedTarget) root.classList.remove('k3v-dropping') }
432
+ const onDrop = (e: DragEvent) => {
433
+ e.preventDefault(); root.classList.remove('k3v-dropping')
434
+ const f = e.dataTransfer?.files?.[0]
435
+ if (f && /\.kicad_pcb$/i.test(f.name)) readBoardFile(f)
436
+ }
437
+ if (wantInteractive) {
438
+ window.addEventListener('dragover', onDragOver)
439
+ window.addEventListener('dragleave', onDragLeave)
440
+ window.addEventListener('drop', onDrop)
441
+ }
442
+
443
+ function onResize(): void {
444
+ const r = pane3d.getBoundingClientRect()
445
+ const w = Math.max(1, r.width), h = Math.max(1, r.height)
446
+ camera.aspect = w / h
447
+ camera.updateProjectionMatrix()
448
+ renderer.setSize(w, h)
449
+ if (composer) composer.setSize(w, h)
450
+ }
451
+ const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(onResize) : null
452
+ ro && ro.observe(pane3d)
453
+ onResize()
454
+
455
+ // ---------- frame loop ----------
456
+ let raf = 0
457
+ function frame(): void {
458
+ raf = requestAnimationFrame(frame)
459
+ const dt = Math.min(clock.getDelta(), 0.05)
460
+ if (demoMode) {
461
+ demoT += dt
462
+ targetU = demoCurve(demoT)
463
+ tTheta += dt * 0.12
464
+ } else if (!dragging && (Math.abs(velTheta) > 1e-4 || Math.abs(velPhi) > 1e-4)) {
465
+ tTheta += velTheta
466
+ tPhi = clamp(tPhi + velPhi, THREE.MathUtils.degToRad(6), THREE.MathUtils.degToRad(88))
467
+ velTheta *= Math.exp(-dt * 3.5); velPhi *= Math.exp(-dt * 3.5)
468
+ }
469
+ const k = 1 - Math.exp(-dt * 9)
470
+ theta += (tTheta - theta) * k
471
+ phi += (tPhi - phi) * k
472
+ radius += (tRadius - radius) * k
473
+ const prevU = u
474
+ u += (targetU - u) * (1 - Math.exp(-dt * 9))
475
+ if (Math.abs(u - prevU) > 1e-5) movedBefore = true
476
+ const speed = Math.abs(u - prevU) / Math.max(dt, 1e-4)
477
+ board!.update(u, speed)
478
+ const effRadius = radius * (1 + 0.42 * u)
479
+ const cp = Math.cos(phi), sp = Math.sin(phi)
480
+ camera.position.set(
481
+ target.x + effRadius * cp * Math.sin(theta),
482
+ target.y - effRadius * cp * Math.cos(theta),
483
+ target.z + effRadius * sp
484
+ )
485
+ camera.lookAt(target)
486
+ if (composer && bokeh) {
487
+ const dist = effRadius + 7
488
+ ;(bokeh.uniforms as Record<string, { value: number }>)['focus']!.value = clamp((camera.near + camera.far) / (2 * dist), 0, 1)
489
+ composer.render()
490
+ } else {
491
+ renderer.render(scene, camera)
492
+ }
493
+ // 帧率统计(1 秒窗口)并刷新画质按钮文案
494
+ fpsFrames += 1
495
+ const now = performance.now()
496
+ if (!fpsT0) fpsT0 = now
497
+ if (now - fpsT0 >= 1000) {
498
+ lastFps = (fpsFrames * 1000) / (now - fpsT0)
499
+ fpsFrames = 0
500
+ fpsT0 = now
501
+ if (qualityBtn) qualityBtn.textContent = Math.round(lastFps) + ' FPS · ' + qualityMode.toUpperCase()
502
+ }
503
+ if (uBar) uBar.style.width = (u * 100).toFixed(1) + '%'
504
+ if (uPct) uPct.textContent = Math.round(u * 100) + '%'
505
+ if (slider && document.activeElement !== slider) slider.value = String(Math.round(u * 100))
506
+ if (caption) {
507
+ caption.textContent = u > 0.94 ? `EXPLODED VIEW · ${board ? board.layerCount : 2}-LAYER STACKUP` : movedBefore && u < 0.03 ? 'FULLY ASSEMBLED' : ''
508
+ caption.style.opacity = caption.textContent ? '1' : '0'
509
+ }
510
+ }
511
+ frame()
512
+
513
+ if (opts.autoDemo) setDemo(true)
514
+
515
+ function setMode(mode: ViewMode): ViewMode {
516
+ const next: ViewMode = mode === '2d' || mode === '3d' ? mode : 'split'
517
+ root.classList.remove('k3v-mode-split', 'k3v-mode-2d', 'k3v-mode-3d')
518
+ root.classList.add('k3v-mode-' + next)
519
+ // 面板/分栏尺寸变了,两个视图各自按新尺寸重排
520
+ onResize()
521
+ v2d.redraw()
522
+ return next
523
+ }
524
+
525
+ return {
526
+ loadBoard,
527
+ setMode,
528
+ getMode() { return [...root.classList].find((c) => c.startsWith('k3v-mode-'))?.slice('k3v-mode-'.length) as ViewMode ?? 'split' },
529
+ setQuality,
530
+ getQuality() { return { mode: qualityMode, tier: activeTier } },
531
+ setExplode(v: number) { targetU = clamp(v, 0, 1) },
532
+ setDemo,
533
+ dispose() {
534
+ cancelAnimationFrame(raf)
535
+ if (wantInteractive) {
536
+ el.removeEventListener('pointerdown', onDown as EventListener)
537
+ window.removeEventListener('pointermove', onMove as EventListener)
538
+ window.removeEventListener('pointerup', onUp)
539
+ window.removeEventListener('pointercancel', onUp)
540
+ el.removeEventListener('wheel', onWheel)
541
+ el.removeEventListener('dblclick', onDblClick)
542
+ window.removeEventListener('dragover', onDragOver)
543
+ window.removeEventListener('dragleave', onDragLeave)
544
+ window.removeEventListener('drop', onDrop)
545
+ }
546
+ if (fileInput) fileInput.removeEventListener('change', onFile)
547
+ if (openBtn) openBtn.removeEventListener('click', onOpen)
548
+ ro && ro.disconnect()
549
+ v2d.destroy()
550
+ board && board.dispose()
551
+ if (composer) composer.dispose()
552
+ renderer.dispose()
553
+ container.removeChild(root)
554
+ },
555
+ }
556
+ }