diff --git a/src/index.ts b/src/index.ts index aa31af1..c9461ff 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,8 @@ export { parseKicadModToKicadJson } from "./parse-kicad-mod-to-kicad-json" export { parseKicadModToCircuitJson } from "./parse-kicad-mod-to-circuit-json" export { convertKicadJsonToTsCircuitSoup } from "./convert-kicad-json-to-tscircuit-soup" +export { parseKicadSymToSchematicInfo } from "./parse-kicad-sym-to-schematic-info" +export type { + SchematicInfo, + KicadSymPin, +} from "./parse-kicad-sym-to-schematic-info" diff --git a/src/parse-kicad-mod-to-circuit-json.ts b/src/parse-kicad-mod-to-circuit-json.ts index 58edbc6..f52e570 100644 --- a/src/parse-kicad-mod-to-circuit-json.ts +++ b/src/parse-kicad-mod-to-circuit-json.ts @@ -1,12 +1,61 @@ import type { AnyCircuitElement } from "circuit-json" import { parseKicadModToKicadJson } from "./parse-kicad-mod-to-kicad-json" import { convertKicadJsonToTsCircuitSoup as convertKicadJsonToCircuitJson } from "./convert-kicad-json-to-tscircuit-soup" +import { + parseKicadSymToSchematicInfo, + type SchematicInfo, +} from "./parse-kicad-sym-to-schematic-info" export const parseKicadModToCircuitJson = async ( kicadMod: string, + kicadSym?: string, ): Promise => { const kicadJson = parseKicadModToKicadJson(kicadMod) const circuitJson = await convertKicadJsonToCircuitJson(kicadJson) + + // If kicad_sym content is provided, enrich the schematic_component + if (kicadSym) { + const schInfo = parseKicadSymToSchematicInfo(kicadSym) + enrichWithSchematicInfo(circuitJson as any[], schInfo) + } + return circuitJson as any } + +/** + * Enrich circuit JSON with schematic info from kicad_sym. + * Finds the schematic_component element and adds pinLabels + schPinArrangement. + */ +function enrichWithSchematicInfo( + elements: any[], + schInfo: SchematicInfo, +): void { + const schComponent = elements.find( + (e: any) => e.type === "schematic_component", + ) + if (schComponent) { + schComponent.pin_labels = schInfo.pinLabels + schComponent.port_arrangement = schInfo.schPinArrangement + } + + // Also enrich source_component if present + const srcComponent = elements.find((e: any) => e.type === "source_component") + if (srcComponent) { + srcComponent.pin_labels = schInfo.pinLabels + srcComponent.port_arrangement = schInfo.schPinArrangement + } + + // Update source_port pin labels based on pinLabels mapping + for (const element of elements) { + if (element.type === "source_port" && element.pin_number != null) { + const label = schInfo.pinLabels[`pin${element.pin_number}`] + if (label) { + element.pin_label = label + if (!element.port_hints?.includes(label)) { + element.port_hints = [...(element.port_hints || []), label] + } + } + } + } +} diff --git a/src/parse-kicad-sym-to-schematic-info.ts b/src/parse-kicad-sym-to-schematic-info.ts new file mode 100644 index 0000000..56e389b --- /dev/null +++ b/src/parse-kicad-sym-to-schematic-info.ts @@ -0,0 +1,206 @@ +import parseSExpression from "s-expression" + +/** + * Parsed pin from a kicad_sym file. + */ +export interface KicadSymPin { + name: string + number: string + electricalType: string + at: [number, number, number?] + length: number +} + +/** + * Result of parsing a kicad_sym file for schematic info. + */ +export interface SchematicInfo { + pinLabels: Record + schPinArrangement: { + leftSide?: { pins: (number | string)[]; direction: "top-to-bottom" } + rightSide?: { pins: (number | string)[]; direction: "top-to-bottom" } + topSide?: { pins: (number | string)[]; direction: "left-to-right" } + bottomSide?: { pins: (number | string)[]; direction: "left-to-right" } + } +} + +/** + * Parse a kicad_sym file and extract pin labels + schematic port arrangement. + * + * KiCad symbol files contain pin positions with rotation angles that indicate + * which side of the component body the pin extends from: + * 0° → pin points right → placed on LEFT side + * 90° → pin points up → placed on BOTTOM side + * 180° → pin points left → placed on RIGHT side + * 270° → pin points down → placed on TOP side + */ +export function parseKicadSymToSchematicInfo( + fileContent: string, +): SchematicInfo { + const sexpr = parseSExpression(fileContent) + + if (sexpr[0] !== "kicad_symbol_lib") { + throw new Error("Invalid kicad_sym file: missing kicad_symbol_lib root") + } + + // Collect all pins from all symbols (including sub-units) + const allPins: KicadSymPin[] = [] + collectPinsFromSExpr(sexpr, allPins) + + // Deduplicate by pin number (sub-units can repeat pins) + const seenNumbers = new Set() + const uniquePins: KicadSymPin[] = [] + for (const pin of allPins) { + if (!seenNumbers.has(pin.number)) { + seenNumbers.add(pin.number) + uniquePins.push(pin) + } + } + + // Build pinLabels: { "1": "VCC", "2": "GND", ... } + const pinLabels: Record = {} + for (const pin of uniquePins) { + if (pin.number && pin.name && pin.name !== "~") { + pinLabels[`pin${pin.number}`] = pin.name + } + } + + // Categorize pins by side based on rotation + const left: (number | string)[] = [] + const right: (number | string)[] = [] + const top: (number | string)[] = [] + const bottom: (number | string)[] = [] + + for (const pin of uniquePins) { + const rotation = (((pin.at[2] ?? 0) % 360) + 360) % 360 + const pinId = /^\d+$/.test(pin.number) ? Number(pin.number) : pin.number + + if (rotation === 0) { + left.push(pinId) + } else if (rotation === 90) { + bottom.push(pinId) + } else if (rotation === 180) { + right.push(pinId) + } else if (rotation === 270) { + top.push(pinId) + } else { + // Non-standard rotation — fall back to position-based + const x = pin.at[0] + if (x <= 0) left.push(pinId) + else right.push(pinId) + } + } + + // Sort pins by their perpendicular coordinate for natural ordering + const sortByY = (pinIds: (number | string)[], pins: KicadSymPin[]) => { + return pinIds.sort((a, b) => { + const pinA = pins.find((p) => + typeof a === "number" ? p.number === String(a) : p.number === a, + ) + const pinB = pins.find((p) => + typeof b === "number" ? p.number === String(b) : p.number === b, + ) + if (!pinA || !pinB) return 0 + // KiCad Y is inverted, so higher Y = visually higher (top) + return pinB.at[1] - pinA.at[1] + }) + } + + const sortByX = (pinIds: (number | string)[], pins: KicadSymPin[]) => { + return pinIds.sort((a, b) => { + const pinA = pins.find((p) => + typeof a === "number" ? p.number === String(a) : p.number === a, + ) + const pinB = pins.find((p) => + typeof b === "number" ? p.number === String(b) : p.number === b, + ) + if (!pinA || !pinB) return 0 + return pinA.at[0] - pinB.at[0] + }) + } + + sortByY(left, uniquePins) + sortByY(right, uniquePins) + sortByX(top, uniquePins) + sortByX(bottom, uniquePins) + + const schPinArrangement: SchematicInfo["schPinArrangement"] = {} + if (left.length > 0) + schPinArrangement.leftSide = { pins: left, direction: "top-to-bottom" } + if (right.length > 0) + schPinArrangement.rightSide = { pins: right, direction: "top-to-bottom" } + if (top.length > 0) + schPinArrangement.topSide = { pins: top, direction: "left-to-right" } + if (bottom.length > 0) + schPinArrangement.bottomSide = { pins: bottom, direction: "left-to-right" } + + return { pinLabels, schPinArrangement } +} + +/** + * Recursively walk the S-expression tree to find all (pin ...) nodes. + */ +function collectPinsFromSExpr(node: any, out: KicadSymPin[]): void { + if (!Array.isArray(node)) return + + if (node[0] === "pin") { + const pin = parsePinNode(node) + if (pin) out.push(pin) + return + } + + for (const child of node) { + collectPinsFromSExpr(child, out) + } +} + +/** + * Parse a single (pin ...) s-expression node. + * + * Format: + * (pin electrical_type graphic_style + * (at X Y [ROTATION]) + * (length LENGTH) + * (name "NAME" [(effects ...)]) + * (number "NUM" [(effects ...)]) + * ) + */ +function parsePinNode(node: any[]): KicadSymPin | null { + const electricalType = String(node[1]?.valueOf?.() ?? node[1] ?? "passive") + // node[2] is graphic_style (line, inverted, etc.) — skip + + let at: [number, number, number?] = [0, 0] + let length = 2.54 + let name = "" + let number = "" + + for (const attr of node.slice(3)) { + if (!Array.isArray(attr)) continue + const token = String(attr[0]?.valueOf?.() ?? attr[0]) + + switch (token) { + case "at": + at = [ + parseFloat(String(attr[1]?.valueOf?.() ?? 0)), + parseFloat(String(attr[2]?.valueOf?.() ?? 0)), + ] + if (attr[3] != null) { + ;(at as any).push(parseFloat(String(attr[3]?.valueOf?.() ?? 0))) + } + break + case "length": + length = parseFloat(String(attr[1]?.valueOf?.() ?? 2.54)) + break + case "name": + name = String(attr[1]?.valueOf?.() ?? "") + break + case "number": + number = String(attr[1]?.valueOf?.() ?? "") + break + } + } + + if (!number) return null + + return { name, number, electricalType, at, length } +} diff --git a/src/site/App.tsx b/src/site/App.tsx index 64758dd..f1f0cfa 100644 --- a/src/site/App.tsx +++ b/src/site/App.tsx @@ -26,7 +26,10 @@ export const App = () => { setError(null) let circuitJson: any try { - circuitJson = await parseKicadModToCircuitJson(filesAdded.kicad_mod) + circuitJson = await parseKicadModToCircuitJson( + filesAdded.kicad_mod, + filesAdded.kicad_sym, + ) updateCircuitJson(circuitJson as any) } catch (err: any) { setError(`Error parsing KiCad Mod file: ${err.toString()}`) @@ -152,6 +155,19 @@ export const App = () => { KiCad Mod File +
+ + {filesAdded.kicad_sym ? "✅" : "➖"} + + + KiCad Sym File{" "} + (optional, for schematic) + +
{Object.keys(filesAdded).length > 0 && ( diff --git a/tests/data/ATmega328P.kicad_sym b/tests/data/ATmega328P.kicad_sym new file mode 100644 index 0000000..e50a514 --- /dev/null +++ b/tests/data/ATmega328P.kicad_sym @@ -0,0 +1,135 @@ +(kicad_symbol_lib + (version 20211014) + (generator kicad_symbol_editor) + (symbol "ATmega328P" + (pin_names (offset 1.016)) + (in_bom yes) + (on_board yes) + (property "Reference" "U" (at 0 26.67 0) + (effects (font (size 1.27 1.27))) + ) + (property "Value" "ATmega328P" (at 0 -26.67 0) + (effects (font (size 1.27 1.27))) + ) + (symbol "ATmega328P_0_1" + (rectangle (start -12.7 25.4) (end 12.7 -25.4) + (stroke (width 0.254) (type default)) + (fill (type background)) + ) + ) + (symbol "ATmega328P_1_1" + (pin power_in line (at -15.24 22.86 0) (length 2.54) + (name "VCC" (effects (font (size 1.27 1.27)))) + (number "7" (effects (font (size 1.27 1.27)))) + ) + (pin power_in line (at -15.24 20.32 0) (length 2.54) + (name "AVCC" (effects (font (size 1.27 1.27)))) + (number "20" (effects (font (size 1.27 1.27)))) + ) + (pin power_in line (at -15.24 -22.86 0) (length 2.54) + (name "GND" (effects (font (size 1.27 1.27)))) + (number "8" (effects (font (size 1.27 1.27)))) + ) + (pin power_in line (at -15.24 -20.32 0) (length 2.54) + (name "GND2" (effects (font (size 1.27 1.27)))) + (number "22" (effects (font (size 1.27 1.27)))) + ) + (pin input line (at -15.24 17.78 0) (length 2.54) + (name "AREF" (effects (font (size 1.27 1.27)))) + (number "21" (effects (font (size 1.27 1.27)))) + ) + (pin bidirectional line (at 15.24 22.86 180) (length 2.54) + (name "PB0" (effects (font (size 1.27 1.27)))) + (number "14" (effects (font (size 1.27 1.27)))) + ) + (pin bidirectional line (at 15.24 20.32 180) (length 2.54) + (name "PB1" (effects (font (size 1.27 1.27)))) + (number "15" (effects (font (size 1.27 1.27)))) + ) + (pin bidirectional line (at 15.24 17.78 180) (length 2.54) + (name "PB2" (effects (font (size 1.27 1.27)))) + (number "16" (effects (font (size 1.27 1.27)))) + ) + (pin bidirectional line (at 15.24 15.24 180) (length 2.54) + (name "PB3" (effects (font (size 1.27 1.27)))) + (number "17" (effects (font (size 1.27 1.27)))) + ) + (pin bidirectional line (at 15.24 12.7 180) (length 2.54) + (name "PB4" (effects (font (size 1.27 1.27)))) + (number "18" (effects (font (size 1.27 1.27)))) + ) + (pin bidirectional line (at 15.24 10.16 180) (length 2.54) + (name "PB5" (effects (font (size 1.27 1.27)))) + (number "19" (effects (font (size 1.27 1.27)))) + ) + (pin bidirectional line (at 15.24 5.08 180) (length 2.54) + (name "PC0" (effects (font (size 1.27 1.27)))) + (number "23" (effects (font (size 1.27 1.27)))) + ) + (pin bidirectional line (at 15.24 2.54 180) (length 2.54) + (name "PC1" (effects (font (size 1.27 1.27)))) + (number "24" (effects (font (size 1.27 1.27)))) + ) + (pin bidirectional line (at 15.24 0 180) (length 2.54) + (name "PC2" (effects (font (size 1.27 1.27)))) + (number "25" (effects (font (size 1.27 1.27)))) + ) + (pin bidirectional line (at 15.24 -2.54 180) (length 2.54) + (name "PC3" (effects (font (size 1.27 1.27)))) + (number "26" (effects (font (size 1.27 1.27)))) + ) + (pin bidirectional line (at 15.24 -5.08 180) (length 2.54) + (name "PC4" (effects (font (size 1.27 1.27)))) + (number "27" (effects (font (size 1.27 1.27)))) + ) + (pin bidirectional line (at 15.24 -7.62 180) (length 2.54) + (name "PC5" (effects (font (size 1.27 1.27)))) + (number "28" (effects (font (size 1.27 1.27)))) + ) + (pin input line (at -15.24 12.7 0) (length 2.54) + (name "~{RESET}" (effects (font (size 1.27 1.27)))) + (number "1" (effects (font (size 1.27 1.27)))) + ) + (pin bidirectional line (at 15.24 -12.7 180) (length 2.54) + (name "PD0" (effects (font (size 1.27 1.27)))) + (number "2" (effects (font (size 1.27 1.27)))) + ) + (pin bidirectional line (at 15.24 -15.24 180) (length 2.54) + (name "PD1" (effects (font (size 1.27 1.27)))) + (number "3" (effects (font (size 1.27 1.27)))) + ) + (pin bidirectional line (at 15.24 -17.78 180) (length 2.54) + (name "PD2" (effects (font (size 1.27 1.27)))) + (number "4" (effects (font (size 1.27 1.27)))) + ) + (pin bidirectional line (at 15.24 -20.32 180) (length 2.54) + (name "PD3" (effects (font (size 1.27 1.27)))) + (number "5" (effects (font (size 1.27 1.27)))) + ) + (pin bidirectional line (at 15.24 -22.86 180) (length 2.54) + (name "PD4" (effects (font (size 1.27 1.27)))) + (number "6" (effects (font (size 1.27 1.27)))) + ) + (pin input line (at -15.24 7.62 0) (length 2.54) + (name "XTAL1" (effects (font (size 1.27 1.27)))) + (number "9" (effects (font (size 1.27 1.27)))) + ) + (pin output line (at -15.24 5.08 0) (length 2.54) + (name "XTAL2" (effects (font (size 1.27 1.27)))) + (number "10" (effects (font (size 1.27 1.27)))) + ) + (pin bidirectional line (at 15.24 -10.16 180) (length 2.54) + (name "PD5" (effects (font (size 1.27 1.27)))) + (number "11" (effects (font (size 1.27 1.27)))) + ) + (pin bidirectional line (at 15.24 -7.62 180) (length 2.54) + (name "PD6" (effects (font (size 1.27 1.27)))) + (number "12" (effects (font (size 1.27 1.27)))) + ) + (pin bidirectional line (at 15.24 -5.08 180) (length 2.54) + (name "PD7" (effects (font (size 1.27 1.27)))) + (number "13" (effects (font (size 1.27 1.27)))) + ) + ) + ) +) diff --git a/tests/data/R.kicad_sym b/tests/data/R.kicad_sym new file mode 100644 index 0000000..56d82f5 --- /dev/null +++ b/tests/data/R.kicad_sym @@ -0,0 +1,32 @@ +(kicad_symbol_lib + (version 20211014) + (generator kicad_symbol_editor) + (symbol "R" + (pin_numbers hide) + (pin_names (offset 0) hide) + (in_bom yes) + (on_board yes) + (property "Reference" "R" (at 2.032 0 90) + (effects (font (size 1.27 1.27))) + ) + (property "Value" "R" (at 0 0 90) + (effects (font (size 1.27 1.27))) + ) + (symbol "R_0_1" + (rectangle (start -1.016 -2.54) (end 1.016 2.54) + (stroke (width 0.254) (type default)) + (fill (type none)) + ) + ) + (symbol "R_1_1" + (pin passive line (at 0 3.81 270) (length 1.27) + (name "~" (effects (font (size 1.27 1.27)))) + (number "1" (effects (font (size 1.27 1.27)))) + ) + (pin passive line (at 0 -3.81 90) (length 1.27) + (name "~" (effects (font (size 1.27 1.27)))) + (number "2" (effects (font (size 1.27 1.27)))) + ) + ) + ) +) diff --git a/tests/kicad-sym-parse.test.ts b/tests/kicad-sym-parse.test.ts new file mode 100644 index 0000000..c31ff02 --- /dev/null +++ b/tests/kicad-sym-parse.test.ts @@ -0,0 +1,51 @@ +import { test, expect } from "bun:test" +import { parseKicadSymToSchematicInfo } from "src" +import fs from "fs" +import { join } from "path" + +test("parse resistor kicad_sym - 2 pin passive", () => { + const content = fs.readFileSync( + join(import.meta.dirname, "data/R.kicad_sym"), + "utf8", + ) + const info = parseKicadSymToSchematicInfo(content) + + // Resistor has 2 pins with name "~" (hidden), so pinLabels should be empty + expect(Object.keys(info.pinLabels).length).toBe(0) + + // Pin 1 has rotation 270° → top side, Pin 2 has rotation 90° → bottom side + expect(info.schPinArrangement.topSide?.pins).toContain(1) + expect(info.schPinArrangement.bottomSide?.pins).toContain(2) +}) + +test("parse ATmega328P kicad_sym - multi-pin IC", () => { + const content = fs.readFileSync( + join(import.meta.dirname, "data/ATmega328P.kicad_sym"), + "utf8", + ) + const info = parseKicadSymToSchematicInfo(content) + + // Should have pin labels for all named pins + expect(info.pinLabels.pin7).toBe("VCC") + expect(info.pinLabels.pin8).toBe("GND") + expect(info.pinLabels.pin1).toBe("~{RESET}") + expect(info.pinLabels.pin14).toBe("PB0") + expect(info.pinLabels.pin23).toBe("PC0") + + // Left side: power pins + inputs (rotation 0°) + expect(info.schPinArrangement.leftSide).toBeDefined() + expect(info.schPinArrangement.leftSide!.pins).toContain(7) // VCC + expect(info.schPinArrangement.leftSide!.pins).toContain(8) // GND + expect(info.schPinArrangement.leftSide!.pins).toContain(1) // RESET + + // Right side: port pins (rotation 180°) + expect(info.schPinArrangement.rightSide).toBeDefined() + expect(info.schPinArrangement.rightSide!.pins).toContain(14) // PB0 + expect(info.schPinArrangement.rightSide!.pins).toContain(2) // PD0 +}) + +test("parse kicad_sym with invalid content throws", () => { + expect(() => parseKicadSymToSchematicInfo("(footprint test)")).toThrow( + "Invalid kicad_sym file", + ) +})