@jlcpcb/core 0.1.0 → 0.2.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.
Files changed (114) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/dist/api/easyeda-community.d.ts +36 -0
  3. package/dist/api/easyeda-community.d.ts.map +1 -0
  4. package/dist/api/easyeda.d.ts +23 -0
  5. package/dist/api/easyeda.d.ts.map +1 -0
  6. package/dist/api/index.d.ts +7 -0
  7. package/dist/api/index.d.ts.map +1 -0
  8. package/dist/api/jlc.d.ts +41 -0
  9. package/dist/api/jlc.d.ts.map +1 -0
  10. package/dist/assets/search.html +528 -0
  11. package/dist/browser/index.d.ts +8 -0
  12. package/dist/browser/index.d.ts.map +1 -0
  13. package/dist/browser/kicad-renderer.d.ts +13 -0
  14. package/dist/browser/kicad-renderer.d.ts.map +1 -0
  15. package/dist/browser/sexpr-parser.d.ts +84 -0
  16. package/dist/browser/sexpr-parser.d.ts.map +1 -0
  17. package/dist/constants/design-rules.d.ts +34 -0
  18. package/dist/constants/design-rules.d.ts.map +1 -0
  19. package/dist/constants/footprints.d.ts +134 -0
  20. package/dist/constants/footprints.d.ts.map +1 -0
  21. package/dist/constants/index.d.ts +7 -0
  22. package/dist/constants/index.d.ts.map +1 -0
  23. package/dist/constants/kicad.d.ts +67 -0
  24. package/dist/constants/kicad.d.ts.map +1 -0
  25. package/dist/converter/category-router.d.ts +47 -0
  26. package/dist/converter/category-router.d.ts.map +1 -0
  27. package/dist/converter/footprint-mapper.d.ts +40 -0
  28. package/dist/converter/footprint-mapper.d.ts.map +1 -0
  29. package/dist/converter/footprint-mapper.test.d.ts +2 -0
  30. package/dist/converter/footprint-mapper.test.d.ts.map +1 -0
  31. package/dist/converter/footprint.d.ts +116 -0
  32. package/dist/converter/footprint.d.ts.map +1 -0
  33. package/dist/converter/global-lib-table.d.ts +29 -0
  34. package/dist/converter/global-lib-table.d.ts.map +1 -0
  35. package/dist/converter/index.d.ts +12 -0
  36. package/dist/converter/index.d.ts.map +1 -0
  37. package/dist/converter/lib-table.d.ts +61 -0
  38. package/dist/converter/lib-table.d.ts.map +1 -0
  39. package/dist/converter/svg-arc.d.ts +45 -0
  40. package/dist/converter/svg-arc.d.ts.map +1 -0
  41. package/dist/converter/symbol-templates.d.ts +34 -0
  42. package/dist/converter/symbol-templates.d.ts.map +1 -0
  43. package/dist/converter/symbol.d.ts +223 -0
  44. package/dist/converter/symbol.d.ts.map +1 -0
  45. package/dist/converter/value-normalizer.d.ts +33 -0
  46. package/dist/converter/value-normalizer.d.ts.map +1 -0
  47. package/dist/http/index.d.ts +5 -0
  48. package/dist/http/index.d.ts.map +1 -0
  49. package/dist/http/routes.d.ts +12 -0
  50. package/dist/http/routes.d.ts.map +1 -0
  51. package/dist/http/server.d.ts +18 -0
  52. package/dist/http/server.d.ts.map +1 -0
  53. package/dist/index.d.ts +13 -0
  54. package/dist/index.d.ts.map +1 -0
  55. package/dist/index.js +9633 -0
  56. package/dist/parsers/easyeda-shapes.d.ts +115 -0
  57. package/dist/parsers/easyeda-shapes.d.ts.map +1 -0
  58. package/dist/parsers/http-client.d.ts +16 -0
  59. package/dist/parsers/http-client.d.ts.map +1 -0
  60. package/dist/parsers/index.d.ts +11 -0
  61. package/dist/parsers/index.d.ts.map +1 -0
  62. package/dist/parsers/utils.d.ts +17 -0
  63. package/dist/parsers/utils.d.ts.map +1 -0
  64. package/dist/services/component-service.d.ts +31 -0
  65. package/dist/services/component-service.d.ts.map +1 -0
  66. package/dist/services/fix-service.d.ts +40 -0
  67. package/dist/services/fix-service.d.ts.map +1 -0
  68. package/dist/services/index.d.ts +8 -0
  69. package/dist/services/index.d.ts.map +1 -0
  70. package/dist/services/library-service.d.ts +112 -0
  71. package/dist/services/library-service.d.ts.map +1 -0
  72. package/dist/types/component.d.ts +56 -0
  73. package/dist/types/component.d.ts.map +1 -0
  74. package/dist/types/easyeda-community.d.ts +74 -0
  75. package/dist/types/easyeda-community.d.ts.map +1 -0
  76. package/dist/types/easyeda.d.ts +326 -0
  77. package/dist/types/easyeda.d.ts.map +1 -0
  78. package/dist/types/index.d.ts +12 -0
  79. package/dist/types/index.d.ts.map +1 -0
  80. package/dist/types/jlc.d.ts +78 -0
  81. package/dist/types/jlc.d.ts.map +1 -0
  82. package/dist/types/kicad.d.ts +141 -0
  83. package/dist/types/kicad.d.ts.map +1 -0
  84. package/dist/types/mcp.d.ts +66 -0
  85. package/dist/types/mcp.d.ts.map +1 -0
  86. package/dist/types/project.d.ts +60 -0
  87. package/dist/types/project.d.ts.map +1 -0
  88. package/dist/utils/conversion.d.ts +59 -0
  89. package/dist/utils/conversion.d.ts.map +1 -0
  90. package/dist/utils/file-system.d.ts +59 -0
  91. package/dist/utils/file-system.d.ts.map +1 -0
  92. package/dist/utils/index.d.ts +8 -0
  93. package/dist/utils/index.d.ts.map +1 -0
  94. package/dist/utils/logger.d.ts +26 -0
  95. package/dist/utils/logger.d.ts.map +1 -0
  96. package/dist/utils/validation.d.ts +259 -0
  97. package/dist/utils/validation.d.ts.map +1 -0
  98. package/package.json +5 -3
  99. package/scripts/build-search-page.ts +68 -0
  100. package/src/assets/search-built.html +528 -0
  101. package/src/assets/search.html +458 -0
  102. package/src/browser/index.ts +389 -0
  103. package/src/browser/kicad-renderer.ts +813 -0
  104. package/src/browser/sexpr-parser.ts +333 -0
  105. package/src/converter/footprint-mapper.test.ts +159 -0
  106. package/src/converter/footprint-mapper.ts +42 -134
  107. package/src/converter/footprint.ts +208 -36
  108. package/src/converter/global-lib-table.ts +71 -0
  109. package/src/http/index.ts +5 -0
  110. package/src/http/routes.ts +266 -0
  111. package/src/http/server.ts +83 -0
  112. package/src/index.ts +3 -0
  113. package/src/parsers/easyeda-shapes.ts +2 -1
  114. package/src/services/library-service.ts +73 -22
