UNPKG

@typecad/typecad-gitdiff

Version:

See differences between two KiCAD PCB files

631 lines (570 loc) 24.2 kB
#!/usr/bin/env tsx import sharp from 'sharp'; import * as fs from 'fs'; import * as path from 'path'; import { parse } from 'svg-parser'; import { PNG } from 'pngjs'; import open from 'open'; import tempDir from 'temp-dir'; // Import the temp-dir package import { KiCAD, kicad_cli_path, kicad_path } from './kicad'; import { exec } from "node:child_process"; import yoctoSpinner from 'yocto-spinner'; // Function to parse SVG dimensions function parseSvgDimensions(svgPath: string): { width: number; height: number } { const svgContent = fs.readFileSync(svgPath, 'utf-8'); const parsedSvg = parse(svgContent); const svgElement = parsedSvg.children[0] as any; const svgAttributes = svgElement?.properties || {}; const widthAttr = svgAttributes.width; const heightAttr = svgAttributes.height; const viewBox = svgAttributes.viewBox; let width = 0; let height = 0; if (typeof widthAttr === 'string') { width = parseFloat(widthAttr.replace(/px$/, '')) || 0; } if (typeof heightAttr === 'string') { height = parseFloat(heightAttr.replace(/px$/, '')) || 0; } if ((!width || !height) && typeof viewBox === 'string') { const [_, __, vbWidth, vbHeight] = viewBox.split(/\s+/).map(Number); width = width || vbWidth; height = height || vbHeight; } if (!width || !height) { console.warn(`No dimensions found in SVG. Using default size: 800x600.`); width = 800; height = 600; } return { width: Math.round(width), height: Math.round(height) }; } async function createBinaryMaskFromSharp(imageBuffer: Buffer): Promise<Uint8Array> { const { data, info } = await sharp(imageBuffer) .raw() .ensureAlpha() .toBuffer({ resolveWithObject: true }); const { width, height } = info; const mask = new Uint8Array(width * height); for (let i = 0; i < data.length; i += 4) { const alpha = data[i + 3]; mask[i / 4] = alpha > 0 ? 1 : 0; } return mask; } function compareBinaryMasks( originalPng: PNG, mask1: Uint8Array, mask2: Uint8Array, width: number, height: number ): PNG { const diff = new PNG({ width, height }); for (let y = 0; y < height; y++) { const rowStart = y * width; const rowEnd = rowStart + width; // Check if the entire row is identical let rowChanged = false; for (let x = rowStart; x < rowEnd; x++) { if (mask1[x] !== mask2[x]) { rowChanged = true; break; } } if (!rowChanged) { // Copy the entire row as semi-transparent for (let x = 0; x < width; x++) { const idx = (y * width + x) << 2; diff.data[idx] = originalPng.data[idx]; diff.data[idx + 1] = originalPng.data[idx + 1]; diff.data[idx + 2] = originalPng.data[idx + 2]; diff.data[idx + 3] = 127; // Semi-transparent } } else { // Compare pixel-by-pixel for this row for (let x = 0; x < width; x++) { const idx = (y * width + x) << 2; const mask1Value = mask1[rowStart + x]; const mask2Value = mask2[rowStart + x]; if (mask1Value === 1 && mask2Value === 0) { // Pixel was present in mask1 but not in mask2 (removed) diff.data[idx] = 255; // Red diff.data[idx + 1] = 0; // Green diff.data[idx + 2] = 0; // Blue diff.data[idx + 3] = 255; // Alpha } else if (mask1Value === 0 && mask2Value === 1) { // Pixel was present in mask2 but not in mask1 (added) diff.data[idx] = 0; // Red diff.data[idx + 1] = 255; // Green diff.data[idx + 2] = 0; // Blue diff.data[idx + 3] = 255; // Alpha } else { // No change, copy original image with semi-transparency diff.data[idx] = originalPng.data[idx]; diff.data[idx + 1] = originalPng.data[idx + 1]; diff.data[idx + 2] = originalPng.data[idx + 2]; diff.data[idx + 3] = 127; // Semi-transparent } } } } return diff; } function areMasksIdentical(mask1: Uint8Array, mask2: Uint8Array): boolean { if (mask1.length !== mask2.length) return false; for (let i = 0; i < mask1.length; i++) { if (mask1[i] !== mask2[i]) return false; } return true; } async function convertSvgToPngBuffer(svgPath: string): Promise<Buffer> { const { width, height } = parseSvgDimensions(svgPath); // Define the target width (1000px) const targetWidth = 1000; // Calculate the scaling factor to maintain the aspect ratio const scaleFactor = targetWidth / width; const targetHeight = Math.round(height * scaleFactor); // Resize the SVG to the target dimensions const pngBuffer = await sharp(svgPath) .resize(targetWidth, targetHeight) // Resize to 1000px wide while maintaining aspect ratio .png() .toBuffer(); return pngBuffer; } function compareColorImages(image1: PNG, image2: PNG): PNG { const width = image1.width; const height = image1.height; // Ensure both images have the same dimensions if (width !== image2.width || height !== image2.height) { throw new Error("Images must have the same dimensions for comparison."); } const diff = new PNG({ width, height }); // Define tolerance thresholds for color and alpha const colorThreshold = 30; // Allow small color differences (0-255 range) const alphaThreshold = 30; // Allow small alpha differences (0-255 range) for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { const idx = (y * width + x) << 2; const r1 = image1.data[idx]; const g1 = image1.data[idx + 1]; const b1 = image1.data[idx + 2]; const a1 = image1.data[idx + 3]; const r2 = image2.data[idx]; const g2 = image2.data[idx + 1]; const b2 = image2.data[idx + 2]; const a2 = image2.data[idx + 3]; // Calculate the absolute differences for each channel const colorDiff = Math.abs(r1 - r2) + Math.abs(g1 - g2) + Math.abs(b1 - b2); const alphaDiff = Math.abs(a1 - a2); if (colorDiff <= colorThreshold && alphaDiff <= alphaThreshold) { // No significant change, copy original pixel with semi-transparency diff.data[idx] = r1; diff.data[idx + 1] = g1; diff.data[idx + 2] = b1; diff.data[idx + 3] = 127; // Semi-transparent } else { // Significant change detected, highlight in red, green, or yellow // Removed feature diff.data[idx] = 255; // Red diff.data[idx + 1] = 255; // Green diff.data[idx + 2] = 0; // Blue diff.data[idx + 3] = 255; // Alpha } } } return diff; } const layers = [ "F.Cu", "F.Adhesive", "F.Paste", "F.Silkscreen", "F.Mask", "F.Courtyard", "F.Fab", "B.Cu", "B.Adhesive", "B.Paste", "B.Silkscreen", "B.Mask", "B.Courtyard", "B.Fab", "User.1", "User.2", "User.3", "User.4", "User.5", "User.6", "User.7", "User.8", "User.9", "Edge.Cuts", "User.Drawings", "User.Comments", "User.Eco1", "User.Eco2", "Margin" ]; (async () => { const tempFiles: string[] = []; // Track all temporary files for cleanup const htmlLayers: { name: string; originalImage: string | null, diffImage: string | null, modifiedImage: string | null }[] = []; console.log("🔃 typeCAD gitdiff starting..."); if (process.argv.length < 3) { console.error("Usage: typecad-gitdiff <original.kicad_pcb> <modified.kicad_pcb>"); console.log("typeCAD-gitdiff version 1.0.0"); process.exit(1); } const spinner = yoctoSpinner({text: 'processing files...', spinner: {frames: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'], interval: 100}, color: 'green'}).start(); try { new KiCAD(); // Export SVGs for both boards to temporary files in parallel const exportPromises = layers.flatMap(layer => { const tempSvgPath1 = path.join(tempDir, `1_${layer}.svg`); const tempSvgPath2 = path.join(tempDir, `2_${layer}.svg`); tempFiles.push(tempSvgPath1, tempSvgPath2); return [ new Promise<void>((resolve, reject) => { exec(`"${kicad_cli_path}" pcb export svg --exclude-drawing-sheet --page-size-mode 2 --layers "${layer}" --output "${tempSvgPath1}" ${process.argv[2]}`, (err) => { if (err) reject(err); else resolve(); }); }), new Promise<void>((resolve, reject) => { exec(`"${kicad_cli_path}" pcb export svg --exclude-drawing-sheet --page-size-mode 2 --layers "${layer}" --output "${tempSvgPath2}" ${process.argv[3]}`, (err) => { if (err) reject(err); else resolve(); }); }) ]; }); await Promise.all(exportPromises); // Generate paths for all SVGs const svgPaths1 = layers.map(layer => path.join(tempDir, `1_${layer}.svg`)); const svgPaths2 = layers.map(layer => path.join(tempDir, `2_${layer}.svg`)); // Convert all SVGs to PNG buffers in parallel const [pngBuffers1, pngBuffers2] = await Promise.all([ Promise.all(svgPaths1.map(svgPath => convertSvgToPngBuffer(svgPath))), Promise.all(svgPaths2.map(svgPath => convertSvgToPngBuffer(svgPath))) ]); // Render PNG files for "Render" layer const renderPngPath1 = path.join(tempDir, '1_render.png'); const renderPngPath2 = path.join(tempDir, '2_render.png'); tempFiles.push(renderPngPath1, renderPngPath2); // Execute rendering commands for "Render" layer await new Promise<void>((resolve, reject) => { exec(`"${kicad_cli_path}" pcb render --output "${renderPngPath1}" ${process.argv[2]}`, (err) => { if (err) reject(err); else resolve(); }); }); await new Promise<void>((resolve, reject) => { exec(`"${kicad_cli_path}" pcb render --output "${renderPngPath2}" ${process.argv[3]}`, (err) => { if (err) reject(err); else resolve(); }); }); // Read the rendered PNG files into buffers const renderPngBuffer1 = fs.readFileSync(renderPngPath1); const renderPngBuffer2 = fs.readFileSync(renderPngPath2); // Add "Render" layer to the layers array layers.push("Render"); // Process layers in parallel // Process layers in parallel await Promise.all(layers.map(async (layer, index) => { let originalPng1, pngBuffer1, pngBuffer2; if (layer === "Render") { // Use the rendered PNG buffers for the "Render" layer originalPng1 = PNG.sync.read(renderPngBuffer1); pngBuffer1 = renderPngBuffer1; pngBuffer2 = renderPngBuffer2; } else { // Use the SVG-derived PNG buffers for other layers originalPng1 = PNG.sync.read(pngBuffers1[index]); pngBuffer1 = pngBuffers1[index]; pngBuffer2 = pngBuffers2[index]; } // Convert original images to Base64 const originalImageBase64 = pngBuffer1.toString('base64'); const originalImageSrc = `data:image/png;base64,${originalImageBase64}`; const modifiedImageBase64 = pngBuffer2.toString('base64'); const modifiedImageSrc = `data:image/png;base64,${modifiedImageBase64}`; if (layer === "Render") { // Compare the two PNGs directly for the "Render" layer const diff = compareColorImages(originalPng1, PNG.sync.read(pngBuffer2)); const diffBuffer = PNG.sync.write(diff); // Get the buffer of the difference image // Convert the buffer to a Base64-encoded string const diffImageBase64 = diffBuffer.toString('base64'); const diffImageSrc = `data:image/png;base64,${diffImageBase64}`; htmlLayers.push({ name: layer, originalImage: originalImageSrc, diffImage: diffImageSrc, modifiedImage: modifiedImageSrc }); } else { // Create binary masks and compare them for other layers const [mask1, mask2] = await Promise.all([ createBinaryMaskFromSharp(pngBuffer1), createBinaryMaskFromSharp(pngBuffer2) ]); if (areMasksIdentical(mask1, mask2)) { htmlLayers.push({ name: layer, originalImage: originalImageSrc, diffImage: null, modifiedImage: modifiedImageSrc }); return; } const diff = compareBinaryMasks(originalPng1, mask1, mask2, originalPng1.width, originalPng1.height); const diffBuffer = PNG.sync.write(diff); // Get the buffer of the difference image // Convert the buffer to a Base64-encoded string const diffImageBase64 = diffBuffer.toString('base64'); const diffImageSrc = `data:image/png;base64,${diffImageBase64}`; htmlLayers.push({ name: layer, originalImage: originalImageSrc, diffImage: diffImageSrc, modifiedImage: modifiedImageSrc }); } })); // Generate HTML file with embedded images and enhanced styling const htmlContent = ` <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Layer Comparison Results</title> <style> /* General Styling */ body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; margin: 0; padding: 0; background: linear-gradient(135deg, #f5f7fa, #c3cfe2); color: #333; line-height: 1.6; } h1 { text-align: center; font-size: 2.25em; font-family: Inter VF, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji" font-weight: 800; color: #111827; margin-top: 20px; margin-bottom: 20px; text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.1); } .container { max-width: 1200px; margin: 0 auto; padding: 20px; } .layer-container { background: #fff; border-radius: 8px; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); margin-bottom: 20px; padding: 15px; transition: transform 0.3s ease, box-shadow 0.3s ease; } .layer-container:hover { transform: translateY(-5px); box-shadow: 0 6px 12px rgba(0, 0, 0, 0.15); } .layer-title { font-size: 1.2em; font-weight: bold; color: #2980b9; margin-bottom: 10px; } .image-grid { display: flex; gap: 15px; justify-content: center; align-items: center; } .image-wrapper { position: relative; display: inline-block; background-color: #fff; /* White background for transparency */ border-radius: 4px; overflow: hidden; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); } .image-grid img { max-width: 100%; height: auto; display: block; cursor: pointer; transition: transform 0.3s ease; background: gray } .image-grid img:hover { transform: scale(1.05); } .image-caption { text-align: center; font-size: 0.9em; color: #555; margin-top: 5px; } .no-changes { color: #888; font-style: italic; text-align: center; } /* Lightbox Styling */ .lightbox { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.8); display: none; justify-content: center; align-items: center; z-index: 1000; } .lightbox img { max-width: 90%; max-height: 90%; border-radius: 8px; box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); background: white; } .lightbox.active { display: flex; } /* Legend Styling */ .legend { margin-top: 40px; padding: 20px; background: #fff; border-radius: 8px; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); font-size: 1em; line-height: 1.5; max-width: 800px; margin-left: auto; margin-right: auto; } .legend h2 { text-align: center; font-size: 1.5em; color: #2c3e50; margin-bottom: 15px; } .legend div { display: flex; align-items: center; margin-bottom: 10px; } .legend span.color-box { width: 20px; height: 20px; margin-right: 15px; border-radius: 2px; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); } .red { background-color: #ff4d4d; } .green { background-color: #4caf50; } .yellow { background-color:rgb(255, 255, 0); } .legend span.description { flex-grow: 1; color: #333; } /* Footer */ footer { text-align: center; margin-top: 40px; padding: 10px; font-size: 0.9em; color: #888; } footer a { color: #2980b9; text-decoration: none; } footer a:hover { text-decoration: underline; } </style> </head> <body> <div class="container"> <h1>Layer Comparison Results</h1> <div id="results"> ${htmlLayers.map(layer => ` <div class="layer-container"> <div class="layer-title">${layer.name}</div> ${layer.diffImage ? ` <div class="image-grid"> <div class="image-wrapper"> <img src="${layer.originalImage}" alt="Original Image" class="clickable-image" data-src="${layer.originalImage}"> <div class="image-caption">Original</div> </div> <div class="image-wrapper"> <img src="${layer.diffImage}" alt="Difference Image" class="clickable-image" data-src="${layer.diffImage}"> <div class="image-caption">Difference</div> </div> <div class="image-wrapper"> <img src="${layer.modifiedImage}" alt="Modified Image" class="clickable-image" data-src="${layer.modifiedImage}"> <div class="image-caption">Modified</div> </div> </div> ` : `<div class="no-changes">No changes detected.</div>`} </div> `).join('')} </div> <!-- Lightbox --> <div class="lightbox" id="lightbox"> <img src="" alt="Expanded Image" id="lightbox-image"> </div> <!-- Legend Section --> <div class="legend"> <h2>Legend</h2> <div> <span class="color-box red"></span> <span class="description">Features removed</span> </div> <div> <span class="color-box green"></span> <span class="description">Features added</span> </div> <div> <span class="color-box yellow"></span> <span class="description">Render features modified</span> </div> </div> <!-- Footer --> <footer> Generated by <a href="https://typecad.net" target="_blank">typecad.net</a> </footer> </div> <script> // Lightbox functionality document.addEventListener('DOMContentLoaded', () => { const lightbox = document.getElementById('lightbox'); const lightboxImage = document.getElementById('lightbox-image'); // Open lightbox when an image is clicked document.querySelectorAll('.clickable-image').forEach(img => { img.addEventListener('click', () => { lightboxImage.src = img.dataset.src; lightbox.classList.add('active'); }); }); // Close lightbox when clicking outside the image or pressing Esc lightbox.addEventListener('click', (e) => { if (e.target === lightbox || e.target === lightboxImage) { lightbox.classList.remove('active'); } }); document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { lightbox.classList.remove('active'); } }); }); </script> </body> </html> `; const htmlFilePath = path.join(tempDir, 'results.html'); fs.writeFileSync(htmlFilePath, htmlContent); await open(htmlFilePath); // Open the HTML file in the default browser } catch (error) { console.error('Error:', error); } finally { // Clean up all temporary files spinner.success('Finished'); for (const filePath of tempFiles) { try { if (fs.existsSync(filePath)) { fs.unlinkSync(filePath); } } catch (cleanupError) { console.error(`Failed to delete temporary file: ${filePath}`, cleanupError); } } } })();