@jlcpcb/mcp 0.1.0

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.
@@ -0,0 +1,646 @@
1
+ /**
2
+ * KiCad S-Expression to SVG Renderer
3
+ * Renders KiCad symbols and footprints from S-expression format
4
+ */
5
+
6
+ import {
7
+ parseSExpr,
8
+ isList,
9
+ isAtom,
10
+ getTag,
11
+ findChild,
12
+ findChildren,
13
+ getAttr,
14
+ getNumericAttr,
15
+ getPoint,
16
+ getPointWithRotation,
17
+ getSize,
18
+ getStroke,
19
+ getFillType,
20
+ getPoints,
21
+ getLayers,
22
+ } from './sexpr-parser.js'
23
+
24
+ // Color schemes
25
+ const SYMBOL_COLORS = {
26
+ background: '#FFFFF8', // Warm white
27
+ body: '#840000', // Dark red for outlines
28
+ bodyFill: '#FFFFC4', // Pale yellow fill
29
+ pin: '#840000', // Dark red for pins
30
+ pinText: '#008484', // Teal for pin names/numbers
31
+ text: '#000000', // Black for text
32
+ }
33
+
34
+ const FOOTPRINT_COLORS = {
35
+ background: '#000000', // Black
36
+ fCu: '#CC0000', // Red for front copper
37
+ bCu: '#0066CC', // Blue for back copper
38
+ fSilkS: '#00FFFF', // Cyan for silkscreen
39
+ bSilkS: '#FF00FF', // Magenta for back silkscreen
40
+ fFab: '#C4A000', // Gold for fab
41
+ fMask: '#660066', // Purple for mask
42
+ drill: '#666666', // Gray for drill holes
43
+ courtyard: '#444444', // Dark gray for courtyard
44
+ edgeCuts: '#C4C400', // Yellow for edge cuts
45
+ }
46
+
47
+ // Scale factor: KiCad uses mm, we render with pixels per mm
48
+ const SYMBOL_SCALE = 10 // pixels per mm for symbols
49
+ const FOOTPRINT_SCALE = 20 // pixels per mm for footprints
50
+
51
+ interface BoundingBox {
52
+ minX: number
53
+ maxX: number
54
+ minY: number
55
+ maxY: number
56
+ }
57
+
58
+ /**
59
+ * Escape text for SVG
60
+ */
61
+ function escapeXml(text: string): string {
62
+ return text
63
+ .replace(/&/g, '&')
64
+ .replace(/</g, '&lt;')
65
+ .replace(/>/g, '&gt;')
66
+ .replace(/"/g, '&quot;')
67
+ }
68
+
69
+ /**
70
+ * Render KiCad symbol S-expression to SVG
71
+ */
72
+ export function renderSymbolSvg(sexpr: string): string {
73
+ if (!sexpr || sexpr.trim() === '') {
74
+ return createErrorSvg('No symbol data')
75
+ }
76
+
77
+ try {
78
+ const parsed = parseSExpr(sexpr)
79
+ if (!isList(parsed) || getTag(parsed) !== 'symbol') {
80
+ return createErrorSvg('Invalid symbol format')
81
+ }
82
+
83
+ const elements: string[] = []
84
+ const bounds: BoundingBox = { minX: Infinity, maxX: -Infinity, minY: Infinity, maxY: -Infinity }
85
+
86
+ // Find all symbol units (graphics and pins)
87
+ const units = findChildren(parsed, 'symbol')
88
+
89
+ for (const unit of units) {
90
+ // Render rectangles
91
+ for (const rect of findChildren(unit, 'rectangle')) {
92
+ const svg = renderSymbolRectangle(rect, bounds)
93
+ if (svg) elements.push(svg)
94
+ }
95
+
96
+ // Render polylines
97
+ for (const polyline of findChildren(unit, 'polyline')) {
98
+ const svg = renderSymbolPolyline(polyline, bounds)
99
+ if (svg) elements.push(svg)
100
+ }
101
+
102
+ // Render circles
103
+ for (const circle of findChildren(unit, 'circle')) {
104
+ const svg = renderSymbolCircle(circle, bounds)
105
+ if (svg) elements.push(svg)
106
+ }
107
+
108
+ // Render arcs
109
+ for (const arc of findChildren(unit, 'arc')) {
110
+ const svg = renderSymbolArc(arc, bounds)
111
+ if (svg) elements.push(svg)
112
+ }
113
+
114
+ // Render pins
115
+ for (const pin of findChildren(unit, 'pin')) {
116
+ const svg = renderSymbolPin(pin, bounds)
117
+ if (svg) elements.push(svg)
118
+ }
119
+ }
120
+
121
+ // Add some padding to bounds
122
+ const padding = 5
123
+ if (!isFinite(bounds.minX)) {
124
+ bounds.minX = -20
125
+ bounds.maxX = 20
126
+ bounds.minY = -20
127
+ bounds.maxY = 20
128
+ }
129
+
130
+ const width = (bounds.maxX - bounds.minX + padding * 2) * SYMBOL_SCALE
131
+ const height = (bounds.maxY - bounds.minY + padding * 2) * SYMBOL_SCALE
132
+
133
+ // Transform to SVG coordinates (flip Y)
134
+ const viewBox = `${(bounds.minX - padding) * SYMBOL_SCALE} ${(-bounds.maxY - padding) * SYMBOL_SCALE} ${width} ${height}`
135
+
136
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${viewBox}" width="100%" height="100%" style="background-color: ${SYMBOL_COLORS.background}">
137
+ <g transform="scale(${SYMBOL_SCALE}, ${-SYMBOL_SCALE})">
138
+ ${elements.join('\n ')}
139
+ </g>
140
+ </svg>`
141
+ } catch (error) {
142
+ return createErrorSvg(`Parse error: ${error}`)
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Render symbol rectangle
148
+ */
149
+ function renderSymbolRectangle(rect: unknown[], bounds: BoundingBox): string {
150
+ const start = getPoint(rect, 'start')
151
+ const end = getPoint(rect, 'end')
152
+ if (!start || !end) return ''
153
+
154
+ const stroke = getStroke(rect)
155
+ const fillType = getFillType(rect)
156
+ const fill = fillType === 'background' ? SYMBOL_COLORS.bodyFill : 'none'
157
+
158
+ updateBounds(bounds, start.x, start.y)
159
+ updateBounds(bounds, end.x, end.y)
160
+
161
+ const x = Math.min(start.x, end.x)
162
+ const y = Math.min(start.y, end.y)
163
+ const w = Math.abs(end.x - start.x)
164
+ const h = Math.abs(end.y - start.y)
165
+
166
+ return `<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="${fill}" stroke="${SYMBOL_COLORS.body}" stroke-width="${stroke?.width ?? 0.254}"/>`
167
+ }
168
+
169
+ /**
170
+ * Render symbol polyline
171
+ */
172
+ function renderSymbolPolyline(polyline: unknown[], bounds: BoundingBox): string {
173
+ const points = getPoints(polyline)
174
+ if (points.length < 2) return ''
175
+
176
+ const stroke = getStroke(polyline)
177
+ const fillType = getFillType(polyline)
178
+ const fill = fillType === 'background' ? SYMBOL_COLORS.bodyFill : 'none'
179
+
180
+ const pointsStr = points.map(p => {
181
+ updateBounds(bounds, p.x, p.y)
182
+ return `${p.x},${p.y}`
183
+ }).join(' ')
184
+
185
+ return `<polyline points="${pointsStr}" fill="${fill}" stroke="${SYMBOL_COLORS.body}" stroke-width="${stroke?.width ?? 0.254}" stroke-linecap="round" stroke-linejoin="round"/>`
186
+ }
187
+
188
+ /**
189
+ * Render symbol circle
190
+ */
191
+ function renderSymbolCircle(circle: unknown[], bounds: BoundingBox): string {
192
+ const center = getPoint(circle, 'center')
193
+ const radius = getNumericAttr(circle, 'radius')
194
+ if (!center || radius === undefined) return ''
195
+
196
+ const stroke = getStroke(circle)
197
+ const fillType = getFillType(circle)
198
+ const fill = fillType === 'background' ? SYMBOL_COLORS.bodyFill : 'none'
199
+
200
+ updateBounds(bounds, center.x - radius, center.y - radius)
201
+ updateBounds(bounds, center.x + radius, center.y + radius)
202
+
203
+ return `<circle cx="${center.x}" cy="${center.y}" r="${radius}" fill="${fill}" stroke="${SYMBOL_COLORS.body}" stroke-width="${stroke?.width ?? 0.254}"/>`
204
+ }
205
+
206
+ /**
207
+ * Render symbol arc
208
+ */
209
+ function renderSymbolArc(arc: unknown[], bounds: BoundingBox): string {
210
+ const start = getPoint(arc, 'start')
211
+ const mid = getPoint(arc, 'mid')
212
+ const end = getPoint(arc, 'end')
213
+ if (!start || !mid || !end) return ''
214
+
215
+ const stroke = getStroke(arc)
216
+
217
+ updateBounds(bounds, start.x, start.y)
218
+ updateBounds(bounds, mid.x, mid.y)
219
+ updateBounds(bounds, end.x, end.y)
220
+
221
+ // Calculate arc from three points
222
+ const arcPath = calculateArcPath(start, mid, end)
223
+
224
+ return `<path d="${arcPath}" fill="none" stroke="${SYMBOL_COLORS.body}" stroke-width="${stroke?.width ?? 0.254}" stroke-linecap="round"/>`
225
+ }
226
+
227
+ /**
228
+ * Calculate SVG arc path from start, mid, end points
229
+ */
230
+ function calculateArcPath(
231
+ start: { x: number; y: number },
232
+ mid: { x: number; y: number },
233
+ end: { x: number; y: number }
234
+ ): string {
235
+ // Find circle center from three points
236
+ const ax = start.x, ay = start.y
237
+ const bx = mid.x, by = mid.y
238
+ const cx = end.x, cy = end.y
239
+
240
+ const d = 2 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by))
241
+ if (Math.abs(d) < 0.0001) {
242
+ // Points are collinear, draw a line
243
+ return `M ${start.x} ${start.y} L ${end.x} ${end.y}`
244
+ }
245
+
246
+ const ux = ((ax * ax + ay * ay) * (by - cy) + (bx * bx + by * by) * (cy - ay) + (cx * cx + cy * cy) * (ay - by)) / d
247
+ const uy = ((ax * ax + ay * ay) * (cx - bx) + (bx * bx + by * by) * (ax - cx) + (cx * cx + cy * cy) * (bx - ax)) / d
248
+
249
+ const radius = Math.sqrt((ax - ux) ** 2 + (ay - uy) ** 2)
250
+
251
+ // Determine sweep direction using cross product
252
+ const cross = (bx - ax) * (cy - ay) - (by - ay) * (cx - ax)
253
+ const sweepFlag = cross > 0 ? 0 : 1
254
+ const largeArcFlag = 0 // Always use small arc for 3-point arcs
255
+
256
+ return `M ${start.x} ${start.y} A ${radius} ${radius} 0 ${largeArcFlag} ${sweepFlag} ${end.x} ${end.y}`
257
+ }
258
+
259
+ /**
260
+ * Render symbol pin
261
+ */
262
+ function renderSymbolPin(pin: unknown[], bounds: BoundingBox): string {
263
+ const at = getPointWithRotation(pin, 'at')
264
+ const length = getNumericAttr(pin, 'length') ?? 2.54
265
+ if (!at) return ''
266
+
267
+ // Get pin name and number
268
+ const nameChild = findChild(pin, 'name')
269
+ const numberChild = findChild(pin, 'number')
270
+ const pinName = nameChild && nameChild.length >= 2 && isAtom(nameChild[1]) ? nameChild[1] : ''
271
+ const pinNumber = numberChild && numberChild.length >= 2 && isAtom(numberChild[1]) ? numberChild[1] : ''
272
+
273
+ // Calculate pin endpoint based on rotation
274
+ const rotation = at.rotation ?? 0
275
+ const radians = (rotation * Math.PI) / 180
276
+ const endX = at.x + length * Math.cos(radians)
277
+ const endY = at.y + length * Math.sin(radians)
278
+
279
+ updateBounds(bounds, at.x, at.y)
280
+ updateBounds(bounds, endX, endY)
281
+
282
+ const elements: string[] = []
283
+
284
+ // Pin line
285
+ elements.push(`<line x1="${at.x}" y1="${at.y}" x2="${endX}" y2="${endY}" stroke="${SYMBOL_COLORS.pin}" stroke-width="0.254"/>`)
286
+
287
+ // Pin dot at connection point
288
+ elements.push(`<circle cx="${at.x}" cy="${at.y}" r="0.3" fill="${SYMBOL_COLORS.pin}"/>`)
289
+
290
+ // Pin name (positioned beyond the pin end)
291
+ if (pinName && pinName !== '~') {
292
+ const textOffset = 0.5
293
+ let textX = endX
294
+ let textY = endY
295
+ let anchor = 'start'
296
+ let baseline = 'middle'
297
+
298
+ // Position text based on pin direction
299
+ if (rotation === 0) {
300
+ textX = endX + textOffset
301
+ anchor = 'start'
302
+ } else if (rotation === 180) {
303
+ textX = endX - textOffset
304
+ anchor = 'end'
305
+ } else if (rotation === 90) {
306
+ textY = endY + textOffset
307
+ baseline = 'hanging'
308
+ anchor = 'middle'
309
+ } else if (rotation === 270) {
310
+ textY = endY - textOffset
311
+ baseline = 'alphabetic'
312
+ anchor = 'middle'
313
+ }
314
+
315
+ // For rotated text, we need to flip Y back since we're in a flipped coordinate system
316
+ elements.push(`<text x="${textX}" y="${textY}" fill="${SYMBOL_COLORS.pinText}" font-size="1" font-family="sans-serif" text-anchor="${anchor}" dominant-baseline="${baseline}" transform="scale(1,-1) translate(0,${-2 * textY})">${escapeXml(pinName)}</text>`)
317
+ }
318
+
319
+ // Pin number (positioned between pin start and end)
320
+ if (pinNumber) {
321
+ const midX = (at.x + endX) / 2
322
+ const midY = (at.y + endY) / 2
323
+ const offsetY = rotation === 0 || rotation === 180 ? 0.8 : 0
324
+
325
+ elements.push(`<text x="${midX}" y="${midY + offsetY}" fill="${SYMBOL_COLORS.pinText}" font-size="0.8" font-family="sans-serif" text-anchor="middle" dominant-baseline="middle" transform="scale(1,-1) translate(0,${-2 * (midY + offsetY)})">${escapeXml(pinNumber)}</text>`)
326
+ }
327
+
328
+ return elements.join('\n')
329
+ }
330
+
331
+ /**
332
+ * Render KiCad footprint S-expression to SVG
333
+ */
334
+ export function renderFootprintSvg(sexpr: string): string {
335
+ if (!sexpr || sexpr.trim() === '') {
336
+ return createErrorSvg('No footprint data')
337
+ }
338
+
339
+ try {
340
+ const parsed = parseSExpr(sexpr)
341
+ if (!isList(parsed) || getTag(parsed) !== 'footprint') {
342
+ return createErrorSvg('Invalid footprint format')
343
+ }
344
+
345
+ const elements: string[] = []
346
+ const bounds: BoundingBox = { minX: Infinity, maxX: -Infinity, minY: Infinity, maxY: -Infinity }
347
+
348
+ // Render pads first (bottom layer)
349
+ for (const pad of findChildren(parsed, 'pad')) {
350
+ const svg = renderFootprintPad(pad, bounds)
351
+ if (svg) elements.push(svg)
352
+ }
353
+
354
+ // Render lines
355
+ for (const line of findChildren(parsed, 'fp_line')) {
356
+ const svg = renderFootprintLine(line, bounds)
357
+ if (svg) elements.push(svg)
358
+ }
359
+
360
+ // Render circles
361
+ for (const circle of findChildren(parsed, 'fp_circle')) {
362
+ const svg = renderFootprintCircle(circle, bounds)
363
+ if (svg) elements.push(svg)
364
+ }
365
+
366
+ // Render arcs
367
+ for (const arc of findChildren(parsed, 'fp_arc')) {
368
+ const svg = renderFootprintArc(arc, bounds)
369
+ if (svg) elements.push(svg)
370
+ }
371
+
372
+ // Render text (silkscreen only)
373
+ for (const text of findChildren(parsed, 'fp_text')) {
374
+ const svg = renderFootprintText(text, bounds)
375
+ if (svg) elements.push(svg)
376
+ }
377
+
378
+ // Add padding to bounds
379
+ const padding = 1
380
+ if (!isFinite(bounds.minX)) {
381
+ bounds.minX = -5
382
+ bounds.maxX = 5
383
+ bounds.minY = -5
384
+ bounds.maxY = 5
385
+ }
386
+
387
+ // ViewBox in mm coordinates - browser scales to fit container
388
+ const width = bounds.maxX - bounds.minX + padding * 2
389
+ const height = bounds.maxY - bounds.minY + padding * 2
390
+ const viewBox = `${bounds.minX - padding} ${bounds.minY - padding} ${width} ${height}`
391
+
392
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${viewBox}" width="100%" height="100%" style="background-color: ${FOOTPRINT_COLORS.background}">
393
+ <g>
394
+ ${elements.join('\n ')}
395
+ </g>
396
+ </svg>`
397
+ } catch (error) {
398
+ return createErrorSvg(`Parse error: ${error}`)
399
+ }
400
+ }
401
+
402
+ /**
403
+ * Get color for a footprint layer
404
+ */
405
+ function getLayerColor(layer: string): string {
406
+ if (layer.includes('F.Cu')) return FOOTPRINT_COLORS.fCu
407
+ if (layer.includes('B.Cu')) return FOOTPRINT_COLORS.bCu
408
+ if (layer.includes('F.SilkS')) return FOOTPRINT_COLORS.fSilkS
409
+ if (layer.includes('B.SilkS')) return FOOTPRINT_COLORS.bSilkS
410
+ if (layer.includes('F.Fab')) return FOOTPRINT_COLORS.fFab
411
+ if (layer.includes('F.CrtYd')) return FOOTPRINT_COLORS.courtyard
412
+ if (layer.includes('Edge.Cuts')) return FOOTPRINT_COLORS.edgeCuts
413
+ return FOOTPRINT_COLORS.fSilkS // Default
414
+ }
415
+
416
+ /**
417
+ * Render footprint pad
418
+ */
419
+ function renderFootprintPad(pad: unknown[], bounds: BoundingBox): string {
420
+ // (pad "number" type shape (at x y [rot]) (size w h) (layers ...) ...)
421
+ if (!isList(pad) || pad.length < 4) return ''
422
+
423
+ const padNumber = isAtom(pad[1]) ? pad[1] : ''
424
+ const padType = isAtom(pad[2]) ? pad[2] : '' // smd, thru_hole, np_thru_hole
425
+ const padShape = isAtom(pad[3]) ? pad[3] : '' // rect, roundrect, circle, oval, custom
426
+
427
+ const at = getPointWithRotation(pad, 'at')
428
+ const size = getSize(pad)
429
+ if (!at || !size) return ''
430
+
431
+ updateBounds(bounds, at.x - size.width / 2, at.y - size.height / 2)
432
+ updateBounds(bounds, at.x + size.width / 2, at.y + size.height / 2)
433
+
434
+ const elements: string[] = []
435
+ const layers = getLayers(pad)
436
+ const color = layers.some(l => l.includes('B.')) ? FOOTPRINT_COLORS.bCu : FOOTPRINT_COLORS.fCu
437
+
438
+ // Render pad shape
439
+ const rotation = at.rotation ?? 0
440
+ const transform = rotation !== 0 ? ` transform="rotate(${rotation}, ${at.x}, ${at.y})"` : ''
441
+
442
+ if (padShape === 'circle') {
443
+ const r = Math.min(size.width, size.height) / 2
444
+ elements.push(`<circle cx="${at.x}" cy="${at.y}" r="${r}" fill="${color}"${transform}/>`)
445
+ } else if (padShape === 'oval') {
446
+ const rx = size.width / 2
447
+ const ry = size.height / 2
448
+ elements.push(`<ellipse cx="${at.x}" cy="${at.y}" rx="${rx}" ry="${ry}" fill="${color}"${transform}/>`)
449
+ } else if (padShape === 'roundrect') {
450
+ const rratio = getNumericAttr(pad, 'roundrect_rratio') ?? 0.25
451
+ const rx = Math.min(size.width, size.height) * rratio / 2
452
+ const x = at.x - size.width / 2
453
+ const y = at.y - size.height / 2
454
+ elements.push(`<rect x="${x}" y="${y}" width="${size.width}" height="${size.height}" rx="${rx}" fill="${color}"${transform}/>`)
455
+ } else if (padShape === 'custom') {
456
+ // Render custom pad primitives
457
+ const primitives = findChild(pad, 'primitives')
458
+ if (primitives) {
459
+ for (const prim of findChildren(primitives, 'gr_poly')) {
460
+ const points = getPoints(prim)
461
+ if (points.length >= 3) {
462
+ // Translate points relative to pad center and update bounds
463
+ const polyStr = points.map(p => {
464
+ const absX = at.x + p.x
465
+ const absY = at.y + p.y
466
+ updateBounds(bounds, absX, absY)
467
+ return `${absX},${absY}`
468
+ }).join(' ')
469
+ elements.push(`<polygon points="${polyStr}" fill="${color}"${transform}/>`)
470
+ }
471
+ }
472
+ }
473
+ // Fallback to rect if no primitives
474
+ if (elements.length === 0) {
475
+ const x = at.x - size.width / 2
476
+ const y = at.y - size.height / 2
477
+ elements.push(`<rect x="${x}" y="${y}" width="${size.width}" height="${size.height}" fill="${color}"${transform}/>`)
478
+ }
479
+ } else {
480
+ // Default to rect
481
+ const x = at.x - size.width / 2
482
+ const y = at.y - size.height / 2
483
+ elements.push(`<rect x="${x}" y="${y}" width="${size.width}" height="${size.height}" fill="${color}"${transform}/>`)
484
+ }
485
+
486
+ // Render drill hole for THT pads
487
+ if (padType === 'thru_hole' || padType === 'np_thru_hole') {
488
+ const drill = findChild(pad, 'drill')
489
+ if (drill && drill.length >= 2) {
490
+ // (drill size) or (drill oval w h)
491
+ const isOval = isAtom(drill[1]) && drill[1] === 'oval'
492
+ if (isOval && drill.length >= 4) {
493
+ const dw = parseFloat(isAtom(drill[2]) ? drill[2] : '0')
494
+ const dh = parseFloat(isAtom(drill[3]) ? drill[3] : '0')
495
+ elements.push(`<ellipse cx="${at.x}" cy="${at.y}" rx="${dw / 2}" ry="${dh / 2}" fill="${FOOTPRINT_COLORS.drill}"${transform}/>`)
496
+ } else {
497
+ const drillSize = parseFloat(isAtom(drill[1]) ? drill[1] : '0')
498
+ elements.push(`<circle cx="${at.x}" cy="${at.y}" r="${drillSize / 2}" fill="${FOOTPRINT_COLORS.drill}"/>`)
499
+ }
500
+ }
501
+ }
502
+
503
+ // Render pad number
504
+ if (padNumber) {
505
+ const fontSize = Math.min(size.width, size.height) * 0.5
506
+ elements.push(`<text x="${at.x}" y="${at.y}" fill="#FFFFFF" font-size="${fontSize}" font-family="sans-serif" text-anchor="middle" dominant-baseline="central">${escapeXml(padNumber)}</text>`)
507
+ }
508
+
509
+ return elements.join('\n')
510
+ }
511
+
512
+ /**
513
+ * Render footprint line
514
+ */
515
+ function renderFootprintLine(line: unknown[], bounds: BoundingBox): string {
516
+ const start = getPoint(line, 'start')
517
+ const end = getPoint(line, 'end')
518
+ if (!start || !end) return ''
519
+
520
+ const stroke = getStroke(line)
521
+ const layers = getLayers(line)
522
+ const layer = layers[0] ?? 'F.SilkS'
523
+
524
+ // Skip courtyard for cleaner preview
525
+ if (layer.includes('CrtYd')) return ''
526
+
527
+ const color = getLayerColor(layer)
528
+
529
+ updateBounds(bounds, start.x, start.y)
530
+ updateBounds(bounds, end.x, end.y)
531
+
532
+ return `<line x1="${start.x}" y1="${start.y}" x2="${end.x}" y2="${end.y}" stroke="${color}" stroke-width="${stroke?.width ?? 0.15}" stroke-linecap="round"/>`
533
+ }
534
+
535
+ /**
536
+ * Render footprint circle
537
+ */
538
+ function renderFootprintCircle(circle: unknown[], bounds: BoundingBox): string {
539
+ const center = getPoint(circle, 'center')
540
+ const end = getPoint(circle, 'end')
541
+ if (!center || !end) return ''
542
+
543
+ // KiCad defines circles by center and a point on the circumference
544
+ const radius = Math.sqrt((end.x - center.x) ** 2 + (end.y - center.y) ** 2)
545
+
546
+ const stroke = getStroke(circle)
547
+ const layers = getLayers(circle)
548
+ const layer = layers[0] ?? 'F.SilkS'
549
+
550
+ // Skip courtyard
551
+ if (layer.includes('CrtYd')) return ''
552
+
553
+ const color = getLayerColor(layer)
554
+
555
+ updateBounds(bounds, center.x - radius, center.y - radius)
556
+ updateBounds(bounds, center.x + radius, center.y + radius)
557
+
558
+ return `<circle cx="${center.x}" cy="${center.y}" r="${radius}" fill="none" stroke="${color}" stroke-width="${stroke?.width ?? 0.15}"/>`
559
+ }
560
+
561
+ /**
562
+ * Render footprint arc
563
+ */
564
+ function renderFootprintArc(arc: unknown[], bounds: BoundingBox): string {
565
+ const start = getPoint(arc, 'start')
566
+ const mid = getPoint(arc, 'mid')
567
+ const end = getPoint(arc, 'end')
568
+ if (!start || !mid || !end) return ''
569
+
570
+ const stroke = getStroke(arc)
571
+ const layers = getLayers(arc)
572
+ const layer = layers[0] ?? 'F.SilkS'
573
+
574
+ // Skip courtyard
575
+ if (layer.includes('CrtYd')) return ''
576
+
577
+ const color = getLayerColor(layer)
578
+
579
+ updateBounds(bounds, start.x, start.y)
580
+ updateBounds(bounds, mid.x, mid.y)
581
+ updateBounds(bounds, end.x, end.y)
582
+
583
+ const arcPath = calculateArcPath(start, mid, end)
584
+
585
+ return `<path d="${arcPath}" fill="none" stroke="${color}" stroke-width="${stroke?.width ?? 0.15}" stroke-linecap="round"/>`
586
+ }
587
+
588
+ /**
589
+ * Render footprint text
590
+ */
591
+ function renderFootprintText(text: unknown[], bounds: BoundingBox): string {
592
+ // (fp_text type "text" (at x y [rot]) (layer "...") (effects ...))
593
+ if (!isList(text) || text.length < 3) return ''
594
+
595
+ const textType = isAtom(text[1]) ? text[1] : '' // reference, value, user
596
+ const textContent = isAtom(text[2]) ? text[2] : ''
597
+
598
+ // Skip reference and value - they're placeholders
599
+ if (textType === 'reference' || textType === 'value') return ''
600
+
601
+ const at = getPointWithRotation(text, 'at')
602
+ if (!at) return ''
603
+
604
+ const layers = getLayers(text)
605
+ const layer = layers[0] ?? 'F.SilkS'
606
+ const color = getLayerColor(layer)
607
+
608
+ // Get font size from effects
609
+ const effects = findChild(text, 'effects')
610
+ let fontSize = 1
611
+ if (effects) {
612
+ const font = findChild(effects, 'font')
613
+ if (font) {
614
+ const size = getSize(font)
615
+ if (size) fontSize = size.height
616
+ }
617
+ }
618
+
619
+ updateBounds(bounds, at.x - 2, at.y - 1)
620
+ updateBounds(bounds, at.x + 2, at.y + 1)
621
+
622
+ const rotation = at.rotation ?? 0
623
+ const transform = rotation !== 0 ? ` transform="rotate(${rotation}, ${at.x}, ${at.y})"` : ''
624
+
625
+ return `<text x="${at.x}" y="${at.y}" fill="${color}" font-size="${fontSize}" font-family="sans-serif" text-anchor="middle" dominant-baseline="central"${transform}>${escapeXml(textContent)}</text>`
626
+ }
627
+
628
+ /**
629
+ * Update bounding box with a point
630
+ */
631
+ function updateBounds(bounds: BoundingBox, x: number, y: number): void {
632
+ bounds.minX = Math.min(bounds.minX, x)
633
+ bounds.maxX = Math.max(bounds.maxX, x)
634
+ bounds.minY = Math.min(bounds.minY, y)
635
+ bounds.maxY = Math.max(bounds.maxY, y)
636
+ }
637
+
638
+ /**
639
+ * Create error SVG
640
+ */
641
+ function createErrorSvg(message: string): string {
642
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 100" width="100%" height="100%">
643
+ <rect width="200" height="100" fill="#FFF0F0"/>
644
+ <text x="100" y="50" fill="#CC0000" font-size="12" font-family="sans-serif" text-anchor="middle" dominant-baseline="central">${escapeXml(message)}</text>
645
+ </svg>`
646
+ }