@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.
- package/CHANGELOG.md +15 -0
- package/README.md +241 -0
- package/debug-text.ts +24 -0
- package/dist/assets/search.html +528 -0
- package/dist/index.js +32364 -0
- package/dist/src/index.js +28521 -0
- package/package.json +49 -0
- package/scripts/build-search-page.ts +68 -0
- package/src/assets/search-built.html +528 -0
- package/src/assets/search.html +458 -0
- package/src/browser/index.ts +381 -0
- package/src/browser/kicad-renderer.ts +646 -0
- package/src/browser/sexpr-parser.ts +321 -0
- package/src/http/routes.ts +253 -0
- package/src/http/server.ts +74 -0
- package/src/index.ts +117 -0
- package/src/tools/details.ts +66 -0
- package/src/tools/easyeda.ts +582 -0
- package/src/tools/index.ts +98 -0
- package/src/tools/library-fix.ts +414 -0
- package/src/tools/library-update.ts +412 -0
- package/src/tools/library.ts +263 -0
- package/src/tools/search.ts +58 -0
- package/tsconfig.json +9 -0
|
@@ -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, '<')
|
|
65
|
+
.replace(/>/g, '>')
|
|
66
|
+
.replace(/"/g, '"')
|
|
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
|
+
}
|