@typecad/schematic
Version:
Generate KiCAD schematics from your typeCAD project
387 lines (335 loc) • 19.3 kB
text/typescript
import fs from "node:fs";
import fsexp from "fast-sexpr";
import SExpr from "s-expression.js";
import { platform } from 'node:os';
import { kicad_path } from "./kicad";
import { GRID_UNIT_MM } from "./kicad-schematic";
const S = new SExpr();
export interface SymbolDefinition {
rawSexpr: any[];
serializedLibEntry: string;
dimensionsMM?: { widthMM: number, heightMM: number, minX: number, minY: number }; // Dimensions and top-left offset relative to symbol origin (all in mm)
}
export interface PinLocation {
x: number;
y: number;
angle: number;
}
export class SymbolLibraryManager {
private libraryCache: Map<string, any> = new Map(); // Cache for parsed library files (key: lib_name)
private symbolCache: Map<string, SymbolDefinition> = new Map(); // Cache for specific symbol definitions (key: symbol_fqn)
private pinLocationCache: Map<string, PinLocation> = new Map(); // Cache for pin locations (key: symbol_fqn + pin_number)
private kicadSymbolPath: string;
constructor() {
this.kicadSymbolPath = platform() === 'win32'
? `${kicad_path}share/kicad/symbols`
: `${kicad_path}symbols`; // Determine path once
}
private formatSExpression(sexpr: string): string {
let formatted = '';
let indentLevel = 0;
let inString = false;
let i = 0;
while (i < sexpr.length) {
const char = sexpr[i];
if (char === '"' && (i === 0 || sexpr[i-1] !== '\\')) {
inString = !inString;
formatted += char;
} else if (!inString) {
if (char === '(') {
if (formatted.length > 0 && formatted[formatted.length - 1] !== '\n' && formatted[formatted.length - 1] !== '\t') {
formatted += '\n' + '\t'.repeat(indentLevel);
}
formatted += char;
indentLevel++;
} else if (char === ')') {
indentLevel--;
formatted += char;
// Add newline after closing parenthesis if not at end and next char is opening paren
if (i + 1 < sexpr.length && sexpr[i + 1] === '(') {
formatted += '\n' + '\t'.repeat(indentLevel);
}
} else if (char === ' ' && sexpr[i + 1] === '(') {
// Space before opening paren - add newline and indent
formatted += '\n' + '\t'.repeat(indentLevel);
} else {
formatted += char;
}
} else {
formatted += char;
}
i++;
}
return formatted;
}
private getLibraryContent(libraryName: string): any | null {
if (this.libraryCache.has(libraryName)) {
return this.libraryCache.get(libraryName);
}
let symbolFilePath = `${this.kicadSymbolPath}/${libraryName}.kicad_sym`;
let symbolFileContents = "";
try {
if (fs.existsSync(symbolFilePath)) {
symbolFileContents = fs.readFileSync(symbolFilePath, "utf8");
} else {
// Fallback to local build lib
symbolFilePath = `./build/lib/${libraryName}.kicad_sym`;
if (fs.existsSync(symbolFilePath)) {
symbolFileContents = fs.readFileSync(symbolFilePath, "utf8");
} else {
console.error(`Library file ${libraryName}.kicad_sym not found`);
return null;
}
}
// Use fast-sexpr for parsing
const parsedLibrary = fsexp(symbolFileContents.replaceAll('"', "`")).pop();
this.libraryCache.set(libraryName, parsedLibrary);
return parsedLibrary;
} catch (err) {
console.error(`Error reading library ${libraryName}`);
return null;
}
}
private _parseSymbolDimensions(symbolFqn: string, rawSexpr: any[]): { widthMM: number, heightMM: number, minX: number, minY: number } | null {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
let foundGraphicalElements = false;
function processElement(element: any) {
if (!Array.isArray(element)) return;
const type = element[0];
if (type === 'rectangle' && element.length > 2) {
let startX, startY, endX, endY;
for (const prop of element) {
if (Array.isArray(prop) && prop.length > 2) {
if (prop[0] === 'start' && prop.length === 3) {
startX = parseFloat(String(prop[1]).replaceAll('`',''));
startY = parseFloat(String(prop[2]).replaceAll('`',''));
} else if (prop[0] === 'end' && prop.length === 3) {
endX = parseFloat(String(prop[1]).replaceAll('`',''));
endY = parseFloat(String(prop[2]).replaceAll('`',''));
}
}
}
if (startX !== undefined && startY !== undefined && endX !== undefined && endY !== undefined) {
minX = Math.min(minX, startX, endX);
minY = Math.min(minY, startY, endY);
maxX = Math.max(maxX, startX, endX);
maxY = Math.max(maxY, startY, endY);
foundGraphicalElements = true;
}
} else if (type === 'circle' && element.length > 2) {
let centerX, centerY, radius;
for (const prop of element) {
if (Array.isArray(prop) && prop.length > 1) {
if (prop[0] === 'center' && prop.length === 3) {
centerX = parseFloat(String(prop[1]).replaceAll('`',''));
centerY = parseFloat(String(prop[2]).replaceAll('`',''));
} else if (prop[0] === 'radius' && prop.length === 2) {
radius = parseFloat(String(prop[1]).replaceAll('`',''));
}
}
}
if (centerX !== undefined && centerY !== undefined && radius !== undefined) {
minX = Math.min(minX, centerX - radius);
minY = Math.min(minY, centerY - radius);
maxX = Math.max(maxX, centerX + radius);
maxY = Math.max(maxY, centerY + radius);
foundGraphicalElements = true;
}
} else if (type === 'pin') { // Consider pin locations for bounding box
let atData: number[] | null = null;
for(const prop of element) {
if (!Array.isArray(prop)) continue;
if (prop[0] === 'at' && prop.length > 2) {
atData = [parseFloat(String(prop[1]).replaceAll('`','')), parseFloat(String(prop[2]).replaceAll('`',''))];
}
}
if (atData) {
minX = Math.min(minX, atData[0]);
minY = Math.min(minY, atData[1]);
maxX = Math.max(maxX, atData[0]);
maxY = Math.max(maxY, atData[1]);
foundGraphicalElements = true;
}
}
else if (type === 'symbol' && Array.isArray(element[2])) {
for (let i = 2; i < element.length; i++) {
processElement(element[i]);
}
}
}
for (const item of rawSexpr) {
processElement(item);
}
if (!foundGraphicalElements || minX === Infinity) {
if (!foundGraphicalElements) {
console.warn(`No graphical elements or pins found for dimension parsing in symbol ${symbolFqn}. Using minimal default size.`);
}
// Default to a small size (e.g., 1x1 grid unit) if no info, centered at origin
return { widthMM: GRID_UNIT_MM, heightMM: GRID_UNIT_MM, minX: -GRID_UNIT_MM / 2, minY: -GRID_UNIT_MM / 2 };
}
// Handle cases where pins might be at 0,0 only
if (minX === Infinity) { minX = 0; maxX = 0; }
if (minY === Infinity) { minY = 0; maxY = 0; }
if (maxX === -Infinity) maxX = minX; // if only minX was set from a single point
if (maxY === -Infinity) maxY = minY;
return {
widthMM: Math.max(GRID_UNIT_MM * 0.25, maxX - minX), // Ensure a minimum sensible size
heightMM: Math.max(GRID_UNIT_MM * 0.25, maxY - minY),
minX: minX,
minY: minY
};
}
private findSymbolInLibrary(libraryContent: any, libraryName: string, symbolName: string): SymbolDefinition | null {
const symbolFqn = `${libraryName}:${symbolName}`;
if (this.symbolCache.has(symbolFqn)) {
return this.symbolCache.get(symbolFqn)!;
}
for (const item of libraryContent) {
if (Array.isArray(item) && item.length > 1 && item[0] === 'symbol') {
// Check for the specific symbol name (removing potential backticks from fsexp output)
const currentSymbolName = String(item[1]).replaceAll('`','');
if (currentSymbolName === symbolName) {
const rawSexpr = JSON.parse(JSON.stringify(item)); // Deep clone the array to avoid reference issues
// Modify the name to be fully qualified *in the cloned array*
rawSexpr[1] = `"${symbolFqn}"`;
// Handle 'extends'
const extendsIndex = rawSexpr.findIndex(el => Array.isArray(el) && el[0] === 'extends');
if (extendsIndex !== -1 && Array.isArray(rawSexpr[extendsIndex]) && rawSexpr[extendsIndex].length > 1) {
const baseSymbolName = String(rawSexpr[extendsIndex][1]).replaceAll('`', '');
// Modify the extends part in the cloned array
rawSexpr[extendsIndex][1] = `"${libraryName}:${baseSymbolName}"`;
// Recursively find the base symbol definition
// console.log(`Symbol ${symbolFqn} extends ${libraryName}:${baseSymbolName}. Following extension...`);
// Use getSymbolDefinition which includes caching
const baseSymbolDef = this.getSymbolDefinition(`${libraryName}:${baseSymbolName}`);
if (baseSymbolDef) {
// Create a new definition for the extended symbol with the correct name
// but using the base symbol's content
let updatedSerializedEntry = baseSymbolDef.serializedLibEntry;
// Replace all occurrences of the base symbol name with the extended symbol name
// This includes the main symbol name and nested symbol unit names
const baseSymbolPattern = new RegExp(`"${libraryName}:${baseSymbolName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"`, 'g');
updatedSerializedEntry = updatedSerializedEntry.replace(baseSymbolPattern, `"${symbolFqn}"`);
// Also replace symbol unit names (e.g., ATtiny807-M_0_1 -> ATtiny3227-M_0_1)
const baseUnitPattern = new RegExp(`"${baseSymbolName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}_`, 'g');
updatedSerializedEntry = updatedSerializedEntry.replace(baseUnitPattern, `"${symbolName}_`);
const extendedSymbolDef: SymbolDefinition = {
rawSexpr: baseSymbolDef.rawSexpr,
serializedLibEntry: updatedSerializedEntry,
dimensionsMM: baseSymbolDef.dimensionsMM
};
// Cache and return the extended symbol definition
this.symbolCache.set(symbolFqn, extendedSymbolDef);
return extendedSymbolDef;
} else {
console.error(`ERROR: Base symbol ${libraryName}:${baseSymbolName} for ${symbolFqn} not found.`);
return null;
}
}
// Serialize the modified s-expression for the library entry
let serializedLibEntry = S.serialize(rawSexpr).replaceAll("`", '"'); // Ensure quotes are correct
// Format the serialized entry with proper indentation for KiCAD
serializedLibEntry = this.formatSExpression(serializedLibEntry);
const dimensions = this._parseSymbolDimensions(symbolFqn, rawSexpr); // Parse dimensions
const definition: SymbolDefinition = {
rawSexpr: rawSexpr,
serializedLibEntry: serializedLibEntry,
dimensionsMM: dimensions ?? undefined // Store dimensions
};
this.symbolCache.set(symbolFqn, definition);
return definition;
}
}
}
return null;
}
public getSymbolDefinition(symbolFqn: string): SymbolDefinition | null {
if (this.symbolCache.has(symbolFqn)) {
return this.symbolCache.get(symbolFqn)!;
}
const parts = symbolFqn.split(':');
if (parts.length !== 2) {
console.error(`Invalid fully qualified symbol name: ${symbolFqn}`);
return null;
}
const [libraryName, symbolName] = parts;
const libraryContent = this.getLibraryContent(libraryName);
if (!libraryContent) {
return null; // Error already logged
}
const definition = this.findSymbolInLibrary(libraryContent, libraryName, symbolName);
if (!definition) {
console.error(`ERROR: Symbol definition ${symbolFqn} not found in library ${libraryName}.`);
}
return definition;
}
public getPinLocation(symbolFqn: string, pinNumber: string | number): PinLocation | null {
const cacheKey = `${symbolFqn}_${pinNumber}`;
if (this.pinLocationCache.has(cacheKey)) {
return this.pinLocationCache.get(cacheKey)!;
}
const symbolDef = this.getSymbolDefinition(symbolFqn);
if (!symbolDef) {
// Error logged in getSymbolDefinition
// console.error(`Cannot get pin location for non-existent symbol: ${symbolFqn}`);
return null;
}
// Search within the raw S-expression for the pin data
// This needs to accommodate the different structures observed in the original code
try {
// Iterate through the elements of the symbol definition
for (const element of symbolDef.rawSexpr) {
if (!Array.isArray(element)) continue;
// Structure 1: (pin type name (at x y a) (length l) (name "n" (effects ..)) (number "num" (effects ..)))
if (element[0] === 'pin') {
let currentPinNumber: string | null = null;
let atData: number[] | null = null;
for(const prop of element) {
if (!Array.isArray(prop)) continue;
if (prop[0] === 'number' && prop.length > 1) {
currentPinNumber = String(prop[1]).replaceAll('`','');
} else if (prop[0] === 'at' && prop.length > 3) {
atData = [parseFloat(prop[1]), parseFloat(prop[2]), parseFloat(prop[3])];
}
}
if (currentPinNumber === String(pinNumber) && atData) {
const location: PinLocation = { x: atData[0], y: atData[1], angle: atData[2] };
this.pinLocationCache.set(cacheKey, location);
return location;
}
}
// Structure 2: Check within nested elements like 'symbol BLAH_1_1 ... (pin ...)'
// This handles symbols composed of multiple units/bodies where pins are defined within a nested symbol element
// e.g., ["symbol", "`NAME_1_1`", ["pin", ...], ["pin", ...]]
else if (Array.isArray(element) && element.length > 1 && element[0] === 'symbol' && typeof element[1] === 'string') {
// Iterate through the children of this nested symbol element
for (const subElement of element.slice(2)) { // Skip the "symbol" and name parts
if (!Array.isArray(subElement) || subElement.length === 0 || subElement[0] !== 'pin') continue;
let currentPinNumber: string | null = null;
let atData: number[] | null = null;
for(const prop of subElement) {
if (!Array.isArray(prop)) continue;
if (prop[0] === 'number' && prop.length > 1) {
currentPinNumber = String(prop[1]).replaceAll('`','');
} else if (prop[0] === 'at' && prop.length > 3) {
atData = [parseFloat(prop[1]), parseFloat(prop[2]), parseFloat(prop[3])];
}
}
if (currentPinNumber === String(pinNumber) && atData) {
const location: PinLocation = { x: atData[0], y: atData[1], angle: atData[2] };
this.pinLocationCache.set(cacheKey, location);
return location;
}
}
}
}
} catch (e) {
console.error(`Error parsing pin data for ${symbolFqn} pin ${pinNumber}:`, e);
return null;
}
console.error(`ERROR: Pin ${pinNumber} not found in symbol ${symbolFqn}`);
// Log the structure we attempted to parse
console.error(`Failed structure for ${symbolFqn}:`, JSON.stringify(symbolDef?.rawSexpr, null, 2));
return null;
}
}