/** * KiCad S-Expression to SVG Renderer * Renders KiCad symbols and footprints from S-expression format */ import { parseSExpr, isList, isAtom, getTag, findChild, findChildren, getAttr, getNumericAttr, getPoint, getPointWithRotation, getSize, getStroke, getFillType, getPoints, getLayers, type SExpr, } from './sexpr-parser.js' // Color schemes const SYMBOL_COLORS = { background: '#FFFFF8', // Warm white body: '#840000', // Dark red for outlines bodyFill: '#FFFFC4', // Pale yellow fill pin: '#840000', // Dark red for pins pinText: '#008484', // Teal for pin names/numbers text: '#000000', // Black for text } const FOOTPRINT_COLORS = { background: '#000000', // Black // Copper layers (KiCad defaults) fCu: '#840000', // F.Cu - dark red bCu: '#008400', // B.Cu - green in1Cu: '#C2C200', // In1.Cu - yellow in2Cu: '#C200C2', // In2.Cu - magenta // Silkscreen fSilkS: '#008484', // F.SilkS - teal bSilkS: '#840084', // B.SilkS - purple // Solder mask fMask: '#840084', // F.Mask - purple bMask: '#848400', // B.Mask - olive // Paste fPaste: '#840000', // F.Paste - dark red bPaste: '#00C2C2', // B.Paste - cyan // Fabrication fFab: '#848484', // F.Fab - gray bFab: '#000084', // B.Fab - dark blue // Courtyard fCrtYd: '#C2C2C2', // F.CrtYd - light gray bCrtYd: '#848484', // B.CrtYd - gray // Other layers edgeCuts: '#C2C200', // Edge.Cuts - yellow dwgsUser: '#C2C2C2', // Dwgs.User - light gray cmtsUser: '#848484', // Cmts.User - gray margin: '#C200C2', // Margin - magenta // Pads padFront: '#840000', // Front SMD pads - red padBack: '#008400', // Back SMD pads - green padThruHole: '#C2C200', // THT pads - yellow drill: '#848484', // Drill holes - gray } // Scale factor: KiCad uses mm, we render with pixels per mm const SYMBOL_SCALE = 10 // pixels per mm for symbols const FOOTPRINT_SCALE = 20 // pixels per mm for footprints interface BoundingBox { minX: number maxX: number minY: number maxY: number } /** * Escape text for SVG */ function escapeXml(text: string): string { return text .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') } /** * Render KiCad symbol S-expression to SVG */ export function renderSymbolSvg(sexpr: string): string { if (!sexpr || sexpr.trim() === '') { return createErrorSvg('No symbol data') } try { let parsed = parseSExpr(sexpr) // Handle kicad_symbol_lib wrapper - extract the first symbol if (isList(parsed) && getTag(parsed) === 'kicad_symbol_lib') { const symbols = findChildren(parsed, 'symbol') if (symbols.length === 0) { return createErrorSvg('No symbol found in library') } parsed = symbols[0] } if (!isList(parsed) || getTag(parsed) !== 'symbol') { return createErrorSvg('Invalid symbol format') } const elements: string[] = [] const bounds: BoundingBox = { minX: Infinity, maxX: -Infinity, minY: Infinity, maxY: -Infinity } // Find all symbol units (graphics and pins) const units = findChildren(parsed, 'symbol') for (const unit of units) { // Render rectangles for (const rect of findChildren(unit, 'rectangle')) { const svg = renderSymbolRectangle(rect, bounds) if (svg) elements.push(svg) } // Render polylines for (const polyline of findChildren(unit, 'polyline')) { const svg = renderSymbolPolyline(polyline, bounds) if (svg) elements.push(svg) } // Render circles for (const circle of findChildren(unit, 'circle')) { const svg = renderSymbolCircle(circle, bounds) if (svg) elements.push(svg) } // Render arcs for (const arc of findChildren(unit, 'arc')) { const svg = renderSymbolArc(arc, bounds) if (svg) elements.push(svg) } // Render pins for (const pin of findChildren(unit, 'pin')) { const svg = renderSymbolPin(pin, bounds) if (svg) elements.push(svg) } } // Add some padding to bounds const padding = 5 if (!isFinite(bounds.minX)) { bounds.minX = -20 bounds.maxX = 20 bounds.minY = -20 bounds.maxY = 20 } const width = (bounds.maxX - bounds.minX + padding * 2) * SYMBOL_SCALE const height = (bounds.maxY - bounds.minY + padding * 2) * SYMBOL_SCALE // Transform to SVG coordinates (flip Y) const viewBox = `${(bounds.minX - padding) * SYMBOL_SCALE} ${(-bounds.maxY - padding) * SYMBOL_SCALE} ${width} ${height}` return ` ${elements.join('\n ')} ` } catch (error) { return createErrorSvg(`Parse error: ${error}`) } } /** * Render symbol rectangle */ function renderSymbolRectangle(rect: SExpr[], bounds: BoundingBox): string { const start = getPoint(rect, 'start') const end = getPoint(rect, 'end') if (!start || !end) return '' const stroke = getStroke(rect) const fillType = getFillType(rect) const fill = fillType === 'background' ? SYMBOL_COLORS.bodyFill : 'none' updateBounds(bounds, start.x, start.y) updateBounds(bounds, end.x, end.y) const x = Math.min(start.x, end.x) const y = Math.min(start.y, end.y) const w = Math.abs(end.x - start.x) const h = Math.abs(end.y - start.y) return `` } /** * Render symbol polyline */ function renderSymbolPolyline(polyline: SExpr[], bounds: BoundingBox): string { const points = getPoints(polyline) if (points.length < 2) return '' const stroke = getStroke(polyline) const fillType = getFillType(polyline) const fill = fillType === 'background' ? SYMBOL_COLORS.bodyFill : 'none' const pointsStr = points.map(p => { updateBounds(bounds, p.x, p.y) return `${p.x},${p.y}` }).join(' ') return `` } /** * Render symbol circle */ function renderSymbolCircle(circle: SExpr[], bounds: BoundingBox): string { const center = getPoint(circle, 'center') const radius = getNumericAttr(circle, 'radius') if (!center || radius === undefined) return '' const stroke = getStroke(circle) const fillType = getFillType(circle) const fill = fillType === 'background' ? SYMBOL_COLORS.bodyFill : 'none' updateBounds(bounds, center.x - radius, center.y - radius) updateBounds(bounds, center.x + radius, center.y + radius) return `` } /** * Render symbol arc */ function renderSymbolArc(arc: SExpr[], bounds: BoundingBox): string { const start = getPoint(arc, 'start') const mid = getPoint(arc, 'mid') const end = getPoint(arc, 'end') if (!start || !mid || !end) return '' const stroke = getStroke(arc) updateBounds(bounds, start.x, start.y) updateBounds(bounds, mid.x, mid.y) updateBounds(bounds, end.x, end.y) // Calculate arc from three points const arcPath = calculateArcPath(start, mid, end) return `` } /** * Calculate SVG arc path from start, mid, end points */ function calculateArcPath( start: { x: number; y: number }, mid: { x: number; y: number }, end: { x: number; y: number } ): string { // Find circle center from three points const ax = start.x, ay = start.y const bx = mid.x, by = mid.y const cx = end.x, cy = end.y const d = 2 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by)) if (Math.abs(d) < 0.0001) { // Points are collinear, draw a line return `M ${start.x} ${start.y} L ${end.x} ${end.y}` } const ux = ((ax * ax + ay * ay) * (by - cy) + (bx * bx + by * by) * (cy - ay) + (cx * cx + cy * cy) * (ay - by)) / d const uy = ((ax * ax + ay * ay) * (cx - bx) + (bx * bx + by * by) * (ax - cx) + (cx * cx + cy * cy) * (bx - ax)) / d const radius = Math.sqrt((ax - ux) ** 2 + (ay - uy) ** 2) // Determine sweep direction using cross product const cross = (bx - ax) * (cy - ay) - (by - ay) * (cx - ax) const sweepFlag = cross > 0 ? 0 : 1 const largeArcFlag = 0 // Always use small arc for 3-point arcs return `M ${start.x} ${start.y} A ${radius} ${radius} 0 ${largeArcFlag} ${sweepFlag} ${end.x} ${end.y}` } /** * Render symbol pin */ function renderSymbolPin(pin: SExpr[], bounds: BoundingBox): string { const at = getPointWithRotation(pin, 'at') const length = getNumericAttr(pin, 'length') ?? 2.54 if (!at) return '' // Get pin name and number const nameChild = findChild(pin, 'name') const numberChild = findChild(pin, 'number') const pinName = nameChild && nameChild.length >= 2 && isAtom(nameChild[1]) ? nameChild[1] : '' const pinNumber = numberChild && numberChild.length >= 2 && isAtom(numberChild[1]) ? numberChild[1] : '' // Calculate pin endpoint based on rotation const rotation = at.rotation ?? 0 const radians = (rotation * Math.PI) / 180 const endX = at.x + length * Math.cos(radians) const endY = at.y + length * Math.sin(radians) updateBounds(bounds, at.x, at.y) updateBounds(bounds, endX, endY) const elements: string[] = [] // Pin line elements.push(``) // Pin dot at connection point elements.push(``) // Pin name (positioned beyond the pin end) if (pinName && pinName !== '~') { const textOffset = 0.5 let textX = endX let textY = endY let anchor = 'start' let baseline = 'middle' // Position text based on pin direction if (rotation === 0) { textX = endX + textOffset anchor = 'start' } else if (rotation === 180) { textX = endX - textOffset anchor = 'end' } else if (rotation === 90) { textY = endY + textOffset baseline = 'hanging' anchor = 'middle' } else if (rotation === 270) { textY = endY - textOffset baseline = 'alphabetic' anchor = 'middle' } // For rotated text, we need to flip Y back since we're in a flipped coordinate system elements.push(`${escapeXml(pinName)}`) } // Pin number (positioned between pin start and end) if (pinNumber) { const midX = (at.x + endX) / 2 const midY = (at.y + endY) / 2 const offsetY = rotation === 0 || rotation === 180 ? 0.8 : 0 elements.push(`${escapeXml(pinNumber)}`) } return elements.join('\n') } /** * Render KiCad footprint S-expression to SVG */ export function renderFootprintSvg(sexpr: string): string { if (!sexpr || sexpr.trim() === '') { return createErrorSvg('No footprint data') } try { const parsed = parseSExpr(sexpr) if (!isList(parsed) || getTag(parsed) !== 'footprint') { return createErrorSvg('Invalid footprint format') } const elements: string[] = [] const bounds: BoundingBox = { minX: Infinity, maxX: -Infinity, minY: Infinity, maxY: -Infinity } // Render pads first (bottom layer) for (const pad of findChildren(parsed, 'pad')) { const svg = renderFootprintPad(pad, bounds) if (svg) elements.push(svg) } // Render lines for (const line of findChildren(parsed, 'fp_line')) { const svg = renderFootprintLine(line, bounds) if (svg) elements.push(svg) } // Render circles for (const circle of findChildren(parsed, 'fp_circle')) { const svg = renderFootprintCircle(circle, bounds) if (svg) elements.push(svg) } // Render arcs for (const arc of findChildren(parsed, 'fp_arc')) { const svg = renderFootprintArc(arc, bounds) if (svg) elements.push(svg) } // Render rectangles for (const rect of findChildren(parsed, 'fp_rect')) { const svg = renderFootprintRect(rect, bounds) if (svg) elements.push(svg) } // Render polygons (SOLIDREGION) for (const poly of findChildren(parsed, 'fp_poly')) { const svg = renderFootprintPoly(poly, bounds) if (svg) elements.push(svg) } // Render gr_* elements (graphics primitives that may appear in footprints) for (const line of findChildren(parsed, 'gr_line')) { const svg = renderFootprintLine(line, bounds) if (svg) elements.push(svg) } for (const circle of findChildren(parsed, 'gr_circle')) { const svg = renderFootprintCircle(circle, bounds) if (svg) elements.push(svg) } for (const arc of findChildren(parsed, 'gr_arc')) { const svg = renderFootprintArc(arc, bounds) if (svg) elements.push(svg) } for (const rect of findChildren(parsed, 'gr_rect')) { const svg = renderFootprintRect(rect, bounds) if (svg) elements.push(svg) } for (const poly of findChildren(parsed, 'gr_poly')) { const svg = renderFootprintPoly(poly, bounds) if (svg) elements.push(svg) } // Render text (silkscreen only) for (const text of findChildren(parsed, 'fp_text')) { const svg = renderFootprintText(text, bounds) if (svg) elements.push(svg) } // Add padding to bounds const padding = 1 if (!isFinite(bounds.minX)) { bounds.minX = -5 bounds.maxX = 5 bounds.minY = -5 bounds.maxY = 5 } // ViewBox in mm coordinates - browser scales to fit container const width = bounds.maxX - bounds.minX + padding * 2 const height = bounds.maxY - bounds.minY + padding * 2 const viewBox = `${bounds.minX - padding} ${bounds.minY - padding} ${width} ${height}` return ` ${elements.join('\n ')} ` } catch (error) { return createErrorSvg(`Parse error: ${error}`) } } /** * Get color for a footprint layer (KiCad default theme) */ function getLayerColor(layer: string): string { // Copper layers if (layer.includes('F.Cu')) return FOOTPRINT_COLORS.fCu if (layer.includes('B.Cu')) return FOOTPRINT_COLORS.bCu if (/In\d+\.Cu/.test(layer)) return FOOTPRINT_COLORS.in1Cu // Silkscreen if (layer.includes('F.SilkS')) return FOOTPRINT_COLORS.fSilkS if (layer.includes('B.SilkS')) return FOOTPRINT_COLORS.bSilkS // Solder mask if (layer.includes('F.Mask')) return FOOTPRINT_COLORS.fMask if (layer.includes('B.Mask')) return FOOTPRINT_COLORS.bMask // Paste if (layer.includes('F.Paste')) return FOOTPRINT_COLORS.fPaste if (layer.includes('B.Paste')) return FOOTPRINT_COLORS.bPaste // Fabrication if (layer.includes('F.Fab')) return FOOTPRINT_COLORS.fFab if (layer.includes('B.Fab')) return FOOTPRINT_COLORS.bFab // Courtyard if (layer.includes('F.CrtYd')) return FOOTPRINT_COLORS.fCrtYd if (layer.includes('B.CrtYd')) return FOOTPRINT_COLORS.bCrtYd // Other layers if (layer.includes('Edge.Cuts')) return FOOTPRINT_COLORS.edgeCuts if (layer.includes('Dwgs.User')) return FOOTPRINT_COLORS.dwgsUser if (layer.includes('Cmts.User')) return FOOTPRINT_COLORS.cmtsUser if (layer.includes('Margin')) return FOOTPRINT_COLORS.margin return FOOTPRINT_COLORS.fSilkS // Default fallback } /** * Render footprint pad */ function renderFootprintPad(pad: SExpr[], bounds: BoundingBox): string { // (pad "number" type shape (at x y [rot]) (size w h) (layers ...) ...) if (!isList(pad) || pad.length < 4) return '' const padNumber = isAtom(pad[1]) ? pad[1] : '' const padType = isAtom(pad[2]) ? pad[2] : '' // smd, thru_hole, np_thru_hole const padShape = isAtom(pad[3]) ? pad[3] : '' // rect, roundrect, circle, oval, custom const at = getPointWithRotation(pad, 'at') const size = getSize(pad) if (!at || !size) return '' updateBounds(bounds, at.x - size.width / 2, at.y - size.height / 2) updateBounds(bounds, at.x + size.width / 2, at.y + size.height / 2) const elements: string[] = [] const layers = getLayers(pad) // Use KiCad-style pad colors: yellow for THT, red/green for SMD let color: string if (padType === 'thru_hole' || padType === 'np_thru_hole') { color = FOOTPRINT_COLORS.padThruHole // Yellow for through-hole } else if (layers.some(l => l.includes('B.'))) { color = FOOTPRINT_COLORS.padBack // Green for back SMD } else { color = FOOTPRINT_COLORS.padFront // Red for front SMD } // Render pad shape const rotation = at.rotation ?? 0 const transform = rotation !== 0 ? ` transform="rotate(${rotation}, ${at.x}, ${at.y})"` : '' if (padShape === 'circle') { const r = Math.min(size.width, size.height) / 2 elements.push(``) } else if (padShape === 'oval') { const rx = size.width / 2 const ry = size.height / 2 elements.push(``) } else if (padShape === 'roundrect') { const rratio = getNumericAttr(pad, 'roundrect_rratio') ?? 0.25 const rx = Math.min(size.width, size.height) * rratio / 2 const x = at.x - size.width / 2 const y = at.y - size.height / 2 elements.push(``) } else if (padShape === 'custom') { // Render custom pad primitives const primitives = findChild(pad, 'primitives') if (primitives) { for (const prim of findChildren(primitives, 'gr_poly')) { const points = getPoints(prim) if (points.length >= 3) { // Translate points relative to pad center and update bounds const polyStr = points.map(p => { const absX = at.x + p.x const absY = at.y + p.y updateBounds(bounds, absX, absY) return `${absX},${absY}` }).join(' ') elements.push(``) } } } // Fallback to rect if no primitives if (elements.length === 0) { const x = at.x - size.width / 2 const y = at.y - size.height / 2 elements.push(``) } } else { // Default to rect const x = at.x - size.width / 2 const y = at.y - size.height / 2 elements.push(``) } // Render drill hole for THT pads if (padType === 'thru_hole' || padType === 'np_thru_hole') { const drill = findChild(pad, 'drill') if (drill && drill.length >= 2) { // (drill size) or (drill oval w h) const isOval = isAtom(drill[1]) && drill[1] === 'oval' if (isOval && drill.length >= 4) { const dw = parseFloat(isAtom(drill[2]) ? drill[2] : '0') const dh = parseFloat(isAtom(drill[3]) ? drill[3] : '0') elements.push(``) } else { const drillSize = parseFloat(isAtom(drill[1]) ? drill[1] : '0') elements.push(``) } } } // Render pad number if (padNumber) { const fontSize = Math.min(size.width, size.height) * 0.5 elements.push(`${escapeXml(padNumber)}`) } return elements.join('\n') } /** * Render footprint line */ function renderFootprintLine(line: SExpr[], bounds: BoundingBox): string { const start = getPoint(line, 'start') const end = getPoint(line, 'end') if (!start || !end) return '' const stroke = getStroke(line) const layers = getLayers(line) const layer = layers[0] ?? 'F.SilkS' // Skip courtyard for cleaner preview if (layer.includes('CrtYd')) return '' const color = getLayerColor(layer) updateBounds(bounds, start.x, start.y) updateBounds(bounds, end.x, end.y) return `` } /** * Render footprint circle */ function renderFootprintCircle(circle: SExpr[], bounds: BoundingBox): string { const center = getPoint(circle, 'center') const end = getPoint(circle, 'end') if (!center || !end) return '' // KiCad defines circles by center and a point on the circumference const radius = Math.sqrt((end.x - center.x) ** 2 + (end.y - center.y) ** 2) const stroke = getStroke(circle) const layers = getLayers(circle) const layer = layers[0] ?? 'F.SilkS' // Skip courtyard if (layer.includes('CrtYd')) return '' const color = getLayerColor(layer) updateBounds(bounds, center.x - radius, center.y - radius) updateBounds(bounds, center.x + radius, center.y + radius) return `` } /** * Render footprint arc */ function renderFootprintArc(arc: SExpr[], bounds: BoundingBox): string { const start = getPoint(arc, 'start') const mid = getPoint(arc, 'mid') const end = getPoint(arc, 'end') if (!start || !mid || !end) return '' const stroke = getStroke(arc) const layers = getLayers(arc) const layer = layers[0] ?? 'F.SilkS' // Skip courtyard if (layer.includes('CrtYd')) return '' const color = getLayerColor(layer) updateBounds(bounds, start.x, start.y) updateBounds(bounds, mid.x, mid.y) updateBounds(bounds, end.x, end.y) const arcPath = calculateArcPath(start, mid, end) return `` } /** * Render footprint polygon (for SOLIDREGION) */ function renderFootprintPoly(poly: SExpr[], bounds: BoundingBox): string { // (fp_poly (pts (xy x y) (xy x y) ...) (layer "...") (stroke ...) (fill ...)) const points = getPoints(poly) if (points.length < 3) return '' const layers = getLayers(poly) const layer = layers[0] ?? 'F.Cu' // Skip courtyard for cleaner preview if (layer.includes('CrtYd')) return '' const color = getLayerColor(layer) const stroke = getStroke(poly) const fillType = getFillType(poly) const fill = fillType === 'solid' || fillType === 'yes' ? color : 'none' const pointsStr = points.map(p => { updateBounds(bounds, p.x, p.y) return `${p.x},${p.y}` }).join(' ') const strokeWidth = stroke?.width ?? 0 const strokeAttr = strokeWidth > 0 ? ` stroke="${color}" stroke-width="${strokeWidth}"` : '' return `` } /** * Render footprint rectangle */ function renderFootprintRect(rect: SExpr[], bounds: BoundingBox): string { const start = getPoint(rect, 'start') const end = getPoint(rect, 'end') if (!start || !end) return '' const layers = getLayers(rect) const layer = layers[0] ?? 'F.SilkS' // Skip courtyard for cleaner preview if (layer.includes('CrtYd')) return '' const color = getLayerColor(layer) const stroke = getStroke(rect) const fillType = getFillType(rect) const fill = fillType === 'solid' ? color : 'none' updateBounds(bounds, start.x, start.y) updateBounds(bounds, end.x, end.y) const x = Math.min(start.x, end.x) const y = Math.min(start.y, end.y) const w = Math.abs(end.x - start.x) const h = Math.abs(end.y - start.y) return `` } /** * Render footprint text */ function renderFootprintText(text: SExpr[], bounds: BoundingBox): string { // (fp_text type "text" (at x y [rot]) (layer "...") (effects ...)) if (!isList(text) || text.length < 3) return '' const textType = isAtom(text[1]) ? text[1] : '' // reference, value, user const textContent = isAtom(text[2]) ? text[2] : '' // Skip reference and value - they're placeholders if (textType === 'reference' || textType === 'value') return '' const at = getPointWithRotation(text, 'at') if (!at) return '' const layers = getLayers(text) const layer = layers[0] ?? 'F.SilkS' const color = getLayerColor(layer) // Parse font size and justification from effects const effects = findChild(text, 'effects') let fontSize = 1 let textAnchor = 'middle' // default: centered if (effects) { // Get font size const font = findChild(effects, 'font') if (font) { const size = getSize(font) if (size) fontSize = size.height } // Get justification: (justify left/right/center) const justify = findChild(effects, 'justify') if (justify) { for (let i = 1; i < justify.length; i++) { const val = isAtom(justify[i]) ? justify[i] : '' if (val === 'left') textAnchor = 'start' else if (val === 'right') textAnchor = 'end' } } } // Estimate text bounds based on content and justification const textWidth = textContent.length * fontSize * 0.6 const textHeight = fontSize if (textAnchor === 'start') { updateBounds(bounds, at.x, at.y - textHeight / 2) updateBounds(bounds, at.x + textWidth, at.y + textHeight / 2) } else if (textAnchor === 'end') { updateBounds(bounds, at.x - textWidth, at.y - textHeight / 2) updateBounds(bounds, at.x, at.y + textHeight / 2) } else { updateBounds(bounds, at.x - textWidth / 2, at.y - textHeight / 2) updateBounds(bounds, at.x + textWidth / 2, at.y + textHeight / 2) } const rotation = at.rotation ?? 0 const transform = rotation !== 0 ? ` transform="rotate(${rotation}, ${at.x}, ${at.y})"` : '' return `${escapeXml(textContent)}` } /** * Update bounding box with a point */ function updateBounds(bounds: BoundingBox, x: number, y: number): void { bounds.minX = Math.min(bounds.minX, x) bounds.maxX = Math.max(bounds.maxX, x) bounds.minY = Math.min(bounds.minY, y) bounds.maxY = Math.max(bounds.maxY, y) } /** * Create error SVG */ function createErrorSvg(message: string): string { return ` ${escapeXml(message)} ` }