Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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"
49 changes: 49 additions & 0 deletions src/parse-kicad-mod-to-circuit-json.ts
Original file line number Diff line number Diff line change
@@ -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<AnyCircuitElement[]> => {
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]
}
}
}
}
}
206 changes: 206 additions & 0 deletions src/parse-kicad-sym-to-schematic-info.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>
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<string>()
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<string, string> = {}
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 }
}
18 changes: 17 additions & 1 deletion src/site/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()}`)
Expand Down Expand Up @@ -152,6 +155,19 @@ export const App = () => {
</span>
<span className="text-gray-300">KiCad Mod File</span>
</div>
<div className="flex items-center gap-2 bg-gray-800/50 p-3 rounded-md">
<span
className={
filesAdded.kicad_sym ? "text-green-500" : "text-gray-500"
}
>
{filesAdded.kicad_sym ? "✅" : "➖"}
</span>
<span className="text-gray-300">
KiCad Sym File{" "}
<span className="text-gray-500">(optional, for schematic)</span>
</span>
</div>
</div>
<div className="flex justify-center items-center gap-2">
{Object.keys(filesAdded).length > 0 && (
Expand Down
Loading
Loading