@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,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser half entry — DSH client plugin (turn-tail card + singleton panel).
|
|
3
|
+
* Everything lives in ./panel.tsx; this entry exists so the tsdown client
|
|
4
|
+
* bundle has a stable, conventional entry path (matches the other plugins).
|
|
5
|
+
*/
|
|
6
|
+
export { apply, inject } from './panel.js'
|
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
// kicad-3d-viewer — DSH 浏览器半边(client 源)。
|
|
2
|
+
//
|
|
3
|
+
// 交互形态(按用户要求):
|
|
4
|
+
// - **模型推送的卡片**:pcb_preview 所在的回合末尾(turn tail,正文流里、不悬浮)
|
|
5
|
+
// 出现一张卡片:板子实时 2D 缩略图 + 文件名 + 参数 + 「显示 PCB / 显示 3D /
|
|
6
|
+
// 浏览器打开」三个按钮。
|
|
7
|
+
// - 数据通路:
|
|
8
|
+
// 1) 会话观察器(挂在 conversation.session.header.actions,渲染 null)持续扫会话
|
|
9
|
+
// 快照,维护 回合号 → 预览 的映射(模块级 store)。
|
|
10
|
+
// 2) 卡片注册在 conversation.chat.turnTail(chain 槽位,官方 deliverables 卡片同一行),
|
|
11
|
+
// select 用回合号查映射表自我选举(priority -1 = 先于 deliverables;该回合没有
|
|
12
|
+
// 预览时让位给它)。
|
|
13
|
+
// 3) 大屏面板仍是 fixed 悬浮层,由卡片按钮打开;单例(模块级状态)。
|
|
14
|
+
//
|
|
15
|
+
// 本文件经 scripts/build-client.mjs 打包为 client.js(ModuleLoader 单文件 CJS)。
|
|
16
|
+
|
|
17
|
+
import React, { useEffect, useRef, useState } from 'react'
|
|
18
|
+
import { createViewer, type ViewMode } from '../viewer.js'
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------- 宿主平台的客户端类型
|
|
21
|
+
// DSH 的 client 类型没有以包的形式安装;这里按实际用到的形状做最小本地声明,
|
|
22
|
+
// ctx / owner / slot props 一律窄化到局部接口,不做全局假设。
|
|
23
|
+
interface ClientContext {
|
|
24
|
+
slots: {
|
|
25
|
+
inject(name: string, cb: () => unknown): void
|
|
26
|
+
register(spec: SlotSpec, component: React.ComponentType<never> | ((props: never) => unknown)): unknown
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
interface SlotSpec {
|
|
30
|
+
name: string
|
|
31
|
+
id?: string
|
|
32
|
+
order?: number
|
|
33
|
+
priority?: number
|
|
34
|
+
select?: (owner: SessionOwner) => PreviewPayload | null
|
|
35
|
+
inject?: () => Record<string, unknown>
|
|
36
|
+
}
|
|
37
|
+
type SessionNode = Record<string, unknown>
|
|
38
|
+
interface SessionSnapshot {
|
|
39
|
+
nodes?: SessionNode[]
|
|
40
|
+
}
|
|
41
|
+
/** turnTail 的 select 收到的宿主侧 owner(只用 turn.turn)。 */
|
|
42
|
+
interface SessionOwner {
|
|
43
|
+
turn?: { turn?: number }
|
|
44
|
+
}
|
|
45
|
+
/** 会话快照的 useSession 选择器(宿主注入)。 */
|
|
46
|
+
type UseSession = <T>(selector: (snapshot: SessionSnapshot | undefined) => T) => T
|
|
47
|
+
|
|
48
|
+
const inject = ['slots']
|
|
49
|
+
|
|
50
|
+
const EMPTY_NODES: SessionNode[] = []
|
|
51
|
+
const MAX_WALK_DEPTH = 100
|
|
52
|
+
const TOOL_NAME = 'pcb_preview'
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------- 工具结果解析
|
|
55
|
+
interface PreviewStats {
|
|
56
|
+
sizeKB?: number
|
|
57
|
+
widthMM?: number
|
|
58
|
+
heightMM?: number
|
|
59
|
+
layers?: number
|
|
60
|
+
comps?: number
|
|
61
|
+
pads?: number
|
|
62
|
+
traces?: number
|
|
63
|
+
vias?: number
|
|
64
|
+
}
|
|
65
|
+
interface PreviewPayload {
|
|
66
|
+
ok: boolean
|
|
67
|
+
key?: string
|
|
68
|
+
viewUrl?: string
|
|
69
|
+
name?: string
|
|
70
|
+
stats?: PreviewStats
|
|
71
|
+
}
|
|
72
|
+
/** tool-result 节点里承载 JSON 的块。 */
|
|
73
|
+
interface TextBlock {
|
|
74
|
+
type?: string
|
|
75
|
+
text?: string
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 从一个 tool-result 节点里取出预览载荷。
|
|
80
|
+
* 先逐块 JSON.parse(host 的 render 会额外回一块 JSON);失败则正则兜底。
|
|
81
|
+
*/
|
|
82
|
+
function pickPreview(node: SessionNode): PreviewPayload | null {
|
|
83
|
+
const blocks = (Array.isArray(node.content) ? (node.content as TextBlock[]) : [])
|
|
84
|
+
.filter((b) => b?.type === 'text')
|
|
85
|
+
for (const b of blocks) {
|
|
86
|
+
try {
|
|
87
|
+
const v = JSON.parse(String(b.text)) as PreviewPayload | null
|
|
88
|
+
if (v && v.ok === true && typeof v.viewUrl === 'string') return v
|
|
89
|
+
} catch { /* not this block */ }
|
|
90
|
+
}
|
|
91
|
+
const joined = blocks.map((b) => b.text).join('\n')
|
|
92
|
+
const m = joined.match(/\/pcb-viewer\/api\/file\?key=([\w.-]+)/)
|
|
93
|
+
if (m) {
|
|
94
|
+
const name = (joined.match(/([\w.-]+\.kicad_pcb)/) ?? [])[1] ?? 'board.kicad_pcb'
|
|
95
|
+
return { ok: true, key: m[1], viewUrl: m[0], name }
|
|
96
|
+
}
|
|
97
|
+
return null
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function walkAll(nodes: SessionNode[], visit: (node: SessionNode) => void): void {
|
|
101
|
+
const visited = new Set<SessionNode>()
|
|
102
|
+
const walk = (node: unknown, depth: number): void => {
|
|
103
|
+
if (!node || typeof node !== 'object' || depth > MAX_WALK_DEPTH || visited.has(node as SessionNode)) return
|
|
104
|
+
const n = node as SessionNode
|
|
105
|
+
visited.add(n)
|
|
106
|
+
visit(n)
|
|
107
|
+
const kids = (n.children ?? n.nodes ?? []) as SessionNode[]
|
|
108
|
+
for (const k of kids) walk(k, depth + 1)
|
|
109
|
+
if (Array.isArray(n.subCalls)) for (const k of n.subCalls as SessionNode[]) walk(k, depth + 1)
|
|
110
|
+
}
|
|
111
|
+
for (const n of nodes) walk(n, 0)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** 会话快照 → { byTurn: Map<turnNumber, preview>, latest: preview|null } */
|
|
115
|
+
interface PreviewIndex {
|
|
116
|
+
byTurn: Map<number, PreviewPayload>
|
|
117
|
+
latest: PreviewPayload | null
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function indexPreviews(nodes: SessionNode[]): PreviewIndex {
|
|
121
|
+
const byCallId = new Map<string, PreviewPayload>()
|
|
122
|
+
let latest: PreviewPayload | null = null
|
|
123
|
+
walkAll(nodes, (n) => {
|
|
124
|
+
const call = n.call as { name?: string } | undefined
|
|
125
|
+
if (n.kind === 'tool-result' && call?.name === TOOL_NAME && !n.isError) {
|
|
126
|
+
const p = pickPreview(n)
|
|
127
|
+
if (p) {
|
|
128
|
+
if (n.callId != null) byCallId.set(String(n.callId), p)
|
|
129
|
+
latest = p
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
})
|
|
133
|
+
const byTurn = new Map<number, PreviewPayload>()
|
|
134
|
+
walkAll(nodes, (n) => {
|
|
135
|
+
if (n.kind !== 'assistant') return
|
|
136
|
+
for (const b of (n.blocks ?? []) as Record<string, unknown>[]) {
|
|
137
|
+
if (b?.kind === 'tool-call' && b.name === TOOL_NAME) {
|
|
138
|
+
const p = (b.callId != null && byCallId.get(String(b.callId))) || null
|
|
139
|
+
if (p && typeof n.turn === 'number') byTurn.set(n.turn, p)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
})
|
|
143
|
+
return { byTurn, latest }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ---------------------------------------------------------------- 板文件文本缓存(带上限,超限逐出最旧)
|
|
147
|
+
const textCache = new Map<string, string>()
|
|
148
|
+
let textCacheBytes = 0
|
|
149
|
+
const TEXT_CACHE_CAP = 250 * 1024 * 1024
|
|
150
|
+
function loadBoardText(preview: PreviewPayload | null | undefined): Promise<string> {
|
|
151
|
+
const key = preview?.key ?? preview?.viewUrl
|
|
152
|
+
if (!key) return Promise.reject(new Error('no preview'))
|
|
153
|
+
const cached = textCache.get(key)
|
|
154
|
+
if (cached !== undefined) return Promise.resolve(cached)
|
|
155
|
+
return fetch(preview!.viewUrl ?? key)
|
|
156
|
+
.then((r) => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.text() })
|
|
157
|
+
.then((text) => {
|
|
158
|
+
const sz = text.length
|
|
159
|
+
while (textCacheBytes + sz > TEXT_CACHE_CAP && textCache.size) {
|
|
160
|
+
const oldest = textCache.keys().next().value as string
|
|
161
|
+
textCacheBytes -= (textCache.get(oldest) ?? '').length
|
|
162
|
+
textCache.delete(oldest)
|
|
163
|
+
}
|
|
164
|
+
textCache.set(key, text)
|
|
165
|
+
textCacheBytes += sz
|
|
166
|
+
return text
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
const THUMB_MAX_KB = 8 * 1024
|
|
170
|
+
|
|
171
|
+
// ---------------------------------------------------------------- 面板单例状态
|
|
172
|
+
interface PanelStore {
|
|
173
|
+
open: boolean
|
|
174
|
+
mode: ViewMode
|
|
175
|
+
preview: PreviewPayload | null
|
|
176
|
+
token: number
|
|
177
|
+
}
|
|
178
|
+
type PanelPatch = Partial<Omit<PanelStore, 'token'>>
|
|
179
|
+
const panelStore: PanelStore = { open: false, mode: 'split', preview: null, token: 0 }
|
|
180
|
+
const panelListeners = new Set<(token: number) => void>()
|
|
181
|
+
function setPanel(patch: PanelPatch): void {
|
|
182
|
+
Object.assign(panelStore, patch)
|
|
183
|
+
panelStore.token += 1
|
|
184
|
+
for (const fn of panelListeners) fn(panelStore.token)
|
|
185
|
+
}
|
|
186
|
+
function usePanel(): PanelStore {
|
|
187
|
+
const [, force] = useState(0)
|
|
188
|
+
useEffect(() => {
|
|
189
|
+
const fn = (): void => force((v) => v + 1)
|
|
190
|
+
panelListeners.add(fn)
|
|
191
|
+
return () => { panelListeners.delete(fn) }
|
|
192
|
+
}, [])
|
|
193
|
+
return panelStore
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// 会话预览索引(观察器写入;turnTail 的 select 是纯函数,只能读模块级状态)
|
|
197
|
+
const previewIndex: PreviewIndex = { byTurn: new Map<number, PreviewPayload>(), latest: null }
|
|
198
|
+
|
|
199
|
+
// ---------------------------------------------------------------- 样式
|
|
200
|
+
const S: Record<string, React.CSSProperties> = {
|
|
201
|
+
card: {
|
|
202
|
+
display: 'flex', flexDirection: 'column', gap: 9, marginTop: 6, marginBottom: 6,
|
|
203
|
+
padding: 11, borderRadius: 10, maxWidth: 320,
|
|
204
|
+
background: 'var(--dsw-alias-bg-layer-3, rgba(255,255,255,.03))',
|
|
205
|
+
border: '1px solid var(--dsw-alias-border-l2, rgba(255,255,255,.12))',
|
|
206
|
+
width: 'fit-content',
|
|
207
|
+
},
|
|
208
|
+
thumbWrap: { position: 'relative', borderRadius: 7, overflow: 'hidden', background: '#0b0e13', border: '1px solid rgba(95,224,205,.18)' },
|
|
209
|
+
thumbHint: { position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#7d8b9a', font: '10px ui-monospace, Menlo, monospace', letterSpacing: '0.14em' },
|
|
210
|
+
cardTitle: { font: '600 12px ui-monospace, Menlo, monospace', color: 'var(--dsw-alias-label-primary, #e6e9ef)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: 290 },
|
|
211
|
+
cardMeta: { font: '10px ui-monospace, Menlo, monospace', color: 'var(--dsw-alias-label-tertiary, #93a1b0)', letterSpacing: '0.04em' },
|
|
212
|
+
strip: { display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' },
|
|
213
|
+
btn: {
|
|
214
|
+
display: 'inline-flex', alignItems: 'center', gap: 5,
|
|
215
|
+
font: '600 11px ui-monospace, Menlo, monospace', letterSpacing: '0.06em',
|
|
216
|
+
color: 'var(--dsw-alias-label-primary, #e6e9ef)',
|
|
217
|
+
background: 'var(--dsw-alias-fill-tsp-secondary, rgba(95,224,205,.08))',
|
|
218
|
+
border: '1px solid rgba(95,224,205,.38)', borderRadius: 6,
|
|
219
|
+
padding: '3px 9px', cursor: 'pointer', whiteSpace: 'nowrap',
|
|
220
|
+
},
|
|
221
|
+
btnOn: { borderColor: '#5fe0cd', color: '#5fe0cd', boxShadow: '0 0 10px rgba(95,224,205,.25)' },
|
|
222
|
+
panel: {
|
|
223
|
+
position: 'fixed', top: 0, right: 0, height: '100vh', zIndex: 200,
|
|
224
|
+
background: '#090b0f', borderLeft: '1px solid rgba(95,224,205,.25)',
|
|
225
|
+
boxShadow: '-18px 0 60px rgba(0,0,0,.55)', display: 'flex', flexDirection: 'column',
|
|
226
|
+
transition: 'width .25s ease',
|
|
227
|
+
},
|
|
228
|
+
head: {
|
|
229
|
+
display: 'flex', alignItems: 'center', gap: 10, padding: '9px 14px',
|
|
230
|
+
borderBottom: '1px solid rgba(95,224,205,.14)', flex: '0 0 auto',
|
|
231
|
+
font: '11px ui-monospace, Menlo, monospace', color: '#cdd8e4', userSelect: 'none',
|
|
232
|
+
},
|
|
233
|
+
title: { color: '#5fe0cd', letterSpacing: '0.18em', fontWeight: 700, fontSize: 11 },
|
|
234
|
+
stats: { opacity: 0.65, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 },
|
|
235
|
+
headBtn: {
|
|
236
|
+
font: '600 11px ui-monospace, Menlo, monospace', color: '#5fe0cd',
|
|
237
|
+
background: 'transparent', border: '1px solid rgba(95,224,205,.35)',
|
|
238
|
+
borderRadius: 5, padding: '3px 9px', cursor: 'pointer', whiteSpace: 'nowrap',
|
|
239
|
+
},
|
|
240
|
+
body: { flex: 1, position: 'relative', minHeight: 0 },
|
|
241
|
+
state: {
|
|
242
|
+
position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
243
|
+
color: '#9fb2c4', font: '12px ui-monospace, Menlo, monospace', letterSpacing: '0.12em',
|
|
244
|
+
},
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function webUrl(preview: PreviewPayload): string {
|
|
248
|
+
return `/pcb-viewer/view?key=${encodeURIComponent(preview.key ?? '')}&name=${encodeURIComponent(preview.name ?? 'board.kicad_pcb')}`
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ---------------------------------------------------------------- 缩略图
|
|
252
|
+
interface BoardThumbProps {
|
|
253
|
+
preview: PreviewPayload | null
|
|
254
|
+
width?: number
|
|
255
|
+
height?: number
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function BoardThumb({ preview, width = 292, height = 158 }: BoardThumbProps) {
|
|
259
|
+
const hostRef = useRef<HTMLDivElement | null>(null)
|
|
260
|
+
const [state, setState] = useState('loading')
|
|
261
|
+
const sizeKB = preview?.stats?.sizeKB ?? 0
|
|
262
|
+
const big = sizeKB > THUMB_MAX_KB
|
|
263
|
+
|
|
264
|
+
useEffect(() => {
|
|
265
|
+
if (!preview) return undefined
|
|
266
|
+
let viewer: { dispose?: () => void } | null = null
|
|
267
|
+
let disposed = false
|
|
268
|
+
// 预加载:卡片一出现就后台拉板文本;大文件的几秒拉取发生在用户看卡片的间隙
|
|
269
|
+
loadBoardText(preview)
|
|
270
|
+
.then((text) => {
|
|
271
|
+
if (disposed || !hostRef.current) return
|
|
272
|
+
try {
|
|
273
|
+
// 板文本直接交给 viewer:createViewer 内部走 BoardParser().parse() → adaptBoard()
|
|
274
|
+
// (与旧 parseKicad 完全同一条流水线,行为不变)
|
|
275
|
+
viewer = createViewer(hostRef.current, {
|
|
276
|
+
board: text,
|
|
277
|
+
cacheKey: preview.key ?? preview.viewUrl,
|
|
278
|
+
boardName: preview.name,
|
|
279
|
+
brand: 'PCB',
|
|
280
|
+
mode: '3d',
|
|
281
|
+
hud: false,
|
|
282
|
+
post: false,
|
|
283
|
+
interactive: false,
|
|
284
|
+
// 大文件轻渲染:只建几何、跳过全部 canvas 纹理(省掉建模的大头)
|
|
285
|
+
textures: !big,
|
|
286
|
+
})
|
|
287
|
+
setState('ready')
|
|
288
|
+
} catch { setState('error') }
|
|
289
|
+
})
|
|
290
|
+
.catch(() => { if (!disposed) setState('error') })
|
|
291
|
+
return () => { disposed = true; viewer?.dispose?.(); viewer = null }
|
|
292
|
+
}, [preview?.key]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
293
|
+
|
|
294
|
+
return (
|
|
295
|
+
<div style={{ ...S.thumbWrap, width, height }}>
|
|
296
|
+
<div ref={hostRef} style={{ position: 'absolute', inset: 0 }} />
|
|
297
|
+
{state !== 'ready' && (
|
|
298
|
+
<div style={S.thumbHint}>
|
|
299
|
+
{state === 'loading' ? (big ? '预加载中…' : 'RENDERING…') : '预览不可用'}
|
|
300
|
+
</div>
|
|
301
|
+
)}
|
|
302
|
+
</div>
|
|
303
|
+
)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ---------------------------------------------------------------- 大屏面板(单例)
|
|
307
|
+
interface PcbPanelProps {
|
|
308
|
+
preview: PreviewPayload | null
|
|
309
|
+
mode: ViewMode
|
|
310
|
+
expanded: boolean
|
|
311
|
+
onMode: (mode: ViewMode) => void
|
|
312
|
+
onToggleExpand: () => void
|
|
313
|
+
onClose: () => void
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function PcbPanel({ preview, mode, expanded, onMode, onToggleExpand, onClose }: PcbPanelProps) {
|
|
317
|
+
const bodyRef = useRef<HTMLDivElement | null>(null)
|
|
318
|
+
const viewerRef = useRef<ReturnType<typeof createViewer> | null>(null)
|
|
319
|
+
const [phase, setPhase] = useState('loading')
|
|
320
|
+
const [error, setError] = useState('')
|
|
321
|
+
const s = preview?.stats ?? {}
|
|
322
|
+
|
|
323
|
+
useEffect(() => {
|
|
324
|
+
if (!preview) return undefined
|
|
325
|
+
let disposed = false
|
|
326
|
+
setPhase('loading')
|
|
327
|
+
setError('')
|
|
328
|
+
loadBoardText(preview)
|
|
329
|
+
.then((text) => {
|
|
330
|
+
if (disposed || !bodyRef.current) return
|
|
331
|
+
viewerRef.current = createViewer(bodyRef.current, {
|
|
332
|
+
board: text,
|
|
333
|
+
cacheKey: preview.key ?? preview.viewUrl,
|
|
334
|
+
boardName: preview.name,
|
|
335
|
+
brand: 'PCB · 3D 预览',
|
|
336
|
+
brandSub: '2D LAYOUT + 3D RENDER',
|
|
337
|
+
mode,
|
|
338
|
+
})
|
|
339
|
+
setPhase('ready')
|
|
340
|
+
})
|
|
341
|
+
.catch((e) => { if (!disposed) { setPhase('error'); setError(String(e)) } })
|
|
342
|
+
return () => {
|
|
343
|
+
disposed = true
|
|
344
|
+
viewerRef.current?.dispose?.()
|
|
345
|
+
viewerRef.current = null
|
|
346
|
+
}
|
|
347
|
+
}, [preview?.key]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
348
|
+
|
|
349
|
+
useEffect(() => {
|
|
350
|
+
viewerRef.current?.setMode?.(mode)
|
|
351
|
+
}, [mode, phase])
|
|
352
|
+
|
|
353
|
+
const modeBtn = (m: ViewMode, label: string) => (
|
|
354
|
+
<button type="button" style={{ ...S.headBtn, ...(mode === m ? S.btnOn : null) }} onClick={() => onMode(m)}>{label}</button>
|
|
355
|
+
)
|
|
356
|
+
const wide = mode === 'split' ? (expanded ? '100vw' : 'min(78vw, 1500px)') : (expanded ? '100vw' : 'min(86vw, 1700px)')
|
|
357
|
+
|
|
358
|
+
return (
|
|
359
|
+
<div style={{ ...S.panel, width: wide }}>
|
|
360
|
+
<div style={S.head}>
|
|
361
|
+
<span style={S.title}>PCB 3D 预览</span>
|
|
362
|
+
<span style={S.stats}>
|
|
363
|
+
{preview?.name ?? ''}
|
|
364
|
+
{s.widthMM ? ` · ${s.widthMM}×${s.heightMM}mm · ${s.layers}层 · ${s.comps}器件 · ${s.traces}走线` : ''}
|
|
365
|
+
</span>
|
|
366
|
+
{modeBtn('2d', 'PCB')}
|
|
367
|
+
{modeBtn('3d', '3D')}
|
|
368
|
+
{modeBtn('split', '并排')}
|
|
369
|
+
<button type="button" style={S.headBtn} onClick={onToggleExpand}>{expanded ? '⇲ 还原' : '⇱ 放大'}</button>
|
|
370
|
+
<button type="button" style={S.headBtn} onClick={onClose}>✕ 关闭</button>
|
|
371
|
+
</div>
|
|
372
|
+
<div style={S.body}>
|
|
373
|
+
<div ref={bodyRef} style={{ position: 'absolute', inset: 0 }} />
|
|
374
|
+
{phase === 'loading' && <div style={S.state}>LOADING BOARD…</div>}
|
|
375
|
+
{phase === 'error' && <div style={S.state}>加载失败:{error}</div>}
|
|
376
|
+
</div>
|
|
377
|
+
</div>
|
|
378
|
+
)
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// ---------------------------------------------------------------- 卡片(回合末尾,正文流)
|
|
382
|
+
interface PcbTailCardProps {
|
|
383
|
+
matched?: PreviewPayload | null
|
|
384
|
+
useSession?: UseSession
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function PcbTailCard(props: PcbTailCardProps) {
|
|
388
|
+
const preview = props.matched ?? null
|
|
389
|
+
const [expanded, setExpanded] = useState(false)
|
|
390
|
+
const st = usePanel()
|
|
391
|
+
const s = preview?.stats ?? {}
|
|
392
|
+
const isCurrent = st.preview?.key === preview?.key
|
|
393
|
+
const shown = st.open && isCurrent
|
|
394
|
+
const openWith = (mode: ViewMode) => setPanel({ preview, mode, open: true })
|
|
395
|
+
|
|
396
|
+
if (!preview) return null
|
|
397
|
+
return (
|
|
398
|
+
<>
|
|
399
|
+
<div style={S.card}>
|
|
400
|
+
<BoardThumb preview={preview} />
|
|
401
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, minWidth: 0 }}>
|
|
402
|
+
<span style={S.cardTitle}>{preview.name}</span>
|
|
403
|
+
<span style={S.cardMeta}>
|
|
404
|
+
{s.widthMM ? `${s.widthMM}×${s.heightMM}mm · ${s.layers}层 · ${s.comps}器件 · ${s.traces}走线` : 'PCB'}
|
|
405
|
+
</span>
|
|
406
|
+
</div>
|
|
407
|
+
<span style={S.strip}>
|
|
408
|
+
<button
|
|
409
|
+
type="button"
|
|
410
|
+
style={{ ...S.btn, ...(shown && st.mode === '2d' ? S.btnOn : null) }}
|
|
411
|
+
onClick={() => (shown && st.mode === '2d' ? setPanel({ open: false }) : openWith('2d'))}
|
|
412
|
+
>
|
|
413
|
+
<svg width="12" height="12" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.3" aria-hidden="true">
|
|
414
|
+
<rect x="1" y="1" width="12" height="12" rx="2" /><path d="M2 9l3-3 2.5 2.5L10 6l2 2" />
|
|
415
|
+
</svg>
|
|
416
|
+
显示 PCB
|
|
417
|
+
</button>
|
|
418
|
+
<button
|
|
419
|
+
type="button"
|
|
420
|
+
style={{ ...S.btn, ...(shown && st.mode === '3d' ? S.btnOn : null) }}
|
|
421
|
+
onClick={() => (shown && st.mode === '3d' ? setPanel({ open: false }) : openWith('3d'))}
|
|
422
|
+
>
|
|
423
|
+
<svg width="12" height="12" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.2" aria-hidden="true">
|
|
424
|
+
<path d="M7 1.5l5 2.8v5.4L7 12.5 2 9.7V4.3z" /><path d="M2 4.3l5 2.8 5-2.8M7 7.1v5.4" />
|
|
425
|
+
</svg>
|
|
426
|
+
显示 3D
|
|
427
|
+
</button>
|
|
428
|
+
<button
|
|
429
|
+
type="button"
|
|
430
|
+
style={S.btn}
|
|
431
|
+
title="在新标签页打开整页(左板子 + 右 3D)"
|
|
432
|
+
onClick={() => window.open(webUrl(preview), '_blank', 'noopener')}
|
|
433
|
+
>
|
|
434
|
+
<svg width="12" height="12" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.3" aria-hidden="true">
|
|
435
|
+
<path d="M6 3H3.5A1.5 1.5 0 0 0 2 4.5v6A1.5 1.5 0 0 0 3.5 12h6A1.5 1.5 0 0 0 11 10.5V8" />
|
|
436
|
+
<path d="M8.5 2H12v3.5M12 2L6.5 7.5" />
|
|
437
|
+
</svg>
|
|
438
|
+
浏览器打开
|
|
439
|
+
</button>
|
|
440
|
+
</span>
|
|
441
|
+
</div>
|
|
442
|
+
{shown && (
|
|
443
|
+
<PcbPanel
|
|
444
|
+
preview={preview}
|
|
445
|
+
mode={st.mode}
|
|
446
|
+
expanded={expanded}
|
|
447
|
+
onMode={(m) => setPanel({ mode: m })}
|
|
448
|
+
onToggleExpand={() => setExpanded((v) => !v)}
|
|
449
|
+
onClose={() => { setExpanded(false); setPanel({ open: false }) }}
|
|
450
|
+
/>
|
|
451
|
+
)}
|
|
452
|
+
</>
|
|
453
|
+
)
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// ---------------------------------------------------------------- 会话观察器(渲染 null)
|
|
457
|
+
interface PreviewWatcherProps {
|
|
458
|
+
useSession?: UseSession
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function PreviewWatcher({ useSession }: PreviewWatcherProps) {
|
|
462
|
+
const nodes = useSession ? useSession((s) => (s && s.nodes) || EMPTY_NODES) : EMPTY_NODES
|
|
463
|
+
useEffect(() => {
|
|
464
|
+
const idx = indexPreviews(nodes)
|
|
465
|
+
previewIndex.byTurn = idx.byTurn
|
|
466
|
+
previewIndex.latest = idx.latest
|
|
467
|
+
}, [nodes])
|
|
468
|
+
return null
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
export function apply(ctx: ClientContext): void {
|
|
472
|
+
// 观察器:常驻、无渲染,只维护 回合号→预览 映射
|
|
473
|
+
ctx.slots.inject('conversation.session.header.actions', () => ctx.slots.register({
|
|
474
|
+
name: 'conversation.session.header.actions',
|
|
475
|
+
id: 'kicad-3d-viewer-watcher',
|
|
476
|
+
order: 999,
|
|
477
|
+
inject: () => ({}),
|
|
478
|
+
}, PreviewWatcher))
|
|
479
|
+
|
|
480
|
+
// 卡片:官方 deliverables 同一行(turn tail chain,正文流里,不悬浮)。
|
|
481
|
+
// select 按回合号自我选举;priority -1 = 先于官方 deliverables(该回合有预览时优先出卡片),
|
|
482
|
+
// 没有预览的回合 select 返回 null → 让位给官方行。
|
|
483
|
+
ctx.slots.inject('conversation.chat.turnTail', () => ctx.slots.register({
|
|
484
|
+
name: 'conversation.chat.turnTail',
|
|
485
|
+
priority: -1,
|
|
486
|
+
select: (owner: SessionOwner) => {
|
|
487
|
+
const turn = owner?.turn?.turn
|
|
488
|
+
return typeof turn === 'number' ? previewIndex.byTurn.get(turn) ?? null : null
|
|
489
|
+
},
|
|
490
|
+
inject: () => ({}),
|
|
491
|
+
}, PcbTailCard))
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// 渲染发生在浏览器半边(本文件即入口);host 半边只负责取板子路径与统计。
|
|
495
|
+
export { inject }
|
|
496
|
+
export const internals = Object.freeze({ pickPreview, indexPreviews })
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// @huaqiu/dsh-tool-pcb-viewer — 独立整页入口(「浏览器打开」用)。
|
|
2
|
+
//
|
|
3
|
+
// 由 host 路由 /pcb-viewer/view 提供的极简页面加载:读 URL 上的 key/name,
|
|
4
|
+
// 向 /pcb-viewer/api/file 拉板文件文本,渲染「左 2D 走线 + 右 3D」整页视图。
|
|
5
|
+
// 布局与面板内一致(split),放大到满屏。
|
|
6
|
+
import { createViewer, type ViewMode } from '../viewer.js'
|
|
7
|
+
|
|
8
|
+
const app = document.getElementById('app') as HTMLElement
|
|
9
|
+
const q = new URLSearchParams(location.search)
|
|
10
|
+
const key = q.get('key') ?? ''
|
|
11
|
+
const name = q.get('name') ?? 'board.kicad_pcb'
|
|
12
|
+
const modeParam = q.get('mode')
|
|
13
|
+
const mode: ViewMode = modeParam === '2d' || modeParam === '3d' ? modeParam : 'split'
|
|
14
|
+
|
|
15
|
+
function fail(message: string): void {
|
|
16
|
+
app.innerHTML = `<div style="position:fixed;inset:0;display:flex;align-items:center;justify-content:center;color:#9fb2c4;font:13px ui-monospace,Menlo,monospace;letter-spacing:.12em">${message}</div>`
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (!key) {
|
|
20
|
+
fail('缺少 key 参数 —— 请从 DSH 会话里点「浏览器打开」进入')
|
|
21
|
+
} else {
|
|
22
|
+
document.title = name + ' · PCB 3D'
|
|
23
|
+
fetch(`/pcb-viewer/api/file?key=${encodeURIComponent(key)}`)
|
|
24
|
+
.then((r) => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.text() })
|
|
25
|
+
.then((text) => {
|
|
26
|
+
createViewer(app, {
|
|
27
|
+
board: text,
|
|
28
|
+
boardName: name,
|
|
29
|
+
brand: 'PCB · 3D',
|
|
30
|
+
brandSub: '2D LAYOUT + 3D RENDER',
|
|
31
|
+
mode,
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
.catch((e) => fail('加载失败:' + String(e instanceof Error ? e.message : e)))
|
|
35
|
+
}
|