@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/index.ts
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @huaqiu/dsh-tool-pcb-viewer — DSH host 半边。
|
|
3
|
+
*
|
|
4
|
+
* 职责:
|
|
5
|
+
* - `pcb_preview` 工具:接收 .kicad_pcb 路径(绝对或相对会话工作目录),用
|
|
6
|
+
* @huaqiu/kicad-sexpr-parser + ./adapter.js 解析出板子统计信息,并把文件登记到
|
|
7
|
+
* 一个 key 上供客户端经 webServer 路由拉取。
|
|
8
|
+
* - 只读路由 /pcb-viewer/api/file?key=…:把登记的板文件文本发给浏览器半边
|
|
9
|
+
* (文件可能上百 MB,不走工具结果本体)。
|
|
10
|
+
* - /pcb-viewer/view + /pcb-viewer/standalone.js:「浏览器打开」整页。
|
|
11
|
+
* - systemPrompt 段:告诉模型何时调用 pcb_preview。
|
|
12
|
+
*
|
|
13
|
+
* 渲染全部发生在浏览器半边(lib/client.js,经 exports["./client"] + dsh.client 加载)。
|
|
14
|
+
*
|
|
15
|
+
* @module @huaqiu/dsh-tool-pcb-viewer
|
|
16
|
+
*/
|
|
17
|
+
import fs from 'node:fs'
|
|
18
|
+
import { randomUUID } from 'node:crypto'
|
|
19
|
+
import path from 'node:path'
|
|
20
|
+
import { fileURLToPath } from 'node:url'
|
|
21
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
22
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
23
|
+
import type {} from '@deepseek-ai/dsh-host-webserver'
|
|
24
|
+
import { defineTool, type JsonValue } from '@deepseek-ai/dsh-tools'
|
|
25
|
+
import { parseBoard } from './parse.js'
|
|
26
|
+
|
|
27
|
+
/** Plugin id — matches package.json. */
|
|
28
|
+
export const name = '@huaqiu/dsh-tool-pcb-viewer'
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Cordis services this half depends on.
|
|
32
|
+
*/
|
|
33
|
+
export const inject = ['tools', 'systemPrompt', 'webServer', 'sessions'] as const
|
|
34
|
+
|
|
35
|
+
export interface PcbViewerPluginConfig {
|
|
36
|
+
/** max .kicad_pcb file size in bytes (default 120MB). */
|
|
37
|
+
maxFileBytes?: number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// 独立页 bundle 的修订号(文件名 + mtime),改了 bundle 自动换 URL
|
|
41
|
+
const bundleRev = (() => {
|
|
42
|
+
try {
|
|
43
|
+
const f = path.join(path.dirname(fileURLToPath(import.meta.url)), 'standalone.js')
|
|
44
|
+
const st = fs.statSync(f)
|
|
45
|
+
return `${st.size.toString(36)}-${Math.floor(st.mtimeMs).toString(36)}`
|
|
46
|
+
} catch { return '' }
|
|
47
|
+
})()
|
|
48
|
+
|
|
49
|
+
const GUIDANCE = `## pcb_preview 工具
|
|
50
|
+
- 当用户给出 .kicad_pcb 文件路径、或要求查看/预览/渲染 PCB 板子时,调用 pcb_preview。
|
|
51
|
+
- path 支持绝对路径,或相对会话工作目录的路径。
|
|
52
|
+
- 调用成功后用户会在右侧面板看到实时的「2D 走线视图 + 3D 渲染」大屏,可点击放大到全屏。
|
|
53
|
+
- 不要在回复里粘贴文件内容;只需告诉用户已打开预览即可。`
|
|
54
|
+
|
|
55
|
+
// key → absolute file path(已校验)。
|
|
56
|
+
// 有意决策:registry 有界(32 条 FIFO 逐出最旧),key 用不可枚举的 randomUUID。
|
|
57
|
+
// 信任边界:DSH 本机服务(Host 必须 localhost;带 Origin 时也必须 localhost)。
|
|
58
|
+
const registry = new Map<string, string>()
|
|
59
|
+
const REGISTRY_CAP = 32
|
|
60
|
+
function registrySet(key: string, file: string): void {
|
|
61
|
+
if (registry.size >= REGISTRY_CAP) {
|
|
62
|
+
const oldest = registry.keys().next().value
|
|
63
|
+
if (oldest !== undefined) registry.delete(oldest)
|
|
64
|
+
}
|
|
65
|
+
registry.set(key, file)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface ExecWithSession {
|
|
69
|
+
sessionId?: string
|
|
70
|
+
session?: { id?: string }
|
|
71
|
+
context?: { sessionId?: string }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function sessionCwdOf(ctx: Context, exec: unknown): string | null {
|
|
75
|
+
const e = exec as ExecWithSession | undefined
|
|
76
|
+
const sid = e?.sessionId ?? e?.session?.id ?? e?.context?.sessionId
|
|
77
|
+
const sessions = ctx.sessions as unknown as { get?: (id: string) => { header?: { cwd?: unknown } } | undefined } | undefined
|
|
78
|
+
const cwd = sid ? sessions?.get?.(sid)?.header?.cwd : null
|
|
79
|
+
return typeof cwd === 'string' && cwd ? cwd : null
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function resolveBoardPath(ctx: Context, exec: unknown, input: unknown): string {
|
|
83
|
+
const p = String(input ?? '').trim()
|
|
84
|
+
if (!p) throw Object.assign(new Error('path is required'), { status: 400 })
|
|
85
|
+
if (path.isAbsolute(p)) return path.resolve(p)
|
|
86
|
+
const cwd = sessionCwdOf(ctx, exec) ?? process.cwd()
|
|
87
|
+
return path.resolve(cwd, p)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function sendJson(res: ServerResponse, status: number, body: unknown): void {
|
|
91
|
+
const text = JSON.stringify(body)
|
|
92
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'content-length': Buffer.byteLength(text) })
|
|
93
|
+
res.end(text)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function isTrustedRequest(req: IncomingMessage): boolean {
|
|
97
|
+
const host = String(req.headers.host ?? '')
|
|
98
|
+
if (!/^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/.test(host)) return false
|
|
99
|
+
const origin = req.headers.origin
|
|
100
|
+
if (origin && !/^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/.test(String(origin))) return false
|
|
101
|
+
return true
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface BoardStats {
|
|
105
|
+
sizeKB: number
|
|
106
|
+
comps: number
|
|
107
|
+
pads: number
|
|
108
|
+
traces: number
|
|
109
|
+
zones: number
|
|
110
|
+
vias: number
|
|
111
|
+
layers: number
|
|
112
|
+
widthMM: number
|
|
113
|
+
heightMM: number
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function readStats(filePath: string, config: Required<PcbViewerPluginConfig>): Promise<BoardStats> {
|
|
117
|
+
const stat = await fs.promises.stat(filePath)
|
|
118
|
+
if (stat.size > config.maxFileBytes) {
|
|
119
|
+
throw Object.assign(new Error(`file too large: ${(stat.size / 1048576).toFixed(1)}MB > ${(config.maxFileBytes / 1048576).toFixed(0)}MB`), { status: 413 })
|
|
120
|
+
}
|
|
121
|
+
// IO 异步化,不再在事件循环上等磁盘;parse 是 CPU 密集同步计算(进 Worker 另行评估)。
|
|
122
|
+
const text = await fs.promises.readFile(filePath, 'utf8')
|
|
123
|
+
const b = parseBoard(text)
|
|
124
|
+
return {
|
|
125
|
+
sizeKB: Math.round(stat.size / 1024),
|
|
126
|
+
comps: b.comps.length,
|
|
127
|
+
pads: b.comps.reduce((s, c) => s + c.pads.length, 0),
|
|
128
|
+
traces: b.traces.length,
|
|
129
|
+
zones: b.zones.length,
|
|
130
|
+
vias: b.vias.length,
|
|
131
|
+
layers: b.cuLayers.length,
|
|
132
|
+
widthMM: +(b.bbox.x1 - b.bbox.x0).toFixed(1),
|
|
133
|
+
heightMM: +(b.bbox.y1 - b.bbox.y0).toFixed(1),
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Shape of the tool's JSON payload — consumed by the browser half. */
|
|
138
|
+
interface PreviewValue {
|
|
139
|
+
ok: boolean
|
|
140
|
+
name?: string
|
|
141
|
+
path?: string
|
|
142
|
+
key?: string
|
|
143
|
+
viewUrl?: string
|
|
144
|
+
stats?: BoardStats
|
|
145
|
+
error?: string
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function apply(ctx: Context, config: PcbViewerPluginConfig = {}): () => void {
|
|
149
|
+
if (!ctx.tools || typeof ctx.tools.register !== 'function') {
|
|
150
|
+
throw new Error('@huaqiu/dsh-tool-pcb-viewer requires the DSH `tools` service (ctx.tools.register).')
|
|
151
|
+
}
|
|
152
|
+
const cfg: Required<PcbViewerPluginConfig> = {
|
|
153
|
+
maxFileBytes: config.maxFileBytes ?? 120 * 1024 * 1024,
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// 只读文件路由:客户端拿 key 换板文件文本。
|
|
157
|
+
if (ctx.webServer && typeof ctx.webServer.register === 'function') {
|
|
158
|
+
ctx.effect(() => ctx.webServer.register({
|
|
159
|
+
kind: 'prefix',
|
|
160
|
+
path: '/pcb-viewer',
|
|
161
|
+
handler: (req: IncomingMessage, res: ServerResponse) => {
|
|
162
|
+
if (!isTrustedRequest(req)) {
|
|
163
|
+
sendJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden' } })
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
try {
|
|
167
|
+
const url = new URL(req.url ?? '/', 'http://dsh.internal')
|
|
168
|
+
const p = url.pathname
|
|
169
|
+
|
|
170
|
+
// 板文件文本(面板与独立页都从这里取)
|
|
171
|
+
if (p === '/pcb-viewer/api/file') {
|
|
172
|
+
const key = url.searchParams.get('key') ?? ''
|
|
173
|
+
const file = registry.get(key)
|
|
174
|
+
if (!file) { sendJson(res, 404, { ok: false, error: { code: 'not-found', message: 'unknown file key' } }); return }
|
|
175
|
+
res.writeHead(200, { 'content-type': 'text/plain; charset=utf-8' })
|
|
176
|
+
fs.createReadStream(file).pipe(res)
|
|
177
|
+
return
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// 独立整页(「浏览器打开」新标签页)——自包含 bundle
|
|
181
|
+
if (p === '/pcb-viewer/view') {
|
|
182
|
+
const html = `<!doctype html>
|
|
183
|
+
<html lang="zh-CN"><head><meta charset="utf-8"/>
|
|
184
|
+
<meta name="viewport" content="width=device-width,initial-scale=1"/>
|
|
185
|
+
<title>PCB 3D</title>
|
|
186
|
+
<style>html,body{margin:0;height:100%;overflow:hidden;background:#090b0f}#app{position:fixed;inset:0}</style>
|
|
187
|
+
</head><body><div id="app"></div>
|
|
188
|
+
<script src="/pcb-viewer/standalone.js${bundleRev ? `?rev=${bundleRev}` : ''}"></script>
|
|
189
|
+
</body></html>`
|
|
190
|
+
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' })
|
|
191
|
+
res.end(html)
|
|
192
|
+
return
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (p === '/pcb-viewer/standalone.js') {
|
|
196
|
+
const file = path.join(path.dirname(fileURLToPath(import.meta.url)), 'standalone.js')
|
|
197
|
+
if (!fs.existsSync(file)) {
|
|
198
|
+
res.writeHead(503, { 'content-type': 'text/plain; charset=utf-8' })
|
|
199
|
+
res.end('standalone bundle missing — run: pnpm build')
|
|
200
|
+
return
|
|
201
|
+
}
|
|
202
|
+
res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-store' })
|
|
203
|
+
fs.createReadStream(file).pipe(res)
|
|
204
|
+
return
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
sendJson(res, 404, { ok: false, error: { code: 'not-found', message: 'unknown pcb-viewer route' } })
|
|
208
|
+
} catch (error) {
|
|
209
|
+
sendJson(res, 500, { ok: false, error: { code: 'internal', message: error instanceof Error ? error.message : String(error) } })
|
|
210
|
+
}
|
|
211
|
+
},
|
|
212
|
+
}), 'dsh-tool-pcb-viewer: /pcb-viewer routes')
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
ctx.systemPrompt.section({ name: 'tool:pcb_preview', order: 107, text: GUIDANCE })
|
|
216
|
+
|
|
217
|
+
const tool = defineTool({
|
|
218
|
+
name: 'pcb_preview',
|
|
219
|
+
description:
|
|
220
|
+
'Render a KiCad .kicad_pcb file as an interactive 2D layout + 3D board view ' +
|
|
221
|
+
'embedded in the conversation. Use whenever the user gives a .kicad_pcb path ' +
|
|
222
|
+
'or asks to view/preview/render a PCB board.',
|
|
223
|
+
parameters: {
|
|
224
|
+
path: {
|
|
225
|
+
type: 'string',
|
|
226
|
+
required: true,
|
|
227
|
+
description: 'Absolute path, or path relative to the session working directory',
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
output: {
|
|
231
|
+
schema: { type: 'json' },
|
|
232
|
+
render: (_args, value) => {
|
|
233
|
+
const v = value as unknown as PreviewValue
|
|
234
|
+
if (!v || v.ok !== true) return [{ type: 'text' as const, text: JSON.stringify(value, null, 2) }]
|
|
235
|
+
const s = v.stats ?? {} as Partial<BoardStats>
|
|
236
|
+
// 第一块:给模型/用户看的人话摘要;第二块:给浏览器半边解析(面板据此打开)。
|
|
237
|
+
return [
|
|
238
|
+
{
|
|
239
|
+
type: 'text' as const,
|
|
240
|
+
text: `PCB 预览已打开:${v.name}(${s.widthMM}×${s.heightMM}mm,${s.layers} 层,${s.comps} 器件 / ${s.pads} 焊盘 / ${s.traces} 走线 / ${s.vias} 过孔)。右侧面板可见 2D 走线 + 3D 渲染,点击可放大。`,
|
|
241
|
+
},
|
|
242
|
+
{ type: 'text' as const, text: JSON.stringify(v) },
|
|
243
|
+
]
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
// 官方即时卡片:工具事件到达就渲染(不必等回合结束)。
|
|
247
|
+
// 注意:视图词汇表没有 actions 字段,交互按钮仍由浏览器半边的卡片承担。
|
|
248
|
+
presentCall(args) {
|
|
249
|
+
const p = typeof args?.path === 'string' ? args.path : ''
|
|
250
|
+
return {
|
|
251
|
+
card: 'generic' as const,
|
|
252
|
+
title: p ? `PCB 预览:${path.basename(p)}` : 'PCB 预览',
|
|
253
|
+
kind: 'read' as const,
|
|
254
|
+
...(p ? { locations: [{ path: p }] } : {}),
|
|
255
|
+
}
|
|
256
|
+
},
|
|
257
|
+
presentResult(_args, result) {
|
|
258
|
+
let info: PreviewValue | null = null
|
|
259
|
+
for (const block of result?.content ?? []) {
|
|
260
|
+
if (block?.type !== 'text') continue
|
|
261
|
+
try {
|
|
262
|
+
const parsed = JSON.parse(block.text) as PreviewValue
|
|
263
|
+
if (parsed && parsed.ok === true && parsed.stats) { info = parsed; break }
|
|
264
|
+
} catch { /* not the JSON block */ }
|
|
265
|
+
}
|
|
266
|
+
if (!info || !info.stats) return undefined
|
|
267
|
+
const s = info.stats
|
|
268
|
+
return {
|
|
269
|
+
card: 'generic' as const,
|
|
270
|
+
title: `PCB 预览:${info.name}`,
|
|
271
|
+
content: [{
|
|
272
|
+
type: 'text' as const,
|
|
273
|
+
text: `${s.widthMM}×${s.heightMM}mm · ${s.layers} 层 · ${s.comps} 器件 · ${s.pads} 焊盘 · ${s.traces} 走线 · ${s.vias} 过孔`,
|
|
274
|
+
}],
|
|
275
|
+
}
|
|
276
|
+
},
|
|
277
|
+
async execute(args, exec) {
|
|
278
|
+
const input = (args && typeof args === 'object' ? args : {}) as { path?: unknown }
|
|
279
|
+
const filePath = resolveBoardPath(ctx, exec, input.path)
|
|
280
|
+
if (!/\.kicad_pcb$/i.test(filePath)) {
|
|
281
|
+
return { ok: false, error: `not a .kicad_pcb file: ${filePath}` } as unknown as JsonValue
|
|
282
|
+
}
|
|
283
|
+
if (!fs.existsSync(filePath)) {
|
|
284
|
+
return { ok: false, error: `file not found: ${filePath}` } as unknown as JsonValue
|
|
285
|
+
}
|
|
286
|
+
const stats = await readStats(filePath, cfg)
|
|
287
|
+
const key = `pcb-${randomUUID()}`
|
|
288
|
+
registrySet(key, filePath)
|
|
289
|
+
return {
|
|
290
|
+
ok: true,
|
|
291
|
+
name: path.basename(filePath),
|
|
292
|
+
path: filePath,
|
|
293
|
+
key,
|
|
294
|
+
viewUrl: `/pcb-viewer/api/file?key=${key}`,
|
|
295
|
+
stats,
|
|
296
|
+
} as unknown as JsonValue
|
|
297
|
+
},
|
|
298
|
+
})
|
|
299
|
+
const unregister = ctx.tools.register(tool)
|
|
300
|
+
|
|
301
|
+
return () => { if (typeof unregister === 'function') unregister() }
|
|
302
|
+
}
|
package/src/model.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flat board model — the renderer-facing shape.
|
|
3
|
+
*
|
|
4
|
+
* This is the contract every part of the viewer is written against. The host
|
|
5
|
+
* half never returns this directly; `adapter.ts` converts the published
|
|
6
|
+
* `@huaqiu/kicad-sexpr-parser` model (`I_KicadPCB`) into this.
|
|
7
|
+
*
|
|
8
|
+
* Units are millimetres. KiCad coordinates: +x right, +y DOWN (the viewer
|
|
9
|
+
* pre-mirrors Y at build time so the render matches KiCad's front view).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** One pad in absolute board coordinates (footprint transform already applied). */
|
|
13
|
+
export interface BoardPad {
|
|
14
|
+
x: number
|
|
15
|
+
y: number
|
|
16
|
+
/** board-aligned pad width (x extent) */
|
|
17
|
+
w: number
|
|
18
|
+
/** board-aligned pad length (y extent) */
|
|
19
|
+
l: number
|
|
20
|
+
shape: 'rect' | 'circle' | 'oval' | 'roundrect' | 'trapezoid' | 'custom' | string
|
|
21
|
+
net: string
|
|
22
|
+
/** present on the top copper */
|
|
23
|
+
top: boolean
|
|
24
|
+
/** present on the bottom copper */
|
|
25
|
+
bottom: boolean
|
|
26
|
+
/** through-hole */
|
|
27
|
+
th: boolean
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** One footprint / component. */
|
|
31
|
+
export interface BoardComp {
|
|
32
|
+
ref: string
|
|
33
|
+
fp: string
|
|
34
|
+
x: number
|
|
35
|
+
y: number
|
|
36
|
+
rot: number
|
|
37
|
+
layer: 'F.Cu' | 'B.Cu' | string
|
|
38
|
+
pads: BoardPad[]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** One copper segment (track). `arc` marks a 3-point arc. */
|
|
42
|
+
export interface BoardTrace {
|
|
43
|
+
pts: [number, number][]
|
|
44
|
+
w: number
|
|
45
|
+
layer: string
|
|
46
|
+
arc?: boolean
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A copper pour polygon on one layer. */
|
|
50
|
+
export interface BoardZone {
|
|
51
|
+
pts: [number, number][]
|
|
52
|
+
layer: string
|
|
53
|
+
layers: string[]
|
|
54
|
+
net: string
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** A via. */
|
|
58
|
+
export interface BoardVia {
|
|
59
|
+
x: number
|
|
60
|
+
y: number
|
|
61
|
+
size: number
|
|
62
|
+
drill: number
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** One Edge.Cuts outline element. */
|
|
66
|
+
export type OutlineSeg =
|
|
67
|
+
| { type: 'line'; a: [number, number]; b: [number, number] }
|
|
68
|
+
| { type: 'arc'; a: [number, number]; m: [number, number]; b: [number, number] }
|
|
69
|
+
| { type: 'circle'; c: [number, number]; r: number }
|
|
70
|
+
|
|
71
|
+
/** Silkscreen board text (gr_text). */
|
|
72
|
+
export interface BoardText {
|
|
73
|
+
text: string
|
|
74
|
+
x: number
|
|
75
|
+
y: number
|
|
76
|
+
rot: number
|
|
77
|
+
size: number
|
|
78
|
+
layer: string
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The full flat board model. */
|
|
82
|
+
export interface BoardModel {
|
|
83
|
+
outline: OutlineSeg[]
|
|
84
|
+
bbox: { x0: number; y0: number; x1: number; y1: number }
|
|
85
|
+
cuLayers: string[]
|
|
86
|
+
comps: BoardComp[]
|
|
87
|
+
traces: BoardTrace[]
|
|
88
|
+
zones: BoardZone[]
|
|
89
|
+
vias: BoardVia[]
|
|
90
|
+
texts: BoardText[]
|
|
91
|
+
}
|
package/src/parse.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* parse.ts — the single parse entry point for the whole package.
|
|
3
|
+
*
|
|
4
|
+
* Both available engines produce the same `BoardModel`:
|
|
5
|
+
* - `./pcb/parseKicad.js` — this package's own linear parser (DEFAULT)
|
|
6
|
+
* - `./adapter.js` — `@huaqiu/kicad-sexpr-parser` + adaptation
|
|
7
|
+
*
|
|
8
|
+
* Why our own engine is the default: it stays linear as board size grows, while
|
|
9
|
+
* the upstream path scales worse on multi-megabyte boards (measured on an 81MB
|
|
10
|
+
* board: seconds vs. over a minute). Both engines are held to the same model by
|
|
11
|
+
* test/parity.test.ts, so this choice is reversible and cheap to re-evaluate.
|
|
12
|
+
*
|
|
13
|
+
* Switching engines is this one line once the upstream quadratic is fixed:
|
|
14
|
+
* return adaptBoard(new BoardParser().parse(text))
|
|
15
|
+
* test/parity.test.ts keeps both engines in lockstep, so the switch is safe.
|
|
16
|
+
*
|
|
17
|
+
* @module @huaqiu/dsh-tool-pcb-viewer/parse
|
|
18
|
+
*/
|
|
19
|
+
import type { BoardModel } from './model.js'
|
|
20
|
+
import { parseKicad } from './pcb/parseKicad.js'
|
|
21
|
+
|
|
22
|
+
export function parseBoard(text: string): BoardModel {
|
|
23
|
+
return parseKicad(text)
|
|
24
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// Turn a parsed Edge.Cuts outline into a flattened polygon (mm, board coords).
|
|
2
|
+
// Shared by the 3D shape builder; tolerant of unordered segments and arcs.
|
|
3
|
+
//
|
|
4
|
+
// Edge.Cuts circles are NOT stitched into the main ring (splicing a closed
|
|
5
|
+
// circle into an open chain self-intersects). They are separate subpaths:
|
|
6
|
+
// - board ring + circles → circles are HOLES (cutouts)
|
|
7
|
+
// - circles only → the largest circle IS the board, the rest are holes
|
|
8
|
+
import type { BoardModel, OutlineSeg } from '../model.js'
|
|
9
|
+
|
|
10
|
+
/** A 2D point in board millimetres, kept as a tuple to survive round-trips. */
|
|
11
|
+
type Pt = [number, number]
|
|
12
|
+
|
|
13
|
+
type CircleSeg = Extract<OutlineSeg, { type: 'circle' }>
|
|
14
|
+
|
|
15
|
+
/** Board outline geometry: the outer ring plus any holes (circular cutouts). */
|
|
16
|
+
export interface OutlineParts {
|
|
17
|
+
/** Outer boundary, CCW (positive signed area). Same semantics as before. */
|
|
18
|
+
ring: Pt[]
|
|
19
|
+
/** Inner boundaries to punch out, wound CW (opposite the ring). */
|
|
20
|
+
holes: Pt[][]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Samples a full circle. `ccw` picks the ring (true) or hole (false) winding. */
|
|
24
|
+
function circlePts(cx: number, cy: number, r: number, n = 48, ccw = true): Pt[] {
|
|
25
|
+
const pts: Pt[] = []
|
|
26
|
+
for (let i = 0; i < n; i++) {
|
|
27
|
+
const t = ((ccw ? 1 : -1) * 2 * Math.PI * i) / n
|
|
28
|
+
pts.push([cx + r * Math.cos(t), cy + r * Math.sin(t)])
|
|
29
|
+
}
|
|
30
|
+
return pts
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const holeOf = (c: CircleSeg): Pt[] => circlePts(c.c[0], c.c[1], c.r, 48, false)
|
|
34
|
+
|
|
35
|
+
/** Outer ring only — the long-standing signature, kept for existing callers. */
|
|
36
|
+
export function outlinePolygon(board: BoardModel): [number, number][] {
|
|
37
|
+
return outlineParts(board).ring
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function outlineParts(board: BoardModel): OutlineParts {
|
|
41
|
+
const B = board.bbox
|
|
42
|
+
const segs = (board.outline || []).filter((s): s is Exclude<OutlineSeg, CircleSeg> => s.type !== 'circle')
|
|
43
|
+
const circles = (board.outline || []).filter((s): s is CircleSeg => s.type === 'circle')
|
|
44
|
+
const bboxRing = (): Pt[] => [[B.x0, B.y0], [B.x1, B.y0], [B.x1, B.y1], [B.x0, B.y1]]
|
|
45
|
+
if (!segs.length) {
|
|
46
|
+
if (circles.length) {
|
|
47
|
+
// Pure-circle board: the biggest circle is the board itself, not a hole.
|
|
48
|
+
const [main, ...rest] = [...circles].sort((a, b) => b.r - a.r)
|
|
49
|
+
if (main) return { ring: circlePts(main.c[0], main.c[1], main.r, 48, true), holes: rest.map(holeOf) }
|
|
50
|
+
}
|
|
51
|
+
return { ring: bboxRing(), holes: [] }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// arc → point list (start→mid→end through a circle)
|
|
55
|
+
const arcPts = (a: Pt, m: Pt, b: Pt, n = 16): Pt[] => {
|
|
56
|
+
const d = 2 * (a[0] * (m[1] - b[1]) + m[0] * (b[1] - a[1]) + b[0] * (a[1] - m[1]))
|
|
57
|
+
if (Math.abs(d) < 1e-9) return [a, b]
|
|
58
|
+
const ux = ((a[0] ** 2 + a[1] ** 2) * (m[1] - b[1]) + (m[0] ** 2 + m[1] ** 2) * (b[1] - a[1]) + (b[0] ** 2 + b[1] ** 2) * (a[1] - m[1])) / d
|
|
59
|
+
const uy = ((a[0] ** 2 + a[1] ** 2) * (b[0] - m[0]) + (m[0] ** 2 + m[1] ** 2) * (a[0] - b[0]) + (b[0] ** 2 + b[1] ** 2) * (m[0] - a[0])) / d
|
|
60
|
+
const r = Math.hypot(a[0] - ux, a[1] - uy)
|
|
61
|
+
const a0 = Math.atan2(a[1] - uy, a[0] - ux)
|
|
62
|
+
const am = Math.atan2(m[1] - uy, m[0] - ux)
|
|
63
|
+
const a1 = Math.atan2(b[1] - uy, b[0] - ux)
|
|
64
|
+
// direction: sweep from a0 to a1 passing through am
|
|
65
|
+
const norm = (t: number) => ((t % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI)
|
|
66
|
+
const ccw = norm(a1 - a0)
|
|
67
|
+
const mid = norm(am - a0)
|
|
68
|
+
const pts: Pt[] = []
|
|
69
|
+
if (mid <= ccw) { for (let i = 0; i <= n; i++) { const t = a0 + (ccw * i) / n; pts.push([ux + r * Math.cos(t), uy + r * Math.sin(t)]) } }
|
|
70
|
+
else { const cw = norm(a0 - a1); for (let i = 0; i <= n; i++) { const t = a0 - (cw * i) / n; pts.push([ux + r * Math.cos(t), uy + r * Math.sin(t)]) } }
|
|
71
|
+
return pts
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// normalize segments to polyline point-lists
|
|
75
|
+
const polys = segs.map((s) => (s.type === 'arc' ? arcPts(s.a, s.m, s.b) : [s.a, s.b]))
|
|
76
|
+
|
|
77
|
+
// stitch by nearest endpoints
|
|
78
|
+
const first = polys.shift()
|
|
79
|
+
if (!first) return { ring: bboxRing(), holes: circles.map(holeOf) }
|
|
80
|
+
const chain: Pt[] = first.slice()
|
|
81
|
+
const dist = (p: Pt, q: Pt) => Math.hypot(p[0] - q[0], p[1] - q[1])
|
|
82
|
+
let guard = 0
|
|
83
|
+
while (polys.length && guard++ < 500) {
|
|
84
|
+
const tail = chain[chain.length - 1]
|
|
85
|
+
if (!tail) break
|
|
86
|
+
let bi = -1, bd = Infinity, flip = false
|
|
87
|
+
for (let i = 0; i < polys.length; i++) {
|
|
88
|
+
const p = polys[i]
|
|
89
|
+
if (!p) continue
|
|
90
|
+
const p0 = p[0]
|
|
91
|
+
const pl = p[p.length - 1]
|
|
92
|
+
if (!p0 || !pl) continue
|
|
93
|
+
const d1 = dist(tail, p0), d2 = dist(tail, pl)
|
|
94
|
+
if (d1 < bd) { bd = d1; bi = i; flip = false }
|
|
95
|
+
if (d2 < bd) { bd = d2; bi = i; flip = true }
|
|
96
|
+
}
|
|
97
|
+
const p = polys.splice(bi, 1)[0]
|
|
98
|
+
if (!p) break
|
|
99
|
+
const seq = flip ? p.slice().reverse() : p
|
|
100
|
+
for (let i = (bd < 0.01 ? 1 : 0); i < seq.length; i++) { const q = seq[i]; if (q) chain.push(q) }
|
|
101
|
+
}
|
|
102
|
+
// winding normalization: the stitched ring always comes out CCW (positive
|
|
103
|
+
// signed area). The greedy chain direction depends on which segment was
|
|
104
|
+
// first, so collection order (doc order vs per-kind batches) used to decide
|
|
105
|
+
// the ring's orientation — normalize it away.
|
|
106
|
+
let area = 0
|
|
107
|
+
for (let i = 0; i < chain.length; i++) {
|
|
108
|
+
const a = chain[i]
|
|
109
|
+
const b = chain[(i + 1) % chain.length]
|
|
110
|
+
if (!a || !b) continue
|
|
111
|
+
area += a[0] * b[1] - b[0] * a[1]
|
|
112
|
+
}
|
|
113
|
+
if (area < 0) chain.reverse()
|
|
114
|
+
return { ring: chain, holes: circles.map(holeOf) }
|
|
115
|
+
}
|