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