@typecad/schematic
Version:
Generate KiCAD schematics from your typeCAD project
610 lines (531 loc) • 25.9 kB
text/typescript
import Handlebars from "handlebars";
import { randomUUID, UUID as CryptoUUID } from "node:crypto";
import { Pin, Component, Node } from './types'; // Assuming types.ts exists
import { SymbolLibraryManager, SymbolDefinition, PinLocation } from "./symbol-library-manager";
// Constants for grid and conversions
export const GRID_UNIT_MM = 2.54;
export const PAGE_WIDTH_MM = 284.73; // A4 Landscape width approx
export const PAGE_HEIGHT_MM = 165.86; // A4 Landscape height approx
export const GRID_OFFSET_X = 13;
export const GRID_OFFSET_Y = 13;
export let schematic_template = `
(kicad_sch
(version 20250114)
(generator "typecad")
(generator_version "0.1.0")
(paper "A4")
(uuid "{{{uuid}}}")
(lib_symbols
{{#each lib_symbols}}
{{{this}}}
{{/each}}
)
{{#each labels}}
{{{this}}}
{{/each}}
{{#each no_connects}}
{{{this}}}
{{/each}}
{{#each symbols}}
{{{this}}}
{{/each}}
(sheet_instances
(path "/"
(page "1")
)
)
(embedded_fonts no)
)`;
export let label_template = `
(label "{{{net_name}}}"
(at {{{x}}} {{{y}}} {{{label_rotation}}})
(effects
(font
(size 1.27 1.27)
)
(justify {{{justify_horizontal}}} {{{justify_vertical}}})
)
(uuid "{{{uuid}}}")
)
`;
export class KiCADSchematic {
lib_symbols: string[] = [];
symbols: string[] = [];
labels: string[] = []; // Added for net labels
no_connects: string[] = []; // Added for no_connect symbols
grid: number[][] = [];
gridWidth: number = Math.ceil(PAGE_WIDTH_MM / GRID_UNIT_MM);
gridHeight: number = Math.ceil(PAGE_HEIGHT_MM / GRID_UNIT_MM);
sheetname: string = '';
uuid: string = randomUUID();
private symbolManager: SymbolLibraryManager; // Add instance of the manager
private processedComponents: Set<string> = new Set(); // Track processed component references
constructor(symbolManager: SymbolLibraryManager) { // Inject the manager
this.symbolManager = symbolManager;
// Initialize the grid
for (let y = 0; y < this.gridHeight; y++) {
this.grid[y] = [];
for (let x = 0; x < this.gridWidth; x++) {
this.grid[y][x] = 0; // Initialize all cells as walkable
}
}
}
// Helper to check if an area for a component is clear on the grid
private isAreaClear(potentialSchX: number, potentialSchY: number, rotation: number, dimensions: { widthMM: number, heightMM: number, minX: number, minY: number }): boolean {
if (rotation === 0) {
const bboxSchematicTopLeftX = potentialSchX + dimensions.minX;
const bboxSchematicTopLeftY = potentialSchY + dimensions.minY;
const bboxSchematicBottomRightX = bboxSchematicTopLeftX + dimensions.widthMM;
const bboxSchematicBottomRightY = bboxSchematicTopLeftY + dimensions.heightMM;
const startGridX = Math.floor(bboxSchematicTopLeftX / GRID_UNIT_MM) + GRID_OFFSET_X;
const startGridY = Math.floor(bboxSchematicTopLeftY / GRID_UNIT_MM) + GRID_OFFSET_Y;
const endGridX = Math.ceil(bboxSchematicBottomRightX / GRID_UNIT_MM) + GRID_OFFSET_X;
const endGridY = Math.ceil(bboxSchematicBottomRightY / GRID_UNIT_MM) + GRID_OFFSET_Y;
for (let gy = startGridY; gy < endGridY; gy++) {
for (let gx = startGridX; gx < endGridX; gx++) {
if (gy >= 0 && gy < this.gridHeight && gx >= 0 && gx < this.gridWidth) {
if (this.grid[gy][gx] === 1) return false; // Area is occupied
}
}
}
} else {
// Simplified check for rotated components
const isNinetyDegreeRotation = rotation === 90 || rotation === 270;
const effectiveWidthMM = isNinetyDegreeRotation ? dimensions.heightMM : dimensions.widthMM;
const effectiveHeightMM = isNinetyDegreeRotation ? dimensions.widthMM : dimensions.heightMM;
const originGridX = Math.round(potentialSchX / GRID_UNIT_MM);
const originGridY = Math.round(potentialSchY / GRID_UNIT_MM);
const halfWidthGridCells = Math.max(1, Math.ceil((effectiveWidthMM / GRID_UNIT_MM) / 2));
const halfHeightGridCells = Math.max(1, Math.ceil((effectiveHeightMM / GRID_UNIT_MM) / 2));
for (let dy = -halfHeightGridCells + 1; dy <= halfHeightGridCells -1; dy++) {
for (let dx = -halfWidthGridCells + 1; dx <= halfWidthGridCells -1; dx++) {
const currentGridX = originGridX + dx;
const currentGridY = originGridY + dy;
if (currentGridY >= 0 && currentGridY < this.gridHeight &&
currentGridX >= 0 && currentGridX < this.gridWidth) {
if (this.grid[currentGridY][currentGridX] === 1) return false; // Area is occupied
}
}
}
}
return true; // Area is clear
}
private markComponentAreaUnwalkable(component: Component, symbolDef: SymbolDefinition | null): void {
if (!component.sch || !symbolDef || !symbolDef.dimensionsMM) {
const compGridX = component.sch ? Math.round(component.sch.x / GRID_UNIT_MM) + GRID_OFFSET_X : -1;
const compGridY = component.sch ? Math.round(component.sch.y / GRID_UNIT_MM) + GRID_OFFSET_Y : -1;
if (compGridY >= 0 && compGridY < this.gridHeight && compGridX >= 0 && compGridX < this.gridWidth) {
this.grid[compGridY][compGridX] = 1;
}
return;
}
const { x: compSchematicX, y: compSchematicY, rotation = 0 } = component.sch;
const { widthMM, heightMM, minX: symMinX, minY: symMinY } = symbolDef.dimensionsMM;
if (rotation === 0) {
const bboxSchematicTopLeftX = compSchematicX + symMinX;
const bboxSchematicTopLeftY = compSchematicY + symMinY;
const bboxSchematicBottomRightX = bboxSchematicTopLeftX + widthMM;
const bboxSchematicBottomRightY = bboxSchematicTopLeftY + heightMM;
const startGridX = Math.floor(bboxSchematicTopLeftX / GRID_UNIT_MM) + GRID_OFFSET_X;
const startGridY = Math.floor(bboxSchematicTopLeftY / GRID_UNIT_MM) + GRID_OFFSET_Y;
const endGridX = Math.ceil(bboxSchematicBottomRightX / GRID_UNIT_MM) + GRID_OFFSET_X;
const endGridY = Math.ceil(bboxSchematicBottomRightY / GRID_UNIT_MM) + GRID_OFFSET_Y;
for (let gy = startGridY; gy < endGridY; gy++) {
for (let gx = startGridX; gx < endGridX; gx++) {
if (gy >= 0 && gy < this.gridHeight && gx >= 0 && gx < this.gridWidth) {
this.grid[gy][gx] = 1;
}
}
}
} else {
const isNinetyDegreeRotation = rotation === 90 || rotation === 270;
const effectiveWidthMM = isNinetyDegreeRotation ? heightMM : widthMM;
const effectiveHeightMM = isNinetyDegreeRotation ? widthMM : heightMM;
const originGridX = Math.round(compSchematicX / GRID_UNIT_MM);
const originGridY = Math.round(compSchematicY / GRID_UNIT_MM);
const halfWidthGridCells = Math.max(1, Math.ceil((effectiveWidthMM / GRID_UNIT_MM) / 2));
const halfHeightGridCells = Math.max(1, Math.ceil((effectiveHeightMM / GRID_UNIT_MM) / 2));
for (let dy = -halfHeightGridCells + 1; dy <= halfHeightGridCells -1; dy++) {
for (let dx = -halfWidthGridCells + 1; dx <= halfWidthGridCells -1; dx++) {
const currentGridX = originGridX + dx;
const currentGridY = originGridY + dy;
if (currentGridY >= 0 && currentGridY < this.gridHeight &&
currentGridX >= 0 && currentGridX < this.gridWidth) {
this.grid[currentGridY][currentGridX] = 1;
}
}
}
}
}
update(component: Component, S: any): string | null {
let component_props: any[] = [];
if (!component) {
console.error("Invalid component: null or undefined");
return null;
}
if (component.dnp === true) {
return null;
}
if (!component.symbol) {
console.error(`Component ${component.reference || '(no reference)'} has no symbol defined`);
return null;
}
// Check if this component has already been processed
if (component.reference && this.processedComponents.has(component.reference)) {
return null;
}
// Mark this component as processed
if (component.reference) {
this.processedComponents.add(component.reference);
}
const symbolDef = this.symbolManager.getSymbolDefinition(component.symbol);
if (!symbolDef) {
console.error(`Symbol ${component.symbol} not found for component ${component.reference}`);
return null;
}
// Track whether x and y were explicitly provided by user
// Treat 0,0 as "not provided" since that's the default value
const userProvidedX = component.sch && component.sch.x !== undefined && component.sch.x !== null && component.sch.x !== 0;
const userProvidedY = component.sch && component.sch.y !== undefined && component.sch.y !== null && component.sch.y !== 0;
if (!component.sch) {
component.sch = { x: 0, y: 0, rotation: 0 };
} else {
if (component.sch.x == null) component.sch.x = 0;
if (component.sch.y == null) component.sch.y = 0;
if (component.sch.rotation == null) component.sch.rotation = 0;
}
// Only use random placement if x and y were not explicitly provided by user
if (!userProvidedX && !userProvidedY && symbolDef.dimensionsMM) {
let foundPlacement = false;
const maxPlacementAttempts = 100;
const currentRotation = component.sch.rotation ?? 0;
for (let attempt = 0; attempt < maxPlacementAttempts; attempt++) {
const randXGrid = Math.floor(Math.random() * this.gridWidth);
const randYGrid = Math.floor(Math.random() * this.gridHeight);
const potentialSchX = randXGrid * GRID_UNIT_MM;
const potentialSchY = randYGrid * GRID_UNIT_MM;
const snappedPotentialSchX = GRID_UNIT_MM * Math.round(potentialSchX / GRID_UNIT_MM);
const snappedPotentialSchY = GRID_UNIT_MM * Math.round(potentialSchY / GRID_UNIT_MM);
if (this.isAreaClear(snappedPotentialSchX, snappedPotentialSchY, currentRotation, symbolDef.dimensionsMM)) {
component.sch.x = snappedPotentialSchX;
component.sch.y = snappedPotentialSchY;
foundPlacement = true;
break;
}
}
if (!foundPlacement) {
console.error(`Could not find placement for ${component.reference || component.symbol}`);
}
} else if (!userProvidedX && !userProvidedY && !symbolDef.dimensionsMM) {
const randXGrid = Math.floor(Math.random() * this.gridWidth);
const randYGrid = Math.floor(Math.random() * this.gridHeight);
component.sch.x = randXGrid * GRID_UNIT_MM;
component.sch.y = randYGrid * GRID_UNIT_MM;
}
// Check if this symbol definition is already in lib_symbols by comparing the symbol name more precisely
const symbolName = component.symbol;
const symbolPattern = new RegExp(`\\(symbol\\s+"${symbolName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"`, 'g');
const isAlreadyIncluded = this.lib_symbols.some(libSymbol => symbolPattern.test(libSymbol));
if (!isAlreadyIncluded) {
this.lib_symbols.push(symbolDef.serializedLibEntry);
}
component_props.push([`lib_id "${component.symbol}"`]);
// Apply different coordinate calculation based on whether coordinates were user-provided
let x, y;
if (userProvidedX && userProvidedY) {
// Both coordinates were explicitly provided by user - use them directly with just grid snapping
x = GRID_UNIT_MM * Math.round(component.sch.x / GRID_UNIT_MM);
y = GRID_UNIT_MM * Math.round(component.sch.y / GRID_UNIT_MM);
} else if (userProvidedX && !userProvidedY) {
// Only X was provided - use direct X, but apply offset to Y
x = GRID_UNIT_MM * Math.round(component.sch.x / GRID_UNIT_MM);
y = GRID_UNIT_MM * (Math.round(component.sch.y / GRID_UNIT_MM) + GRID_OFFSET_Y);
} else if (!userProvidedX && userProvidedY) {
// Only Y was provided - apply offset to X, use direct Y
x = GRID_UNIT_MM * (Math.round(component.sch.x / GRID_UNIT_MM) + GRID_OFFSET_X);
y = GRID_UNIT_MM * Math.round(component.sch.y / GRID_UNIT_MM);
} else {
// Neither coordinate was provided (random placement) - apply offset to avoid sheet edges
x = GRID_UNIT_MM * (Math.round(component.sch.x / GRID_UNIT_MM) + GRID_OFFSET_X);
y = GRID_UNIT_MM * (Math.round(component.sch.y / GRID_UNIT_MM) + GRID_OFFSET_Y);
}
component.sch.x = x;
component.sch.y = y;
this.markComponentAreaUnwalkable(component, symbolDef);
component_props.push([`at ${x} ${y} ${component.sch.rotation}`]);
component_props.push([`unit 1`]);
component_props.push([`exclude_from_sim no`]);
component_props.push([`in_bom yes`]);
component_props.push([`on_board yes`]);
component_props.push([`dnp no`]);
component_props.push([`fields_autoplaced yes`]);
component_props.push([`uuid "${randomUUID()}"`]);
const propertiesToAdd = ['Reference', 'Value', 'Footprint'];
const textOffsetY = 5.08;
propertiesToAdd.forEach((propName) => {
const key = propName.toLowerCase() as keyof Component;
const propVal = component[key];
if (propVal !== undefined && (typeof propVal === "string" || typeof propVal === "number")) {
const propX = x;
const propY = y + textOffsetY;
component_props.push([
`property "${propName}" "${propVal}"`,
[`at ${propX} ${propY} ${component.sch?.rotation ?? 0}`],
[`effects`, [`font`, [`size 1.27 1.27`]], propName === 'Reference' ? [`hide no`] : [`hide yes`]],
]);
}
});
// Add pin UUIDs for each pin in the symbol definition
if (symbolDef) {
const pinNumbers = this.extractPinNumbersFromSymbol(symbolDef.rawSexpr);
for (const pinNumber of pinNumbers) {
component_props.push([
`pin "${pinNumber}"`,
[`uuid "${randomUUID()}"`]
]);
}
}
if (component.reference) {
component_props.push([
`instances`,
[`project "${this.sheetname}"`, [
`path "/${this.uuid}" (reference "${component.reference}") (unit 1)`
]],
]);
}
let s_expr_str = S.serialize(component_props, { includingRootParentheses: false });
s_expr_str = `(symbol ${s_expr_str})`;
// Format the symbol instance with proper indentation
s_expr_str = this.formatSymbolInstance(s_expr_str);
return s_expr_str;
}
getAbsolutePinCoordinates(component: Component, pin: Pin): [number, number] | null {
if (!component || !component.symbol || !component.sch || pin === undefined || pin.number === undefined) {
return null;
}
const pinLocation = this.symbolManager.getPinLocation(component.symbol, pin.number);
if (!pinLocation) {
return null;
}
let { x: relX, y: relY } = pinLocation;
const rotation = component.sch.rotation ?? 0;
const compX = component.sch.x;
const compY = component.sch.y;
let absX = compX;
let absY = compY;
const angleRad = rotation * Math.PI / 180;
const cosA = Math.cos(angleRad);
const sinA = Math.sin(angleRad);
const rotatedX = relX * cosA - relY * sinA;
const rotatedY = relX * sinA + relY * cosA;
absX = compX + rotatedX;
absY = compY - rotatedY;
return [absX, absY];
}
async net(node: Node): Promise<void> {
if (node.nodes.length === 0) {
return;
}
const label_compiler = Handlebars.compile(label_template, { noEscape: true });
const netName = node.name.replaceAll(' ', '_');
for (const pinDef of node.nodes) {
if (!pinDef.owner || pinDef.owner.dnp === true) {
continue;
}
// Skip no_connect pins - they don't need net labels
if (pinDef.type === 'no_connect') {
continue;
}
const absCoords = this.getAbsolutePinCoordinates(pinDef.owner!, pinDef);
if (!absCoords) {
continue;
}
let labelX = absCoords[0];
let labelY = absCoords[1];
let label_rotation = 0;
let justifyHorizontal = 'left';
let justifyVertical = 'bottom';
const pinLocation = this.symbolManager.getPinLocation(pinDef.owner!.symbol!, pinDef.number);
if (pinLocation) {
let pinSymbolAngle = pinLocation.angle;
const compRotation = pinDef.owner?.sch?.rotation ?? 0;
let effectivePinSymbolAngle = pinSymbolAngle;
if (compRotation === 90) {
if (pinSymbolAngle === 0) effectivePinSymbolAngle = 90;
else if (pinSymbolAngle === 90) effectivePinSymbolAngle = 180;
else if (pinSymbolAngle === 180) effectivePinSymbolAngle = 270;
else if (pinSymbolAngle === 270) effectivePinSymbolAngle = 0;
} else if (compRotation === 180) {
if (pinSymbolAngle === 0) effectivePinSymbolAngle = 180;
else if (pinSymbolAngle === 90) effectivePinSymbolAngle = 270;
else if (pinSymbolAngle === 180) effectivePinSymbolAngle = 0;
else if (pinSymbolAngle === 270) effectivePinSymbolAngle = 90;
} else if (compRotation === 270) {
if (pinSymbolAngle === 0) effectivePinSymbolAngle = 270;
else if (pinSymbolAngle === 90) effectivePinSymbolAngle = 0;
else if (pinSymbolAngle === 180) effectivePinSymbolAngle = 90;
else if (pinSymbolAngle === 270) effectivePinSymbolAngle = 180;
}
switch (effectivePinSymbolAngle) {
case 0:
label_rotation = 180;
justifyHorizontal = 'right';
justifyVertical = 'bottom';
break;
case 90:
label_rotation = 270;
justifyHorizontal = 'right';
justifyVertical = 'bottom';
break;
case 180:
label_rotation = 0;
justifyHorizontal = 'left';
justifyVertical = 'bottom';
break;
case 270:
label_rotation = 90;
justifyHorizontal = 'left';
justifyVertical = 'bottom';
break;
}
}
this.labels.push(label_compiler({
net_name: netName,
x: labelX,
y: labelY,
label_rotation: label_rotation,
justify_horizontal: justifyHorizontal,
justify_vertical: justifyVertical,
uuid: randomUUID()
}));
}
}
processNoConnectPins(components: Component[]): void {
for (const component of components) {
if (component.dnp === true || !component.symbol || !component.pins) {
continue;
}
for (const pin of component.pins) {
if (pin.type === 'no_connect') {
const absCoords = this.getAbsolutePinCoordinates(component, pin);
if (absCoords) {
const [x, y] = absCoords;
const noConnectSymbol = `\t(no_connect (at ${x} ${y}) (uuid "${randomUUID()}"))`;
this.no_connects.push(noConnectSymbol);
}
}
}
}
}
private formatSymbolInstance(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 + 1); // +1 for symbol level
}
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 + 1);
}
} else if (char === ' ' && sexpr[i + 1] === '(') {
// Space before opening paren - add newline and indent
formatted += '\n' + '\t'.repeat(indentLevel + 1);
} else {
formatted += char;
}
} else {
formatted += char;
}
i++;
}
return formatted;
}
private extractPinNumbersFromSymbol(rawSexpr: any[]): string[] {
const pinNumbers: string[] = [];
function searchForPins(element: any) {
if (!Array.isArray(element)) return;
// Direct pin definition: (pin type name (at x y a) ... (number "N" ...))
if (element[0] === 'pin') {
for (const prop of element) {
if (Array.isArray(prop) && prop[0] === 'number' && prop.length > 1) {
const pinNumber = String(prop[1]).replace(/["`]/g, '');
if (!pinNumbers.includes(pinNumber)) {
pinNumbers.push(pinNumber);
}
break;
}
}
}
// Nested symbol structure: (symbol "NAME_1_1" (pin ...) (pin ...))
else if (element[0] === 'symbol' && typeof element[1] === 'string') {
for (let i = 2; i < element.length; i++) {
searchForPins(element[i]);
}
}
// Recursively search other array elements
else {
for (const subElement of element) {
if (Array.isArray(subElement)) {
searchForPins(subElement);
}
}
}
}
// Search through all elements in the symbol definition
for (const element of rawSexpr) {
searchForPins(element);
}
return pinNumbers.sort((a, b) => {
// Try to sort numerically if possible, otherwise alphabetically
const aNum = parseInt(a);
const bNum = parseInt(b);
if (!isNaN(aNum) && !isNaN(bNum)) {
return aNum - bNum;
}
return a.localeCompare(b);
});
}
public generateGridDebugView(): string {
let debugView = "Grid Debug View:\n";
debugView += `Dimensions: ${this.gridWidth}x${this.gridHeight}\n`;
debugView += "Legend: . = Walkable (0), # = Occupied (1) by component\n";
// Add a top border/header for column numbers
let header = " "; // Space for row numbers
for (let x = 0; x < this.gridWidth; x++) {
header += (x % 10 === 0) ? Math.floor(x / 10).toString() : " ";
}
debugView += header + "\n";
header = " ";
for (let x = 0; x < this.gridWidth; x++) {
header += (x % 10).toString();
}
debugView += header + "\n";
for (let y = 0; y < this.gridHeight; y++) {
let rowStr = y.toString().padStart(2, "0") + " "; // Row number
for (let x = 0; x < this.gridWidth; x++) {
if (this.grid[y] && this.grid[y][x] !== undefined) {
rowStr += this.grid[y][x] === 0 ? "." : "#";
}
else {
rowStr += "?"; // Should not happen if grid is initialized properly
}
}
debugView += rowStr + "\n";
}
return debugView;
}
}