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