@@ -0,0 +1,813 @@
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
+ type SExpr,
23
+ } from './sexpr-parser.js'
24
+
25
+ // Color schemes
26
+ const SYMBOL_COLORS = {
27
+ background: '#FFFFF8', // Warm white
28
+ body: '#840000', // Dark red for outlines
29
+ bodyFill: '#FFFFC4', // Pale yellow fill
30
+ pin: '#840000', // Dark red for pins
31
+ pinText: '#008484', // Teal for pin names/numbers
32
+ text: '#000000', // Black for text
33
+ }
34
+
35
+ const FOOTPRINT_COLORS = {
36
+ background: '#000000', // Black
37
+ // Copper layers (KiCad defaults)
38
+ fCu: '#840000', // F.Cu - dark red
39
+ bCu: '#008400', // B.Cu - green
40
+ in1Cu: '#C2C200', // In1.Cu - yellow
41
+ in2Cu: '#C200C2', // In2.Cu - magenta
42
+ // Silkscreen
43
+ fSilkS: '#008484', // F.SilkS - teal
44
+ bSilkS: '#840084', // B.SilkS - purple
45
+ // Solder mask
46
+ fMask: '#840084', // F.Mask - purple
47
+ bMask: '#848400', // B.Mask - olive
48
+ // Paste
49
+ fPaste: '#840000', // F.Paste - dark red
50
+ bPaste: '#00C2C2', // B.Paste - cyan
51
+ // Fabrication
52
+ fFab: '#848484', // F.Fab - gray
53
+ bFab: '#000084', // B.Fab - dark blue
54
+ // Courtyard
55
+ fCrtYd: '#C2C2C2', // F.CrtYd - light gray
56
+ bCrtYd: '#848484', // B.CrtYd - gray
57
+ // Other layers
58
+ edgeCuts: '#C2C200', // Edge.Cuts - yellow
59
+ dwgsUser: '#C2C2C2', // Dwgs.User - light gray
60
+ cmtsUser: '#848484', // Cmts.User - gray
61
+ margin: '#C200C2', // Margin - magenta
62
+ // Pads
63
+ padFront: '#840000', // Front SMD pads - red
64
+ padBack: '#008400', // Back SMD pads - green
65
+ padThruHole: '#C2C200', // THT pads - yellow
66
+ drill: '#848484', // Drill holes - gray
67
+ }
68
+
69
+ // Scale factor: KiCad uses mm, we render with pixels per mm
70
+ const SYMBOL_SCALE = 10 // pixels per mm for symbols
71
+ const FOOTPRINT_SCALE = 20 // pixels per mm for footprints
72
+
73
+ interface BoundingBox {
74
+ minX: number
75
+ maxX: number
76
+ minY: number
77
+ maxY: number
78
+ }
79
+
80
+ /**
81
+ * Escape text for SVG
82
+ */
83
+ function escapeXml(text: string): string {
84
+ return text
85
+ .replace(/&/g, '&')
86
+ .replace(/</g, '&lt;')
87
+ .replace(/>/g, '&gt;')
88
+ .replace(/"/g, '&quot;')
89
+ }
90
+
91
+ /**
92
+ * Render KiCad symbol S-expression to SVG
93
+ */
94
+ export function renderSymbolSvg(sexpr: string): string {
95
+ if (!sexpr || sexpr.trim() === '') {
96
+ return createErrorSvg('No symbol data')
97
+ }
98
+
99
+ try {
100
+ const parsed = parseSExpr(sexpr)
101
+ if (!isList(parsed) || getTag(parsed) !== 'symbol') {
102
+ return createErrorSvg('Invalid symbol format')
103
+ }
104
+
105
+ const elements: string[] = []
106
+ const bounds: BoundingBox = { minX: Infinity, maxX: -Infinity, minY: Infinity, maxY: -Infinity }
107
+
108
+ // Find all symbol units (graphics and pins)
109
+ const units = findChildren(parsed, 'symbol')
110
+
111
+ for (const unit of units) {
112
+ // Render rectangles
113
+ for (const rect of findChildren(unit, 'rectangle')) {
114
+ const svg = renderSymbolRectangle(rect, bounds)
115
+ if (svg) elements.push(svg)
116
+ }
117
+
118
+ // Render polylines
119
+ for (const polyline of findChildren(unit, 'polyline')) {
120
+ const svg = renderSymbolPolyline(polyline, bounds)
121
+ if (svg) elements.push(svg)
122
+ }
123
+
124
+ // Render circles
125
+ for (const circle of findChildren(unit, 'circle')) {
126
+ const svg = renderSymbolCircle(circle, bounds)
127
+ if (svg) elements.push(svg)
128
+ }
129
+
130
+ // Render arcs
131
+ for (const arc of findChildren(unit, 'arc')) {
132
+ const svg = renderSymbolArc(arc, bounds)
133
+ if (svg) elements.push(svg)
134
+ }
135
+
136
+ // Render pins
137
+ for (const pin of findChildren(unit, 'pin')) {
138
+ const svg = renderSymbolPin(pin, bounds)
139
+ if (svg) elements.push(svg)
140
+ }
141
+ }
142
+
143
+ // Add some padding to bounds
144
+ const padding = 5
145
+ if (!isFinite(bounds.minX)) {
146
+ bounds.minX = -20
147
+ bounds.maxX = 20
148
+ bounds.minY = -20
149
+ bounds.maxY = 20
150
+ }
151
+
152
+ const width = (bounds.maxX - bounds.minX + padding * 2) * SYMBOL_SCALE
153
+ const height = (bounds.maxY - bounds.minY + padding * 2) * SYMBOL_SCALE
154
+
155
+ // Transform to SVG coordinates (flip Y)
156
+ const viewBox = `${(bounds.minX - padding) * SYMBOL_SCALE} ${(-bounds.maxY - padding) * SYMBOL_SCALE} ${width} ${height}`
157
+
158
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${viewBox}" width="100%" height="100%" style="background-color: ${SYMBOL_COLORS.background}">
159
+ <g transform="scale(${SYMBOL_SCALE}, ${-SYMBOL_SCALE})">
160
+ ${elements.join('\n ')}
161
+ </g>
162
+ </svg>`
163
+ } catch (error) {
164
+ return createErrorSvg(`Parse error: ${error}`)
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Render symbol rectangle
170
+ */
171
+ function renderSymbolRectangle(rect: SExpr[], bounds: BoundingBox): string {
172
+ const start = getPoint(rect, 'start')
173
+ const end = getPoint(rect, 'end')
174
+ if (!start || !end) return ''
175
+
176
+ const stroke = getStroke(rect)
177
+ const fillType = getFillType(rect)
178
+ const fill = fillType === 'background' ? SYMBOL_COLORS.bodyFill : 'none'
179
+
180
+ updateBounds(bounds, start.x, start.y)
181
+ updateBounds(bounds, end.x, end.y)
182
+
183
+ const x = Math.min(start.x, end.x)
184
+ const y = Math.min(start.y, end.y)
185
+ const w = Math.abs(end.x - start.x)
186
+ const h = Math.abs(end.y - start.y)
187
+
188
+ return `<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="${fill}" stroke="${SYMBOL_COLORS.body}" stroke-width="${stroke?.width ?? 0.254}"/>`
189
+ }
190
+
191
+ /**
192
+ * Render symbol polyline
193
+ */
194
+ function renderSymbolPolyline(polyline: SExpr[], bounds: BoundingBox): string {
195
+ const points = getPoints(polyline)
196
+ if (points.length < 2) return ''
197
+
198
+ const stroke = getStroke(polyline)
199
+ const fillType = getFillType(polyline)
200
+ const fill = fillType === 'background' ? SYMBOL_COLORS.bodyFill : 'none'
201
+
202
+ const pointsStr = points.map(p => {
203
+ updateBounds(bounds, p.x, p.y)
204
+ return `${p.x},${p.y}`
205
+ }).join(' ')
206
+
207
+ return `<polyline points="${pointsStr}" fill="${fill}" stroke="${SYMBOL_COLORS.body}" stroke-width="${stroke?.width ?? 0.254}" stroke-linecap="round" stroke-linejoin="round"/>`
208
+ }
209
+
210
+ /**
211
+ * Render symbol circle
212
+ */
213
+ function renderSymbolCircle(circle: SExpr[], bounds: BoundingBox): string {
214
+ const center = getPoint(circle, 'center')
215
+ const radius = getNumericAttr(circle, 'radius')
216
+ if (!center || radius === undefined) return ''
217
+
218
+ const stroke = getStroke(circle)
219
+ const fillType = getFillType(circle)
220
+ const fill = fillType === 'background' ? SYMBOL_COLORS.bodyFill : 'none'
221
+
222
+ updateBounds(bounds, center.x - radius, center.y - radius)
223
+ updateBounds(bounds, center.x + radius, center.y + radius)
224
+
225
+ return `<circle cx="${center.x}" cy="${center.y}" r="${radius}" fill="${fill}" stroke="${SYMBOL_COLORS.body}" stroke-width="${stroke?.width ?? 0.254}"/>`
226
+ }
227
+
228
+ /**
229
+ * Render symbol arc
230
+ */
231
+ function renderSymbolArc(arc: SExpr[], bounds: BoundingBox): string {
232
+ const start = getPoint(arc, 'start')
233
+ const mid = getPoint(arc, 'mid')
234
+ const end = getPoint(arc, 'end')
235
+ if (!start || !mid || !end) return ''
236
+
237
+ const stroke = getStroke(arc)
238
+
239
+ updateBounds(bounds, start.x, start.y)
240
+ updateBounds(bounds, mid.x, mid.y)
241
+ updateBounds(bounds, end.x, end.y)
242
+
243
+ // Calculate arc from three points
244
+ const arcPath = calculateArcPath(start, mid, end)
245
+
246
+ return `<path d="${arcPath}" fill="none" stroke="${SYMBOL_COLORS.body}" stroke-width="${stroke?.width ?? 0.254}" stroke-linecap="round"/>`
247
+ }
248
+
249
+ /**
250
+ * Calculate SVG arc path from start, mid, end points
251
+ */
252
+ function calculateArcPath(
253
+ start: { x: number; y: number },
254
+ mid: { x: number; y: number },
255
+ end: { x: number; y: number }
256
+ ): string {
257
+ // Find circle center from three points
258
+ const ax = start.x, ay = start.y
259
+ const bx = mid.x, by = mid.y
260
+ const cx = end.x, cy = end.y
261
+
262
+ const d = 2 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by))
263
+ if (Math.abs(d) < 0.0001) {
264
+ // Points are collinear, draw a line
265
+ return `M ${start.x} ${start.y} L ${end.x} ${end.y}`
266
+ }
267
+
268
+ const ux = ((ax * ax + ay * ay) * (by - cy) + (bx * bx + by * by) * (cy - ay) + (cx * cx + cy * cy) * (ay - by)) / d
269
+ const uy = ((ax * ax + ay * ay) * (cx - bx) + (bx * bx + by * by) * (ax - cx) + (cx * cx + cy * cy) * (bx - ax)) / d
270
+
271
+ const radius = Math.sqrt((ax - ux) ** 2 + (ay - uy) ** 2)
272
+
273
+ // Determine sweep direction using cross product
274
+ const cross = (bx - ax) * (cy - ay) - (by - ay) * (cx - ax)
275
+ const sweepFlag = cross > 0 ? 0 : 1
276
+ const largeArcFlag = 0 // Always use small arc for 3-point arcs
277
+
278
+ return `M ${start.x} ${start.y} A ${radius} ${radius} 0 ${largeArcFlag} ${sweepFlag} ${end.x} ${end.y}`
279
+ }
280
+
281
+ /**
282
+ * Render symbol pin
283
+ */
284
+ function renderSymbolPin(pin: SExpr[], bounds: BoundingBox): string {
285
+ const at = getPointWithRotation(pin, 'at')
286
+ const length = getNumericAttr(pin, 'length') ?? 2.54
287
+ if (!at) return ''
288
+
289
+ // Get pin name and number
290
+ const nameChild = findChild(pin, 'name')
291
+ const numberChild = findChild(pin, 'number')
292
+ const pinName = nameChild && nameChild.length >= 2 && isAtom(nameChild[1]) ? nameChild[1] : ''
293
+ const pinNumber = numberChild && numberChild.length >= 2 && isAtom(numberChild[1]) ? numberChild[1] : ''
294
+
295
+ // Calculate pin endpoint based on rotation
296
+ const rotation = at.rotation ?? 0
297
+ const radians = (rotation * Math.PI) / 180
298
+ const endX = at.x + length * Math.cos(radians)
299
+ const endY = at.y + length * Math.sin(radians)
300
+
301
+ updateBounds(bounds, at.x, at.y)
302
+ updateBounds(bounds, endX, endY)
303
+
304
+ const elements: string[] = []
305
+
306
+ // Pin line
307
+ elements.push(`<line x1="${at.x}" y1="${at.y}" x2="${endX}" y2="${endY}" stroke="${SYMBOL_COLORS.pin}" stroke-width="0.254"/>`)
308
+
309
+ // Pin dot at connection point
310
+ elements.push(`<circle cx="${at.x}" cy="${at.y}" r="0.3" fill="${SYMBOL_COLORS.pin}"/>`)
311
+
312
+ // Pin name (positioned beyond the pin end)
313
+ if (pinName && pinName !== '~') {
314
+ const textOffset = 0.5
315
+ let textX = endX
316
+ let textY = endY
317
+ let anchor = 'start'
318
+ let baseline = 'middle'
319
+
320
+ // Position text based on pin direction
321
+ if (rotation === 0) {
322
+ textX = endX + textOffset
323
+ anchor = 'start'
324
+ } else if (rotation === 180) {
325
+ textX = endX - textOffset
326
+ anchor = 'end'
327
+ } else if (rotation === 90) {
328
+ textY = endY + textOffset
329
+ baseline = 'hanging'
330
+ anchor = 'middle'
331
+ } else if (rotation === 270) {
332
+ textY = endY - textOffset
333
+ baseline = 'alphabetic'
334
+ anchor = 'middle'
335
+ }
336
+
337
+ // For rotated text, we need to flip Y back since we're in a flipped coordinate system
338
+ 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>`)
339
+ }
340
+
341
+ // Pin number (positioned between pin start and end)
342
+ if (pinNumber) {
343
+ const midX = (at.x + endX) / 2
344
+ const midY = (at.y + endY) / 2
345
+ const offsetY = rotation === 0 || rotation === 180 ? 0.8 : 0
346
+
347
+ 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>`)
348
+ }
349
+
350
+ return elements.join('\n')
351
+ }
352
+
353
+ /**
354
+ * Render KiCad footprint S-expression to SVG
355
+ */
356
+ export function renderFootprintSvg(sexpr: string): string {
357
+ if (!sexpr || sexpr.trim() === '') {
358
+ return createErrorSvg('No footprint data')
359
+ }
360
+
361
+ try {
362
+ const parsed = parseSExpr(sexpr)
363
+ if (!isList(parsed) || getTag(parsed) !== 'footprint') {
364
+ return createErrorSvg('Invalid footprint format')
365
+ }
366
+
367
+ const elements: string[] = []
368
+ const bounds: BoundingBox = { minX: Infinity, maxX: -Infinity, minY: Infinity, maxY: -Infinity }
369
+
370
+ // Render pads first (bottom layer)
371
+ for (const pad of findChildren(parsed, 'pad')) {
372
+ const svg = renderFootprintPad(pad, bounds)
373
+ if (svg) elements.push(svg)
374
+ }
375
+
376
+ // Render lines
377
+ for (const line of findChildren(parsed, 'fp_line')) {
378
+ const svg = renderFootprintLine(line, bounds)
379
+ if (svg) elements.push(svg)
380
+ }
381
+
382
+ // Render circles
383
+ for (const circle of findChildren(parsed, 'fp_circle')) {
384
+ const svg = renderFootprintCircle(circle, bounds)
385
+ if (svg) elements.push(svg)
386
+ }
387
+
388
+ // Render arcs
389
+ for (const arc of findChildren(parsed, 'fp_arc')) {
390
+ const svg = renderFootprintArc(arc, bounds)
391
+ if (svg) elements.push(svg)
392
+ }
393
+
394
+ // Render rectangles
395
+ for (const rect of findChildren(parsed, 'fp_rect')) {
396
+ const svg = renderFootprintRect(rect, bounds)
397
+ if (svg) elements.push(svg)
398
+ }
399
+
400
+ // Render polygons (SOLIDREGION)
401
+ for (const poly of findChildren(parsed, 'fp_poly')) {
402
+ const svg = renderFootprintPoly(poly, bounds)
403
+ if (svg) elements.push(svg)
404
+ }
405
+
406
+ // Render gr_* elements (graphics primitives that may appear in footprints)
407
+ for (const line of findChildren(parsed, 'gr_line')) {
408
+ const svg = renderFootprintLine(line, bounds)
409
+ if (svg) elements.push(svg)
410
+ }
411
+ for (const circle of findChildren(parsed, 'gr_circle')) {
412
+ const svg = renderFootprintCircle(circle, bounds)
413
+ if (svg) elements.push(svg)
414
+ }
415
+ for (const arc of findChildren(parsed, 'gr_arc')) {
416
+ const svg = renderFootprintArc(arc, bounds)
417
+ if (svg) elements.push(svg)
418
+ }
419
+ for (const rect of findChildren(parsed, 'gr_rect')) {
420
+ const svg = renderFootprintRect(rect, bounds)
421
+ if (svg) elements.push(svg)
422
+ }
423
+ for (const poly of findChildren(parsed, 'gr_poly')) {
424
+ const svg = renderFootprintPoly(poly, bounds)
425
+ if (svg) elements.push(svg)
426
+ }
427
+
428
+ // Render text (silkscreen only)
429
+ for (const text of findChildren(parsed, 'fp_text')) {
430
+ const svg = renderFootprintText(text, bounds)
431
+ if (svg) elements.push(svg)
432
+ }
433
+
434
+ // Add padding to bounds
435
+ const padding = 1
436
+ if (!isFinite(bounds.minX)) {
437
+ bounds.minX = -5
438
+ bounds.maxX = 5
439
+ bounds.minY = -5
440
+ bounds.maxY = 5
441
+ }
442
+
443
+ // ViewBox in mm coordinates - browser scales to fit container
444
+ const width = bounds.maxX - bounds.minX + padding * 2
445
+ const height = bounds.maxY - bounds.minY + padding * 2
446
+ const viewBox = `${bounds.minX - padding} ${bounds.minY - padding} ${width} ${height}`
447
+
448
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${viewBox}" width="100%" height="100%" style="background-color: ${FOOTPRINT_COLORS.background}">
449
+ <g>
450
+ ${elements.join('\n ')}
451
+ </g>
452
+ </svg>`
453
+ } catch (error) {
454
+ return createErrorSvg(`Parse error: ${error}`)
455
+ }
456
+ }
457
+
458
+ /**
459
+ * Get color for a footprint layer (KiCad default theme)
460
+ */
461
+ function getLayerColor(layer: string): string {
462
+ // Copper layers
463
+ if (layer.includes('F.Cu')) return FOOTPRINT_COLORS.fCu
464
+ if (layer.includes('B.Cu')) return FOOTPRINT_COLORS.bCu
465
+ if (/In\d+\.Cu/.test(layer)) return FOOTPRINT_COLORS.in1Cu
466
+ // Silkscreen
467
+ if (layer.includes('F.SilkS')) return FOOTPRINT_COLORS.fSilkS
468
+ if (layer.includes('B.SilkS')) return FOOTPRINT_COLORS.bSilkS
469
+ // Solder mask
470
+ if (layer.includes('F.Mask')) return FOOTPRINT_COLORS.fMask
471
+ if (layer.includes('B.Mask')) return FOOTPRINT_COLORS.bMask
472
+ // Paste
473
+ if (layer.includes('F.Paste')) return FOOTPRINT_COLORS.fPaste
474
+ if (layer.includes('B.Paste')) return FOOTPRINT_COLORS.bPaste
475
+ // Fabrication
476
+ if (layer.includes('F.Fab')) return FOOTPRINT_COLORS.fFab
477
+ if (layer.includes('B.Fab')) return FOOTPRINT_COLORS.bFab
478
+ // Courtyard
479
+ if (layer.includes('F.CrtYd')) return FOOTPRINT_COLORS.fCrtYd
480
+ if (layer.includes('B.CrtYd')) return FOOTPRINT_COLORS.bCrtYd
481
+ // Other layers
482
+ if (layer.includes('Edge.Cuts')) return FOOTPRINT_COLORS.edgeCuts
483
+ if (layer.includes('Dwgs.User')) return FOOTPRINT_COLORS.dwgsUser
484
+ if (layer.includes('Cmts.User')) return FOOTPRINT_COLORS.cmtsUser
485
+ if (layer.includes('Margin')) return FOOTPRINT_COLORS.margin
486
+ return FOOTPRINT_COLORS.fSilkS // Default fallback
487
+ }
488
+
489
+ /**
490
+ * Render footprint pad
491
+ */
492
+ function renderFootprintPad(pad: SExpr[], bounds: BoundingBox): string {
493
+ // (pad "number" type shape (at x y [rot]) (size w h) (layers ...) ...)
494
+ if (!isList(pad) || pad.length < 4) return ''
495
+
496
+ const padNumber = isAtom(pad[1]) ? pad[1] : ''
497
+ const padType = isAtom(pad[2]) ? pad[2] : '' // smd, thru_hole, np_thru_hole
498
+ const padShape = isAtom(pad[3]) ? pad[3] : '' // rect, roundrect, circle, oval, custom
499
+
500
+ const at = getPointWithRotation(pad, 'at')
501
+ const size = getSize(pad)
502
+ if (!at || !size) return ''
503
+
504
+ updateBounds(bounds, at.x - size.width / 2, at.y - size.height / 2)
505
+ updateBounds(bounds, at.x + size.width / 2, at.y + size.height / 2)
506
+
507
+ const elements: string[] = []
508
+ const layers = getLayers(pad)
509
+
510
+ // Use KiCad-style pad colors: yellow for THT, red/green for SMD
511
+ let color: string
512
+ if (padType === 'thru_hole' || padType === 'np_thru_hole') {
513
+ color = FOOTPRINT_COLORS.padThruHole // Yellow for through-hole
514
+ } else if (layers.some(l => l.includes('B.'))) {
515
+ color = FOOTPRINT_COLORS.padBack // Green for back SMD
516
+ } else {
517
+ color = FOOTPRINT_COLORS.padFront // Red for front SMD
518
+ }
519
+
520
+ // Render pad shape
521
+ const rotation = at.rotation ?? 0
522
+ const transform = rotation !== 0 ? ` transform="rotate(${rotation}, ${at.x}, ${at.y})"` : ''
523
+
524
+ if (padShape === 'circle') {
525
+ const r = Math.min(size.width, size.height) / 2
526
+ elements.push(`<circle cx="${at.x}" cy="${at.y}" r="${r}" fill="${color}"${transform}/>`)
527
+ } else if (padShape === 'oval') {
528
+ const rx = size.width / 2
529
+ const ry = size.height / 2
530
+ elements.push(`<ellipse cx="${at.x}" cy="${at.y}" rx="${rx}" ry="${ry}" fill="${color}"${transform}/>`)
531
+ } else if (padShape === 'roundrect') {
532
+ const rratio = getNumericAttr(pad, 'roundrect_rratio') ?? 0.25
533
+ const rx = Math.min(size.width, size.height) * rratio / 2
534
+ const x = at.x - size.width / 2
535
+ const y = at.y - size.height / 2
536
+ elements.push(`<rect x="${x}" y="${y}" width="${size.width}" height="${size.height}" rx="${rx}" fill="${color}"${transform}/>`)
537
+ } else if (padShape === 'custom') {
538
+ // Render custom pad primitives
539
+ const primitives = findChild(pad, 'primitives')
540
+ if (primitives) {
541
+ for (const prim of findChildren(primitives, 'gr_poly')) {
542
+ const points = getPoints(prim)
543
+ if (points.length >= 3) {
544
+ // Translate points relative to pad center and update bounds
545
+ const polyStr = points.map(p => {
546
+ const absX = at.x + p.x
547
+ const absY = at.y + p.y
548
+ updateBounds(bounds, absX, absY)
549
+ return `${absX},${absY}`
550
+ }).join(' ')
551
+ elements.push(`<polygon points="${polyStr}" fill="${color}"${transform}/>`)
552
+ }
553
+ }
554
+ }
555
+ // Fallback to rect if no primitives
556
+ if (elements.length === 0) {
557
+ const x = at.x - size.width / 2
558
+ const y = at.y - size.height / 2
559
+ elements.push(`<rect x="${x}" y="${y}" width="${size.width}" height="${size.height}" fill="${color}"${transform}/>`)
560
+ }
561
+ } else {
562
+ // Default to rect
563
+ const x = at.x - size.width / 2
564
+ const y = at.y - size.height / 2
565
+ elements.push(`<rect x="${x}" y="${y}" width="${size.width}" height="${size.height}" fill="${color}"${transform}/>`)
566
+ }
567
+
568
+ // Render drill hole for THT pads
569
+ if (padType === 'thru_hole' || padType === 'np_thru_hole') {
570
+ const drill = findChild(pad, 'drill')
571
+ if (drill && drill.length >= 2) {
572
+ // (drill size) or (drill oval w h)
573
+ const isOval = isAtom(drill[1]) && drill[1] === 'oval'
574
+ if (isOval && drill.length >= 4) {
575
+ const dw = parseFloat(isAtom(drill[2]) ? drill[2] : '0')
576
+ const dh = parseFloat(isAtom(drill[3]) ? drill[3] : '0')
577
+ elements.push(`<ellipse cx="${at.x}" cy="${at.y}" rx="${dw / 2}" ry="${dh / 2}" fill="${FOOTPRINT_COLORS.drill}"${transform}/>`)
578
+ } else {
579
+ const drillSize = parseFloat(isAtom(drill[1]) ? drill[1] : '0')
580
+ elements.push(`<circle cx="${at.x}" cy="${at.y}" r="${drillSize / 2}" fill="${FOOTPRINT_COLORS.drill}"/>`)
581
+ }
582
+ }
583
+ }
584
+
585
+ // Render pad number
586
+ if (padNumber) {
587
+ const fontSize = Math.min(size.width, size.height) * 0.5
588
+ 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>`)
589
+ }
590
+
591
+ return elements.join('\n')
592
+ }
593
+
594
+ /**
595
+ * Render footprint line
596
+ */
597
+ function renderFootprintLine(line: SExpr[], bounds: BoundingBox): string {
598
+ const start = getPoint(line, 'start')
599
+ const end = getPoint(line, 'end')
600
+ if (!start || !end) return ''
601
+
602
+ const stroke = getStroke(line)
603
+ const layers = getLayers(line)
604
+ const layer = layers[0] ?? 'F.SilkS'
605
+
606
+ // Skip courtyard for cleaner preview
607
+ if (layer.includes('CrtYd')) return ''
608
+
609
+ const color = getLayerColor(layer)
610
+
611
+ updateBounds(bounds, start.x, start.y)
612
+ updateBounds(bounds, end.x, end.y)
613
+
614
+ 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"/>`
615
+ }
616
+
617
+ /**
618
+ * Render footprint circle
619
+ */
620
+ function renderFootprintCircle(circle: SExpr[], bounds: BoundingBox): string {
621
+ const center = getPoint(circle, 'center')
622
+ const end = getPoint(circle, 'end')
623
+ if (!center || !end) return ''
624
+
625
+ // KiCad defines circles by center and a point on the circumference
626
+ const radius = Math.sqrt((end.x - center.x) ** 2 + (end.y - center.y) ** 2)
627
+
628
+ const stroke = getStroke(circle)
629
+ const layers = getLayers(circle)
630
+ const layer = layers[0] ?? 'F.SilkS'
631
+
632
+ // Skip courtyard
633
+ if (layer.includes('CrtYd')) return ''
634
+
635
+ const color = getLayerColor(layer)
636
+
637
+ updateBounds(bounds, center.x - radius, center.y - radius)
638
+ updateBounds(bounds, center.x + radius, center.y + radius)
639
+
640
+ return `<circle cx="${center.x}" cy="${center.y}" r="${radius}" fill="none" stroke="${color}" stroke-width="${stroke?.width ?? 0.15}"/>`
641
+ }
642
+
643
+ /**
644
+ * Render footprint arc
645
+ */
646
+ function renderFootprintArc(arc: SExpr[], bounds: BoundingBox): string {
647
+ const start = getPoint(arc, 'start')
648
+ const mid = getPoint(arc, 'mid')
649
+ const end = getPoint(arc, 'end')
650
+ if (!start || !mid || !end) return ''
651
+
652
+ const stroke = getStroke(arc)
653
+ const layers = getLayers(arc)
654
+ const layer = layers[0] ?? 'F.SilkS'
655
+
656
+ // Skip courtyard
657
+ if (layer.includes('CrtYd')) return ''
658
+
659
+ const color = getLayerColor(layer)
660
+
661
+ updateBounds(bounds, start.x, start.y)
662
+ updateBounds(bounds, mid.x, mid.y)
663
+ updateBounds(bounds, end.x, end.y)
664
+
665
+ const arcPath = calculateArcPath(start, mid, end)
666
+
667
+ return `<path d="${arcPath}" fill="none" stroke="${color}" stroke-width="${stroke?.width ?? 0.15}" stroke-linecap="round"/>`
668
+ }
669
+
670
+ /**
671
+ * Render footprint polygon (for SOLIDREGION)
672
+ */
673
+ function renderFootprintPoly(poly: SExpr[], bounds: BoundingBox): string {
674
+ // (fp_poly (pts (xy x y) (xy x y) ...) (layer "...") (stroke ...) (fill ...))
675
+ const points = getPoints(poly)
676
+ if (points.length < 3) return ''
677
+
678
+ const layers = getLayers(poly)
679
+ const layer = layers[0] ?? 'F.Cu'
680
+
681
+ // Skip courtyard for cleaner preview
682
+ if (layer.includes('CrtYd')) return ''
683
+
684
+ const color = getLayerColor(layer)
685
+ const stroke = getStroke(poly)
686
+ const fillType = getFillType(poly)
687
+ const fill = fillType === 'solid' || fillType === 'yes' ? color : 'none'
688
+
689
+ const pointsStr = points.map(p => {
690
+ updateBounds(bounds, p.x, p.y)
691
+ return `${p.x},${p.y}`
692
+ }).join(' ')
693
+
694
+ const strokeWidth = stroke?.width ?? 0
695
+ const strokeAttr = strokeWidth > 0 ? ` stroke="${color}" stroke-width="${strokeWidth}"` : ''
696
+
697
+ return `<polygon points="${pointsStr}" fill="${fill}"${strokeAttr}/>`
698
+ }
699
+
700
+ /**
701
+ * Render footprint rectangle
702
+ */
703
+ function renderFootprintRect(rect: SExpr[], bounds: BoundingBox): string {
704
+ const start = getPoint(rect, 'start')
705
+ const end = getPoint(rect, 'end')
706
+ if (!start || !end) return ''
707
+
708
+ const layers = getLayers(rect)
709
+ const layer = layers[0] ?? 'F.SilkS'
710
+
711
+ // Skip courtyard for cleaner preview
712
+ if (layer.includes('CrtYd')) return ''
713
+
714
+ const color = getLayerColor(layer)
715
+ const stroke = getStroke(rect)
716
+ const fillType = getFillType(rect)
717
+ const fill = fillType === 'solid' ? color : 'none'
718
+
719
+ updateBounds(bounds, start.x, start.y)
720
+ updateBounds(bounds, end.x, end.y)
721
+
722
+ const x = Math.min(start.x, end.x)
723
+ const y = Math.min(start.y, end.y)
724
+ const w = Math.abs(end.x - start.x)
725
+ const h = Math.abs(end.y - start.y)
726
+
727
+ return `<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="${fill}" stroke="${color}" stroke-width="${stroke?.width ?? 0.15}"/>`
728
+ }
729
+
730
+ /**
731
+ * Render footprint text
732
+ */
733
+ function renderFootprintText(text: SExpr[], bounds: BoundingBox): string {
734
+ // (fp_text type "text" (at x y [rot]) (layer "...") (effects ...))
735
+ if (!isList(text) || text.length < 3) return ''
736
+
737
+ const textType = isAtom(text[1]) ? text[1] : '' // reference, value, user
738
+ const textContent = isAtom(text[2]) ? text[2] : ''
739
+
740
+ // Skip reference and value - they're placeholders
741
+ if (textType === 'reference' || textType === 'value') return ''
742
+
743
+ const at = getPointWithRotation(text, 'at')
744
+ if (!at) return ''
745
+
746
+ const layers = getLayers(text)
747
+ const layer = layers[0] ?? 'F.SilkS'
748
+ const color = getLayerColor(layer)
749
+
750
+ // Parse font size and justification from effects
751
+ const effects = findChild(text, 'effects')
752
+ let fontSize = 1
753
+ let textAnchor = 'middle' // default: centered
754
+
755
+ if (effects) {
756
+ // Get font size
757
+ const font = findChild(effects, 'font')
758
+ if (font) {
759
+ const size = getSize(font)
760
+ if (size) fontSize = size.height
761
+ }
762
+
763
+ // Get justification: (justify left/right/center)
764
+ const justify = findChild(effects, 'justify')
765
+ if (justify) {
766
+ for (let i = 1; i < justify.length; i++) {
767
+ const val = isAtom(justify[i]) ? justify[i] : ''
768
+ if (val === 'left') textAnchor = 'start'
769
+ else if (val === 'right') textAnchor = 'end'
770
+ }
771
+ }
772
+ }
773
+
774
+ // Estimate text bounds based on content and justification
775
+ const textWidth = textContent.length * fontSize * 0.6
776
+ const textHeight = fontSize
777
+
778
+ if (textAnchor === 'start') {
779
+ updateBounds(bounds, at.x, at.y - textHeight / 2)
780
+ updateBounds(bounds, at.x + textWidth, at.y + textHeight / 2)
781
+ } else if (textAnchor === 'end') {
782
+ updateBounds(bounds, at.x - textWidth, at.y - textHeight / 2)
783
+ updateBounds(bounds, at.x, at.y + textHeight / 2)
784
+ } else {
785
+ updateBounds(bounds, at.x - textWidth / 2, at.y - textHeight / 2)
786
+ updateBounds(bounds, at.x + textWidth / 2, at.y + textHeight / 2)
787
+ }
788
+
789
+ const rotation = at.rotation ?? 0
790
+ const transform = rotation !== 0 ? ` transform="rotate(${rotation}, ${at.x}, ${at.y})"` : ''
791
+
792
+ return `<text x="${at.x}" y="${at.y}" dy="0.35em" fill="${color}" font-size="${fontSize}" font-family="sans-serif" text-anchor="${textAnchor}"${transform}>${escapeXml(textContent)}</text>`
793
+ }
794
+
795
+ /**
796
+ * Update bounding box with a point
797
+ */
798
+ function updateBounds(bounds: BoundingBox, x: number, y: number): void {
799
+ bounds.minX = Math.min(bounds.minX, x)
800
+ bounds.maxX = Math.max(bounds.maxX, x)
801
+ bounds.minY = Math.min(bounds.minY, y)
802
+ bounds.maxY = Math.max(bounds.maxY, y)
803
+ }
804
+
805
+ /**
806
+ * Create error SVG
807
+ */
808
+ function createErrorSvg(message: string): string {
809
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 100" width="100%" height="100%">
810
+ <rect width="200" height="100" fill="#FFF0F0"/>
811
+ <text x="100" y="50" fill="#CC0000" font-size="12" font-family="sans-serif" text-anchor="middle" dominant-baseline="central">${escapeXml(message)}</text>
812
+ </svg>`
813
+ }