diff --git a/app.py b/app.py index 74c475c..aef8ed7 100644 --- a/app.py +++ b/app.py @@ -7,22 +7,32 @@ import zipfile import tempfile import shutil import json +import re +import math from pathlib import Path +from datetime import datetime from flask import Flask, render_template, request, send_file, jsonify from flask_socketio import SocketIO, emit import time -from PyPDF2 import PdfMerger +from PyPDF2 import PdfMerger, PdfReader, PdfWriter +from reportlab.pdfgen import canvas +from reportlab.lib.pagesizes import letter from variant_manager import VariantManager import pyodbc +from io import BytesIO app = Flask(__name__) app.config['SECRET_KEY'] = 'secret!' -socketio = SocketIO(app, cors_allowed_origins="*") +socketio = SocketIO(app, + cors_allowed_origins="*", + ping_timeout=120, # 2 minutes timeout + ping_interval=25) # Send ping every 25 seconds # Store arguments app_args = {} connected_clients = set() heartbeat_timeout = 5 # seconds +shutdown_timer = None # Track shutdown timer to cancel if client reconnects # Configuration config_file = 'config.json' @@ -45,6 +55,543 @@ def save_config(): with open(config_file, 'w') as f: json.dump(app_config, f, indent=2) +def add_text_overlay_to_pdf(input_pdf_path, output_pdf_path, text, x=50, y=750, font_size=14): + """Add text overlay to upper left corner of PDF""" + # Read the existing PDF + reader = PdfReader(input_pdf_path) + writer = PdfWriter() + + # Create text overlay + packet = BytesIO() + can = canvas.Canvas(packet, pagesize=letter) + can.setFont("Helvetica-Bold", font_size) + can.drawString(x, y, text) + can.save() + + # Move to the beginning of the BytesIO buffer + packet.seek(0) + overlay_pdf = PdfReader(packet) + + # Merge overlay with each page + for page in reader.pages: + page.merge_page(overlay_pdf.pages[0]) + writer.add_page(page) + + # Write output + with open(output_pdf_path, 'wb') as output_file: + writer.write(output_file) + +# --------------------------------------------------------------------------- +# KiCad Library Functions +# --------------------------------------------------------------------------- + +def get_kicad_lib_path(): + """Get the KiCad library path from UM_KICAD environment variable.""" + um_kicad = os.environ.get('UM_KICAD') + if not um_kicad: + return None + return os.path.join(um_kicad, 'lib') + +def get_symbol_libraries(): + """Get list of all symbol library files.""" + lib_path = get_kicad_lib_path() + if not lib_path: + return [] + + symbols_dir = os.path.join(lib_path, 'symbols') + if not os.path.exists(symbols_dir): + return [] + + libraries = [] + for filename in os.listdir(symbols_dir): + if filename.endswith('.kicad_sym'): + libraries.append({ + 'name': filename, + 'path': os.path.join(symbols_dir, filename) + }) + + return sorted(libraries, key=lambda x: x['name']) + +def get_footprint_libraries(): + """Get list of all footprint libraries (.pretty directories).""" + lib_path = get_kicad_lib_path() + if not lib_path: + return [] + + footprints_dir = os.path.join(lib_path, 'footprints') + if not os.path.exists(footprints_dir): + return [] + + libraries = [] + for item in os.listdir(footprints_dir): + item_path = os.path.join(footprints_dir, item) + if os.path.isdir(item_path) and item.endswith('.pretty'): + libraries.append({ + 'name': item, + 'path': item_path + }) + + return sorted(libraries, key=lambda x: x['name']) + +def parse_kicad_symbol_file(file_path): + """Parse a KiCad symbol library file and extract symbol names.""" + symbols = [] + + try: + with open(file_path, 'r', encoding='utf-8') as f: + content = f.read() + + # Find all symbols (top-level only, not sub-symbols) + symbol_pattern = r'^\t\(symbol\s+"([^"]+)"' + symbol_names = re.findall(symbol_pattern, content, re.MULTILINE) + + # Filter out sub-symbols (those with _X_Y suffix) + for symbol_name in symbol_names: + if not re.match(r'.*_\d+_\d+$', symbol_name): + symbols.append(symbol_name) + + except Exception as e: + print(f"Error parsing {file_path}: {e}") + + return sorted(symbols) + +def get_footprints_in_library(library_path): + """Get list of all footprints in a .pretty library.""" + footprints = [] + + try: + for filename in os.listdir(library_path): + if filename.endswith('.kicad_mod'): + footprint_name = filename[:-10] # Remove .kicad_mod extension + footprints.append(footprint_name) + except Exception as e: + print(f"Error reading footprints from {library_path}: {e}") + + return sorted(footprints) + +def extract_symbol_graphics(file_path, symbol_name): + """Extract graphical elements from a symbol for rendering.""" + try: + with open(file_path, 'r', encoding='utf-8') as f: + content = f.read() + + # Find the symbol definition + symbol_pattern = re.compile(r'^\t\(symbol\s+"' + re.escape(symbol_name) + r'"\s*$', re.MULTILINE) + match = symbol_pattern.search(content) + + if not match: + return None + + symbol_start = match.start() + + # Find the end of this symbol + next_symbol_pattern = re.compile(r'^\t\(symbol\s+"[^"]+"', re.MULTILINE) + next_match = next_symbol_pattern.search(content, symbol_start + len(symbol_name) + 10) + + if next_match: + symbol_block = content[symbol_start:next_match.start()] + else: + end_pattern = re.compile(r'^\)', re.MULTILINE) + end_match = end_pattern.search(content, symbol_start + 1) + if end_match: + symbol_block = content[symbol_start:end_match.start()] + else: + symbol_block = content[symbol_start:] + + # Filter out alternate De Morgan representations + symbol_block = re.sub( + r'^\t\t\(symbol\s+"' + re.escape(symbol_name) + r'_(\d+)_([2-9])".*?^\t\t\)', + '', + symbol_block, + flags=re.MULTILINE | re.DOTALL + ) + + return symbol_block + except Exception as e: + print(f"Error extracting graphics for {symbol_name}: {e}") + return None + +def render_symbol_to_svg(symbol_block): + """Convert symbol graphics to SVG.""" + if not symbol_block: + return None + + svg_elements = [] + min_x, min_y, max_x, max_y = 0, 0, 0, 0 + + # Parse polylines + for polyline in re.finditer(r'\(polyline\n(.*?)\n\t+\)', symbol_block, re.DOTALL): + polyline_text = polyline.group(0) + points = [] + + # Extract points + pts_match = re.search(r'\(pts\s+(.*?)\n\s*\)', polyline_text, re.DOTALL) + if pts_match: + pts_content = pts_match.group(1) + xy_pattern = r'\(xy\s+([-\d.]+)\s+([-\d.]+)\)' + for match in re.finditer(xy_pattern, pts_content): + x, y = float(match.group(1)), -float(match.group(2)) + points.append((x, y)) + + if points: + stroke_width = 0.254 + stroke_match = re.search(r'\(width\s+([-\d.]+)\)', polyline_text) + if stroke_match: + stroke_width = float(stroke_match.group(1)) + + fill_type = 'none' + fill_match = re.search(r'\(fill\s+\(type\s+(\w+)\)', polyline_text) + if fill_match: + fill_type = fill_match.group(1) + + path_d = f"M {points[0][0]} {points[0][1]}" + for x, y in points[1:]: + path_d += f" L {x} {y}" + + fill_color = 'none' if fill_type == 'none' else '#FFFFCC' + if fill_type == 'outline': + path_d += ' Z' + fill_color = '#FFFFCC' + + svg_elements.append(f'') + + for x, y in points: + min_x, max_x = min(min_x, x), max(max_x, x) + min_y, max_y = min(min_y, y), max(max_y, y) + + # Parse circles + for circle in re.finditer(r'\(circle\n(.*?)\n\t+\)', symbol_block, re.DOTALL): + circle_text = circle.group(0) + center_match = re.search(r'\(center\s+([-\d.]+)\s+([-\d.]+)\)', circle_text) + radius_match = re.search(r'\(radius\s+([-\d.]+)\)', circle_text) + + if center_match and radius_match: + cx, cy = float(center_match.group(1)), -float(center_match.group(2)) + radius = float(radius_match.group(1)) + + stroke_width = 0.254 + stroke_match = re.search(r'\(width\s+([-\d.]+)\)', circle_text) + if stroke_match: + stroke_width = float(stroke_match.group(1)) + + fill_type = 'none' + fill_match = re.search(r'\(fill\s+\(type\s+(\w+)\)', circle_text) + if fill_match: + fill_type = fill_match.group(1) + + fill_color = 'none' if fill_type == 'none' else '#FFFFCC' + svg_elements.append(f'') + + min_x, max_x = min(min_x, cx - radius), max(max_x, cx + radius) + min_y, max_y = min(min_y, cy - radius), max(max_y, cy + radius) + + # Parse rectangles + for rect in re.finditer(r'(\t+)\(rectangle\n.*?\n\1\)', symbol_block, re.DOTALL): + rect_text = rect.group(0) + start_match = re.search(r'\(start\s+([-\d.]+)\s+([-\d.]+)\)', rect_text) + end_match = re.search(r'\(end\s+([-\d.]+)\s+([-\d.]+)\)', rect_text) + + if start_match and end_match: + x1, y1 = float(start_match.group(1)), -float(start_match.group(2)) + x2, y2 = float(end_match.group(1)), -float(end_match.group(2)) + width = abs(x2 - x1) + height = abs(y2 - y1) + x = min(x1, x2) + y = min(y1, y2) + + stroke_width = 0.254 + stroke_match = re.search(r'\(width\s+([-\d.]+)\)', rect_text) + if stroke_match: + stroke_width = max(float(stroke_match.group(1)), 0.1) + + fill_type = 'none' + fill_match = re.search(r'\(fill\s+\(type\s+(\w+)\)', rect_text) + if fill_match: + fill_type = fill_match.group(1) + + fill_color = 'none' if fill_type == 'none' else '#FFFFCC' + svg_elements.append(f'') + + min_x, max_x = min(min_x, x), max(max_x, x + width) + min_y, max_y = min(min_y, y), max(max_y, y + height) + + # Parse text elements + for text in re.finditer(r'\(text\s+"([^"]*)".*?\n\t+\)', symbol_block, re.DOTALL): + text_str = text.group(1) + text_block = text.group(0) + at_match = re.search(r'\(at\s+([-\d.]+)\s+([-\d.]+)', text_block) + + if at_match and text_str: + x, y = float(at_match.group(1)), -float(at_match.group(2)) + size = 1.27 + size_match = re.search(r'\(size\s+([-\d.]+)', text_block) + if size_match: + size = float(size_match.group(1)) + + svg_elements.append(f'{text_str}') + + # Parse pins + for pin in re.finditer(r'(\t+)\(pin\s+\w+\s+\w+\n.*?\n\1\)', symbol_block, re.DOTALL): + pin_text = pin.group(0) + at_match = re.search(r'\(at\s+([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)\)', pin_text) + length_match = re.search(r'\(length\s+([-\d.]+)\)', pin_text) + number_match = re.search(r'\(number\s+"([^"]*)"', pin_text) + name_match = re.search(r'\(name\s+"([^"]*)"', pin_text) + + if at_match and length_match: + x, y = float(at_match.group(1)), -float(at_match.group(2)) + angle = float(at_match.group(3)) + length = float(length_match.group(1)) + number = number_match.group(1) if number_match else "" + name = name_match.group(1) if name_match else "" + + # Filter out placeholder names + if name == "~": + name = "" + + angle_rad = math.radians(angle) + x2 = x + length * math.cos(angle_rad) + y2 = y - length * math.sin(angle_rad) + + svg_elements.append(f'') + svg_elements.append(f'') + + # Pin number at the connection point (outside) + if number: + # Position number outside the pin with more offset + num_offset = 1.5 + num_x = x - math.cos(angle_rad) * num_offset + num_y = y + math.sin(angle_rad) * num_offset + # Determine text anchor based on pin direction + anchor = "middle" + if angle == 0: # Right - number on left side + anchor = "end" + num_x = x - num_offset + elif angle == 180: # Left - number on right side + anchor = "start" + num_x = x + num_offset + elif angle == 90: # Down - number on top + anchor = "middle" + num_y = y - num_offset + elif angle == 270: # Up - number on bottom + anchor = "middle" + num_y = y + num_offset + svg_elements.append(f'{number}') + + # Pin name at the inner end (inside symbol) + if name: + # Position name inside the symbol with more offset from pin end + name_offset = 1.2 + name_x = x2 - math.cos(angle_rad) * name_offset + name_y = y2 + math.sin(angle_rad) * name_offset + # Determine text anchor based on pin direction + anchor = "middle" + if angle == 0: # Right - name inside on right + anchor = "start" + name_x = x2 + name_offset + elif angle == 180: # Left - name inside on left + anchor = "end" + name_x = x2 - name_offset + elif angle == 90: # Down - name inside below + anchor = "middle" + name_y = y2 + name_offset + elif angle == 270: # Up - name inside above + anchor = "middle" + name_y = y2 - name_offset + svg_elements.append(f'{name}') + + min_x = min(min_x, x, x2) + max_x = max(max_x, x, x2) + min_y = min(min_y, y, y2) + max_y = max(max_y, y, y2) + + if not svg_elements: + return 'No preview' + + # Add padding + padding = 5 + min_x -= padding + min_y -= padding + max_x += padding + max_y += padding + + width = max_x - min_x + height = max_y - min_y + + scale = 10 + + svg = f''' + + {''.join(svg_elements)} +''' + + return svg + +def render_footprint_to_svg(file_path): + """Render a KiCad footprint to SVG.""" + try: + with open(file_path, 'r', encoding='utf-8') as f: + content = f.read() + + svg_elements = [] + min_x, min_y, max_x, max_y = 0, 0, 0, 0 + + # Parse fp_line elements (silkscreen/fab lines) + for line in re.finditer(r'\(fp_line\s+(.*?)\n\t\)', content, re.DOTALL): + line_text = line.group(1) + start_match = re.search(r'\(start\s+([-\d.]+)\s+([-\d.]+)\)', line_text) + end_match = re.search(r'\(end\s+([-\d.]+)\s+([-\d.]+)\)', line_text) + width_match = re.search(r'\(width\s+([-\d.]+)\)', line_text) + layer_match = re.search(r'\(layer\s+"([^"]+)"\)', line_text) + + if start_match and end_match: + x1, y1 = float(start_match.group(1)), float(start_match.group(2)) + x2, y2 = float(end_match.group(1)), float(end_match.group(2)) + width = float(width_match.group(1)) if width_match else 0.15 + layer = layer_match.group(1) if layer_match else "F.SilkS" + + # Color by layer + color = "#CC00CC" if "SilkS" in layer else "#888888" + + svg_elements.append(f'') + + min_x = min(min_x, x1, x2) + max_x = max(max_x, x1, x2) + min_y = min(min_y, y1, y2) + max_y = max(max_y, y1, y2) + + # Parse fp_circle elements + for circle in re.finditer(r'\(fp_circle\s+(.*?)\n\t\)', content, re.DOTALL): + circle_text = circle.group(1) + center_match = re.search(r'\(center\s+([-\d.]+)\s+([-\d.]+)\)', circle_text) + end_match = re.search(r'\(end\s+([-\d.]+)\s+([-\d.]+)\)', circle_text) + width_match = re.search(r'\(width\s+([-\d.]+)\)', circle_text) + layer_match = re.search(r'\(layer\s+"([^"]+)"\)', circle_text) + + if center_match and end_match: + cx, cy = float(center_match.group(1)), float(center_match.group(2)) + ex, ey = float(end_match.group(1)), float(end_match.group(2)) + radius = ((ex - cx)**2 + (ey - cy)**2)**0.5 + width = float(width_match.group(1)) if width_match else 0.15 + layer = layer_match.group(1) if layer_match else "F.SilkS" + + color = "#CC00CC" if "SilkS" in layer else "#888888" + + svg_elements.append(f'') + + min_x = min(min_x, cx - radius) + max_x = max(max_x, cx + radius) + min_y = min(min_y, cy - radius) + max_y = max(max_y, cy + radius) + + # Parse fp_rect elements + for rect in re.finditer(r'\(fp_rect\s+(.*?)\n\t\)', content, re.DOTALL): + rect_text = rect.group(1) + start_match = re.search(r'\(start\s+([-\d.]+)\s+([-\d.]+)\)', rect_text) + end_match = re.search(r'\(end\s+([-\d.]+)\s+([-\d.]+)\)', rect_text) + width_match = re.search(r'\(width\s+([-\d.]+)\)', rect_text) + layer_match = re.search(r'\(layer\s+"([^"]+)"\)', rect_text) + + if start_match and end_match: + x1, y1 = float(start_match.group(1)), float(start_match.group(2)) + x2, y2 = float(end_match.group(1)), float(end_match.group(2)) + width = float(width_match.group(1)) if width_match else 0.15 + layer = layer_match.group(1) if layer_match else "F.SilkS" + + color = "#CC00CC" if "SilkS" in layer else "#888888" + + w = abs(x2 - x1) + h = abs(y2 - y1) + x = min(x1, x2) + y = min(y1, y2) + + svg_elements.append(f'') + + min_x = min(min_x, x) + max_x = max(max_x, x + w) + min_y = min(min_y, y) + max_y = max(max_y, y + h) + + # Parse pads + for pad in re.finditer(r'\(pad\s+"([^"]+)"\s+(\w+)\s+(\w+)\s+(.*?)\n\t\)', content, re.DOTALL): + pad_num = pad.group(1) + pad_type = pad.group(2) # smd, thru_hole, np_thru_hole + pad_shape = pad.group(3) # rect, circle, oval, roundrect + pad_params = pad.group(4) + + at_match = re.search(r'\(at\s+([-\d.]+)\s+([-\d.]+)(?:\s+([-\d.]+))?\)', pad_params) + size_match = re.search(r'\(size\s+([-\d.]+)\s+([-\d.]+)\)', pad_params) + + if at_match and size_match: + px, py = float(at_match.group(1)), float(at_match.group(2)) + rotation = float(at_match.group(3)) if at_match.group(3) else 0 + width, height = float(size_match.group(1)), float(size_match.group(2)) + + # Color based on pad type + pad_color = "#C87533" if pad_type == "smd" else "#FFD700" + + # Create transform for rotation (only add if rotation is non-zero) + transform_attr = f' transform="rotate({rotation} {px} {py})"' if rotation != 0 else '' + + if pad_shape == "rect": + x = px - width / 2 + y = py - height / 2 + svg_elements.append(f'') + elif pad_shape == "circle": + r = width / 2 + svg_elements.append(f'') + elif pad_shape == "oval": + rx = width / 2 + ry = height / 2 + svg_elements.append(f'') + elif pad_shape == "roundrect": + # Extract roundrect_rratio if available + rratio_match = re.search(r'\(roundrect_rratio\s+([-\d.]+)\)', pad_params) + rratio = float(rratio_match.group(1)) if rratio_match else 0.25 + + # Calculate corner radius based on the smaller dimension + corner_radius = min(width, height) * rratio + + x = px - width / 2 + y = py - height / 2 + svg_elements.append(f'') + + # Add pad number + svg_elements.append(f'{pad_num}') + + min_x = min(min_x, px - width / 2) + max_x = max(max_x, px + width / 2) + min_y = min(min_y, py - height / 2) + max_y = max(max_y, py + height / 2) + + if not svg_elements: + return 'No preview' + + # Add padding (as percentage of footprint size for better scaling) + width = max_x - min_x + height = max_y - min_y + padding = max(width, height) * 0.15 # 15% padding + + min_x -= padding + min_y -= padding + max_x += padding + max_y += padding + + width = max_x - min_x + height = max_y - min_y + + # Don't set fixed width/height - let it scale to fill container while maintaining aspect ratio + svg = f''' + + {''.join(svg_elements)} +''' + + return svg + + except Exception as e: + print(f"Error rendering footprint: {e}") + return None + @app.route('/') def index(): # Reconstruct the command line that invoked this app @@ -65,18 +612,29 @@ def index(): @socketio.on('connect') def handle_connect(): + global shutdown_timer + + # Cancel shutdown timer if client reconnects + if shutdown_timer is not None: + print("Client reconnected, canceling shutdown") + shutdown_timer.cancel() + shutdown_timer = None + connected_clients.add(request.sid) print(f"Client connected: {request.sid}") @socketio.on('disconnect') def handle_disconnect(): + global shutdown_timer + connected_clients.discard(request.sid) print(f"Client disconnected: {request.sid}") - # Shutdown if no clients connected + # Shutdown if no clients connected after waiting for potential reconnect if not connected_clients: - print("No clients connected. Shutting down...") - threading.Timer(1.0, shutdown_server).start() + print("No clients connected. Waiting 10 seconds for reconnect before shutting down...") + shutdown_timer = threading.Timer(10.0, shutdown_server) + shutdown_timer.start() @socketio.on('heartbeat') def handle_heartbeat(): @@ -1191,6 +1749,8 @@ def handle_create_part(data): description = data.get('description', '').strip() class_value = data.get('class', '').strip() datasheet = data.get('datasheet', '').strip() + symbol = data.get('symbol', '').strip() + footprint = data.get('footprint', '').strip() if not ipn: emit('library_error', {'error': 'IPN is required'}) @@ -1213,9 +1773,9 @@ def handle_create_part(data): # Insert new part cursor.execute(""" - INSERT INTO parts (ipn, manufacturer, mpn, description, class, datasheet) - VALUES (?, ?, ?, ?, ?, ?) - """, (ipn, manufacturer, mpn, description, class_value, datasheet)) + INSERT INTO parts (ipn, manufacturer, mpn, description, class, datasheet, symbol, footprint) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, (ipn, manufacturer, mpn, description, class_value, datasheet, symbol, footprint)) conn.commit() cursor.close() @@ -1227,6 +1787,108 @@ def handle_create_part(data): import traceback emit('library_error', {'error': f'{str(e)}\n\nTraceback:\n{traceback.format_exc()}'}) +@socketio.on('get_symbol_libraries') +def handle_get_symbol_libraries(): + try: + libraries = get_symbol_libraries() + emit('symbol_libraries_result', {'libraries': libraries}) + except Exception as e: + import traceback + emit('library_error', {'error': f'{str(e)}\n\nTraceback:\n{traceback.format_exc()}'}) + +@socketio.on('get_symbols_in_library') +def handle_get_symbols_in_library(data): + try: + library_path = data.get('library_path', '') + if not library_path or not os.path.exists(library_path): + emit('library_error', {'error': 'Invalid library path'}) + return + + symbols = parse_kicad_symbol_file(library_path) + emit('symbols_in_library_result', {'symbols': symbols}) + except Exception as e: + import traceback + emit('library_error', {'error': f'{str(e)}\n\nTraceback:\n{traceback.format_exc()}'}) + +@socketio.on('render_symbol') +def handle_render_symbol(data): + try: + library_path = data.get('library_path', '') + symbol_name = data.get('symbol_name', '') + + if not library_path or not symbol_name: + emit('library_error', {'error': 'Missing library path or symbol name'}) + return + + if not os.path.exists(library_path): + emit('library_error', {'error': 'Library file not found'}) + return + + symbol_block = extract_symbol_graphics(library_path, symbol_name) + svg = render_symbol_to_svg(symbol_block) + + if svg: + emit('symbol_render_result', {'svg': svg, 'symbol_name': symbol_name}) + else: + emit('library_error', {'error': f'Could not render symbol {symbol_name}'}) + + except Exception as e: + import traceback + emit('library_error', {'error': f'{str(e)}\n\nTraceback:\n{traceback.format_exc()}'}) + +@socketio.on('get_footprint_libraries') +def handle_get_footprint_libraries(): + try: + libraries = get_footprint_libraries() + emit('footprint_libraries_result', {'libraries': libraries}) + except Exception as e: + import traceback + emit('library_error', {'error': f'{str(e)}\n\nTraceback:\n{traceback.format_exc()}'}) + +@socketio.on('get_footprints_in_library') +def handle_get_footprints_in_library(data): + try: + library_path = data.get('library_path', '') + if not library_path or not os.path.exists(library_path): + emit('library_error', {'error': 'Invalid library path'}) + return + + footprints = get_footprints_in_library(library_path) + emit('footprints_in_library_result', {'footprints': footprints}) + except Exception as e: + import traceback + emit('library_error', {'error': f'{str(e)}\n\nTraceback:\n{traceback.format_exc()}'}) + +@socketio.on('render_footprint') +def handle_render_footprint(data): + try: + library_path = data.get('library_path', '') + footprint_name = data.get('footprint_name', '') + + if not library_path or not footprint_name: + emit('library_error', {'error': 'Missing library path or footprint name'}) + return + + if not os.path.exists(library_path): + emit('library_error', {'error': 'Library directory not found'}) + return + + footprint_file = os.path.join(library_path, f'{footprint_name}.kicad_mod') + if not os.path.exists(footprint_file): + emit('library_error', {'error': f'Footprint file not found: {footprint_name}.kicad_mod'}) + return + + svg = render_footprint_to_svg(footprint_file) + + if svg: + emit('footprint_render_result', {'svg': svg, 'footprint_name': footprint_name}) + else: + emit('library_error', {'error': f'Could not render footprint {footprint_name}'}) + + except Exception as e: + import traceback + emit('library_error', {'error': f'{str(e)}\n\nTraceback:\n{traceback.format_exc()}'}) + # --------------------------------------------------------------------------- # BOM Generation # --------------------------------------------------------------------------- @@ -1298,6 +1960,228 @@ def handle_generate_bom(): import traceback emit('bom_error', {'error': f'{str(e)}\n\nTraceback:\n{traceback.format_exc()}'}) +@socketio.on('build_all') +def handle_build_all(): + """Generate all manufacturing outputs: PDFs, Gerbers, Drill, ODB++, STEP, BOMs""" + try: + kicad_cli = app_args.get('Kicad Cli', '') + sch_file = app_args.get('Schematic File', '') + board_file = app_args.get('Board File', '') + project_dir = app_args.get('Project Dir', '') + project_name = app_args.get('Project Name', 'project') + variant = app_args.get('Variant', 'default') + + if not kicad_cli or not sch_file or not board_file: + emit('build_error', {'error': 'Schematic or board file not configured'}) + return + + if not os.path.exists(sch_file): + emit('build_error', {'error': f'Schematic file not found: {sch_file}'}) + return + + if not os.path.exists(board_file): + emit('build_error', {'error': f'Board file not found: {board_file}'}) + return + + # Create output directory + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + output_dir = os.path.join(project_dir, f'manufacturing_{timestamp}') + os.makedirs(output_dir, exist_ok=True) + + # ===== STEP 1: Generate Schematic PDF ===== + emit('build_progress', {'percent': 5, 'status': 'Generating schematic PDF...', 'message': 'Starting schematic PDF generation'}) + + schematics_dir = os.path.join(output_dir, 'schematics') + os.makedirs(schematics_dir, exist_ok=True) + + sch_pdf_path = os.path.join(schematics_dir, f'{project_name}_schematic.pdf') + cmd = [kicad_cli, 'sch', 'export', 'pdf', sch_file, '-o', sch_pdf_path] + result = subprocess.run(cmd, capture_output=True, text=True) + + if result.returncode != 0: + emit('build_error', {'error': f'Schematic PDF generation failed: {result.stderr}'}) + return + + # ===== STEP 2: Generate Board Layer PDFs ===== + emit('build_progress', {'percent': 10, 'status': 'Generating board layer PDFs...', 'message': 'Schematic PDF complete'}) + + board_dir = os.path.join(output_dir, 'board') + os.makedirs(board_dir, exist_ok=True) + temp_pdf_dir = os.path.join(output_dir, 'temp_pdfs') + os.makedirs(temp_pdf_dir, exist_ok=True) + + # All layers to export + layers = [ + ('F.Cu', 'Top_Copper'), + ('B.Cu', 'Bottom_Copper'), + ('F.Silkscreen', 'Top_Silkscreen'), + ('B.Silkscreen', 'Bottom_Silkscreen'), + ('F.Mask', 'Top_Soldermask'), + ('B.Mask', 'Bottom_Soldermask'), + ('F.Paste', 'Top_Paste'), + ('B.Paste', 'Bottom_Paste'), + ('Edge.Cuts', 'Board_Outline'), + ('F.Fab', 'Top_Fabrication'), + ('B.Fab', 'Bottom_Fabrication'), + ] + + pdf_files = [] + for layer_name, file_suffix in layers: + pdf_path_temp = os.path.join(temp_pdf_dir, f'{file_suffix}_temp.pdf') + pdf_path = os.path.join(temp_pdf_dir, f'{file_suffix}.pdf') + + # Include Edge.Cuts on every layer except the Edge.Cuts layer itself + if layer_name == 'Edge.Cuts': + layers_to_export = layer_name + else: + layers_to_export = f"{layer_name},Edge.Cuts" + + cmd = [ + kicad_cli, 'pcb', 'export', 'pdf', + board_file, + '-l', layers_to_export, + '--include-border-title', + '-o', pdf_path_temp + ] + + result = subprocess.run(cmd, capture_output=True, text=True) + if result.returncode == 0: + # Add layer name text overlay + layer_text = f"Layer: {file_suffix.replace('_', ' ')}" + add_text_overlay_to_pdf(pdf_path_temp, pdf_path, layer_text) + pdf_files.append(pdf_path) + + # Merge all PDFs into one + if pdf_files: + emit('build_progress', {'percent': 25, 'status': 'Merging board layer PDFs...', 'message': f'Generated {len(pdf_files)} layer PDFs'}) + merged_pdf_path = os.path.join(board_dir, f'{project_name}.pdf') + merger = PdfMerger() + + for pdf in pdf_files: + merger.append(pdf) + + merger.write(merged_pdf_path) + merger.close() + + # Delete temp PDF directory + shutil.rmtree(temp_pdf_dir) + + # ===== STEP 3: Generate Gerbers ===== + emit('build_progress', {'percent': 35, 'status': 'Generating Gerbers...', 'message': 'Board PDFs complete, starting Gerber generation'}) + + gerber_dir = os.path.join(output_dir, 'gerbers') + os.makedirs(gerber_dir, exist_ok=True) + + cmd = [kicad_cli, 'pcb', 'export', 'gerbers', board_file, '-o', gerber_dir] + result = subprocess.run(cmd, capture_output=True, text=True) + + if result.returncode != 0: + emit('build_error', {'error': f'Gerber generation failed: {result.stderr}'}) + return + + # ===== STEP 4: Generate Drill Files ===== + emit('build_progress', {'percent': 50, 'status': 'Generating drill files...', 'message': 'Gerbers complete'}) + + cmd = [kicad_cli, 'pcb', 'export', 'drill', board_file, '-o', gerber_dir] + result = subprocess.run(cmd, capture_output=True, text=True) + + if result.returncode != 0: + emit('build_progress', {'percent': 55, 'status': 'Drill file warning', 'message': f'Drill generation had issues: {result.stderr}'}) + + # ===== STEP 5: Generate ODB++ ===== + emit('build_progress', {'percent': 60, 'status': 'Generating ODB++...', 'message': 'Drill files complete'}) + + odb_dir = os.path.join(output_dir, 'odb') + os.makedirs(odb_dir, exist_ok=True) + odb_file = os.path.join(odb_dir, f'{project_name}.zip') + cmd = [kicad_cli, 'pcb', 'export', 'odb', board_file, '-o', odb_file] + result = subprocess.run(cmd, capture_output=True, text=True) + + if result.returncode != 0: + emit('build_progress', {'percent': 65, 'status': 'ODB++ generation skipped', 'message': 'ODB++ not available, continuing...'}) + else: + emit('build_progress', {'percent': 65, 'status': 'ODB++ complete', 'message': 'ODB++ generation successful'}) + + # ===== STEP 6: Export STEP ===== + emit('build_progress', {'percent': 70, 'status': 'Exporting STEP model...', 'message': 'Starting 3D model export'}) + + step_file = os.path.join(output_dir, f'{project_name}.step') + cmd = [kicad_cli, 'pcb', 'export', 'step', board_file, '-o', step_file, '--subst-models'] + result = subprocess.run(cmd, capture_output=True, text=True) + + if result.returncode != 0: + emit('build_progress', {'percent': 75, 'status': 'STEP export failed', 'message': f'STEP export error: {result.stderr}'}) + else: + emit('build_progress', {'percent': 75, 'status': 'STEP export complete', 'message': 'STEP model generated'}) + + # ===== STEP 7: Generate BOMs ===== + emit('build_progress', {'percent': 80, 'status': 'Generating BOMs...', 'message': 'Starting BOM generation'}) + + bom_script = os.path.join(os.path.dirname(__file__), 'bom_generator.py') + + # BOM generator expects: schematic_file, project_name, variant_name, [dnp_uuids_json], [pcb_file] + cmd = [sys.executable, bom_script, sch_file, project_name, variant, '[]'] + if board_file: + cmd.append(board_file) + + result = subprocess.run(cmd, capture_output=True, text=True) + + if result.returncode != 0: + emit('build_progress', {'percent': 90, 'status': 'BOM generation failed', 'message': f'BOM error: {result.stderr}'}) + else: + # Move BOM files from schematic directory to output directory + bom_base_name = f'{project_name}_{variant}' + bom_files = [ + f'{bom_base_name}_BOM.xlsx', + f'{bom_base_name}_Not_Populated.csv', + f'{bom_base_name}_BOM_Top.xlsx', + f'{bom_base_name}_BOM_Bottom.xlsx' + ] + + for bom_file in bom_files: + src_path = os.path.join(project_dir, bom_file) + if os.path.exists(src_path): + dst_path = os.path.join(output_dir, bom_file) + shutil.move(src_path, dst_path) + + emit('build_progress', {'percent': 90, 'status': 'BOMs complete', 'message': 'BOM files generated'}) + + # ===== STEP 8: Create ZIP Package ===== + emit('build_progress', {'percent': 95, 'status': 'Packaging outputs...', 'message': 'Creating ZIP archive'}) + + zip_filename = f'{project_name}_manufacturing_{timestamp}.zip' + zip_path = os.path.join(project_dir, zip_filename) + + with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf: + # Add all files from output directory + for root, dirs, files in os.walk(output_dir): + for file in files: + file_path = os.path.join(root, file) + arcname = os.path.relpath(file_path, output_dir) + zipf.write(file_path, arcname) + + emit('build_progress', {'percent': 100, 'status': 'Build complete!', 'message': f'Package ready: {zip_filename}'}) + emit('build_complete', {'download_url': f'/download_build/{zip_filename}', 'filename': zip_filename}) + + except Exception as e: + import traceback + emit('build_error', {'error': f'{str(e)}\n\nTraceback:\n{traceback.format_exc()}'}) + +@app.route('/download_build/') +def download_build(filename): + """Serve the manufacturing output ZIP file""" + sch_file = app_args.get('Schematic File', '') + if not sch_file: + return "Configuration error", 500 + + project_dir = os.path.dirname(sch_file) + file_path = os.path.join(project_dir, filename) + + if not os.path.exists(file_path): + return "File not found", 404 + + return send_file(file_path, as_attachment=True, download_name=filename) + def shutdown_server(): print("Server stopped") os._exit(0) diff --git a/templates/index.html b/templates/index.html index 26d3fdd..80007da 100644 --- a/templates/index.html +++ b/templates/index.html @@ -356,16 +356,34 @@

Generate all manufacturing outputs in a single operation with progress feedback.

-

Coming Soon

-

This tab will allow you to:

-
    -
  • Generate PDFs (Schematic + Board)
  • -
  • Export Gerbers & Drill Files
  • -
  • Export STEP model
  • -
  • Generate BOM
  • -
  • Run design checks
  • -
-

All with a single button press and real-time progress tracking.

+ + + + +
@@ -427,6 +445,43 @@ + + +
+ +
+
+ + +
+
+ No symbol selected +
+
+
+ + +
+ +
+
+ + +
+
+ No footprint selected +
+
+
+ @@ -990,7 +1045,21 @@ const datasheetInput = document.getElementById('datasheetInput'); const createPartBtn = document.getElementById('createPartBtn'); + // Symbol and Footprint selection elements + const symbolLibrarySelect = document.getElementById('symbolLibrarySelect'); + const symbolSelect = document.getElementById('symbolSelect'); + const symbolPreview = document.getElementById('symbolPreview'); + const footprintLibrarySelect = document.getElementById('footprintLibrarySelect'); + const footprintSelect = document.getElementById('footprintSelect'); + const footprintPreview = document.getElementById('footprintPreview'); + let missingPartsData = []; + let symbolLibraries = []; + let footprintLibraries = []; + let selectedSymbolLibraryPath = ''; + let selectedFootprintLibraryPath = ''; + let selectedSymbol = ''; + let selectedFootprint = ''; function showCreatePartMessage(text, type) { createPartMessage.textContent = text; @@ -1006,6 +1075,9 @@ createPartForm.style.display = 'none'; createPartMessage.style.display = 'none'; socket.emit('get_missing_ipns'); + // Load symbol and footprint libraries + socket.emit('get_symbol_libraries'); + socket.emit('get_footprint_libraries'); }); // Cancel and return to browser @@ -1020,10 +1092,142 @@ manufacturerInput.value = ''; mpnInput.value = ''; datasheetInput.value = ''; + symbolLibrarySelect.value = ''; + symbolSelect.value = ''; + symbolSelect.disabled = true; + symbolPreview.innerHTML = 'No symbol selected'; + footprintLibrarySelect.value = ''; + footprintSelect.value = ''; + footprintSelect.disabled = true; + footprintPreview.innerHTML = 'No footprint selected'; + selectedSymbol = ''; + selectedFootprint = ''; createPartBtn.disabled = true; missingPartsData = []; }); + // Symbol library handlers + socket.on('symbol_libraries_result', (data) => { + symbolLibraries = data.libraries; + symbolLibrarySelect.innerHTML = ''; + for (const lib of data.libraries) { + const option = document.createElement('option'); + option.value = lib.path; + option.textContent = lib.name; + symbolLibrarySelect.appendChild(option); + } + }); + + symbolLibrarySelect.addEventListener('change', () => { + const libraryPath = symbolLibrarySelect.value; + if (!libraryPath) { + symbolSelect.innerHTML = ''; + symbolSelect.disabled = true; + symbolPreview.innerHTML = 'No symbol selected'; + selectedSymbol = ''; + return; + } + + selectedSymbolLibraryPath = libraryPath; + symbolSelect.innerHTML = ''; + symbolSelect.disabled = true; + socket.emit('get_symbols_in_library', { library_path: libraryPath }); + }); + + socket.on('symbols_in_library_result', (data) => { + symbolSelect.innerHTML = ''; + for (const symbol of data.symbols) { + const option = document.createElement('option'); + option.value = symbol; + option.textContent = symbol; + symbolSelect.appendChild(option); + } + symbolSelect.disabled = false; + }); + + symbolSelect.addEventListener('change', () => { + const symbolName = symbolSelect.value; + if (!symbolName) { + symbolPreview.innerHTML = 'No symbol selected'; + selectedSymbol = ''; + return; + } + + symbolPreview.innerHTML = 'Loading...'; + socket.emit('render_symbol', { + library_path: selectedSymbolLibraryPath, + symbol_name: symbolName + }); + }); + + socket.on('symbol_render_result', (data) => { + symbolPreview.innerHTML = data.svg; + // Store as LibraryName:SymbolName format + const libName = symbolLibrarySelect.options[symbolLibrarySelect.selectedIndex].text; + selectedSymbol = `${libName}:${data.symbol_name}`; + }); + + // Footprint library handlers + socket.on('footprint_libraries_result', (data) => { + footprintLibraries = data.libraries; + footprintLibrarySelect.innerHTML = ''; + for (const lib of data.libraries) { + const option = document.createElement('option'); + option.value = lib.path; + option.textContent = lib.name; + footprintLibrarySelect.appendChild(option); + } + }); + + footprintLibrarySelect.addEventListener('change', () => { + const libraryPath = footprintLibrarySelect.value; + if (!libraryPath) { + footprintSelect.innerHTML = ''; + footprintSelect.disabled = true; + footprintPreview.innerHTML = 'No footprint selected'; + selectedFootprint = ''; + return; + } + + selectedFootprintLibraryPath = libraryPath; + footprintSelect.innerHTML = ''; + footprintSelect.disabled = true; + socket.emit('get_footprints_in_library', { library_path: libraryPath }); + }); + + socket.on('footprints_in_library_result', (data) => { + footprintSelect.innerHTML = ''; + for (const footprint of data.footprints) { + const option = document.createElement('option'); + option.value = footprint; + option.textContent = footprint; + footprintSelect.appendChild(option); + } + footprintSelect.disabled = false; + }); + + footprintSelect.addEventListener('change', () => { + const footprintName = footprintSelect.value; + if (!footprintName) { + footprintPreview.innerHTML = 'No footprint selected'; + selectedFootprint = ''; + return; + } + + footprintPreview.innerHTML = 'Loading...'; + socket.emit('render_footprint', { + library_path: selectedFootprintLibraryPath, + footprint_name: footprintName + }); + }); + + socket.on('footprint_render_result', (data) => { + footprintPreview.innerHTML = data.svg; + // Store as LibraryName:FootprintName format + const libName = footprintLibrarySelect.options[footprintLibrarySelect.selectedIndex].text; + selectedFootprint = `${libName}:${data.footprint_name}`; + }); + socket.on('missing_ipns_result', (data) => { createPartFormLoading.style.display = 'none'; @@ -1085,7 +1289,9 @@ class: part.class, manufacturer: part.manufacturer, mpn: part.mpn, - datasheet: datasheetInput.value.trim() + datasheet: datasheetInput.value.trim(), + symbol: selectedSymbol, + footprint: selectedFootprint }); }); @@ -1110,6 +1316,16 @@ manufacturerInput.value = ''; mpnInput.value = ''; datasheetInput.value = ''; + symbolLibrarySelect.value = ''; + symbolSelect.value = ''; + symbolSelect.disabled = true; + symbolPreview.innerHTML = 'No symbol selected'; + footprintLibrarySelect.value = ''; + footprintSelect.value = ''; + footprintSelect.disabled = true; + footprintPreview.innerHTML = 'No footprint selected'; + selectedSymbol = ''; + selectedFootprint = ''; createPartBtn.disabled = true; if (missingPartsData.length === 0) { @@ -1118,6 +1334,77 @@ } }); + // ======================================== + // Build Pipeline + // ======================================== + const buildAllBtn = document.getElementById('buildAllBtn'); + const buildProgress = document.getElementById('buildProgress'); + const buildProgressBar = document.getElementById('buildProgressBar'); + const buildProgressPercent = document.getElementById('buildProgressPercent'); + const buildStatusText = document.getElementById('buildStatusText'); + const buildLog = document.getElementById('buildLog'); + const buildMessage = document.getElementById('buildMessage'); + + function showBuildMessage(text, type) { + buildMessage.textContent = text; + buildMessage.className = 'message ' + type; + buildMessage.style.display = 'block'; + } + + function addBuildLogEntry(text, isError = false) { + const timestamp = new Date().toLocaleTimeString(); + const color = isError ? '#dc3545' : '#333'; + buildLog.innerHTML += `
[${timestamp}] ${text}
`; + buildLog.parentElement.scrollTop = buildLog.parentElement.scrollHeight; + } + + function updateBuildProgress(percent, status) { + buildProgressBar.style.width = percent + '%'; + buildProgressPercent.textContent = percent + '%'; + buildStatusText.textContent = status; + } + + buildAllBtn.addEventListener('click', () => { + buildAllBtn.disabled = true; + buildProgress.style.display = 'block'; + buildMessage.style.display = 'none'; + buildLog.innerHTML = ''; + updateBuildProgress(0, 'Starting build...'); + addBuildLogEntry('Build pipeline started'); + + socket.emit('build_all'); + }); + + socket.on('build_progress', (data) => { + updateBuildProgress(data.percent, data.status); + addBuildLogEntry(data.message); + }); + + socket.on('build_complete', (data) => { + updateBuildProgress(100, 'Build complete!'); + addBuildLogEntry('✓ Build completed successfully'); + buildAllBtn.disabled = false; + + // Auto-download the ZIP file using hidden link (preserves WebSocket connection) + if (data.download_url) { + addBuildLogEntry('Downloading build package...'); + const link = document.createElement('a'); + link.href = data.download_url; + link.download = data.filename || 'manufacturing.zip'; + link.style.display = 'none'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + showBuildMessage('Build complete! Download started automatically.', 'success'); + } + }); + + socket.on('build_error', (data) => { + addBuildLogEntry('✗ Error: ' + data.error, true); + showBuildMessage('Build failed: ' + data.error, 'error'); + buildAllBtn.disabled = false; + }); + // ======================================== // Variant Manager // ========================================