UNPKG

@typecad/schematic

Version:

Generate KiCAD schematics from your typeCAD project

610 lines (531 loc) 25.9 kB
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; } }