From 55235f222bc7e93bc6f1f16e87b005867f5506f6 Mon Sep 17 00:00:00 2001 From: brentperteet Date: Sun, 22 Feb 2026 19:26:36 -0600 Subject: [PATCH] Add library browser, BOM generation, and UI improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add parts library browser with ODBC database search - Implement hierarchical BOM generation with PCB side detection - Add STEP export and PCB rendering functionality - Adjust page width to 80% up to 1536px max - Add library-only mode for standalone library browsing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- app.py | 330 ++++++++++++++++++++++++++++++-- bom_generator.py | 445 +++++++++++++++++++++++++++++++++++++++++++ templates/index.html | 274 +++++++++++++++++++++++++- 3 files changed, 1026 insertions(+), 23 deletions(-) create mode 100644 bom_generator.py diff --git a/app.py b/app.py index 9fbdc1a..4e8b14d 100644 --- a/app.py +++ b/app.py @@ -13,6 +13,7 @@ from flask_socketio import SocketIO, emit import time from PyPDF2 import PdfMerger from variant_manager import VariantManager +import pyodbc app = Flask(__name__) app.config['SECRET_KEY'] = 'secret!' @@ -57,7 +58,10 @@ def index(): cmd_parts.append(arg) invocation_cmd = ' '.join(cmd_parts) - return render_template('index.html', args=app_args, invocation_cmd=invocation_cmd) + # Check if called with no arguments (library-only mode) + library_only_mode = len(sys.argv) == 1 or not app_args + + return render_template('index.html', args=app_args, invocation_cmd=invocation_cmd, library_only_mode=library_only_mode) @socketio.on('connect') def handle_connect(): @@ -265,6 +269,81 @@ def handle_generate_gerbers(): except Exception as e: emit('gerber_error', {'error': str(e)}) +@socketio.on('export_step') +def handle_export_step(): + try: + kicad_cli = app_args.get('Kicad Cli', '') + board_file = app_args.get('Board File', '') + project_dir = app_args.get('Project Dir', '') + project_name = app_args.get('Project Name', 'project') + + if not kicad_cli or not board_file: + emit('step_error', {'error': 'Missing kicad-cli or board-file arguments'}) + return + + # Create output filename + step_filename = f'{project_name}.step' + step_path = os.path.join(project_dir if project_dir else os.path.dirname(board_file), step_filename) + + # Generate STEP file + emit('step_status', {'status': 'Exporting PCB to STEP format...'}) + cmd = [kicad_cli, 'pcb', 'export', 'step', board_file, '-o', step_path] + + result = subprocess.run(cmd, capture_output=True, text=True) + + if result.returncode != 0: + emit('step_error', {'error': f'STEP export failed: {result.stderr}'}) + return + + if not os.path.exists(step_path): + emit('step_error', {'error': 'STEP file was not created'}) + return + + emit('step_complete', {'path': step_path, 'filename': step_filename}) + + except Exception as e: + emit('step_error', {'error': str(e)}) + +@socketio.on('render_pcb') +def handle_render_pcb(): + try: + kicad_cli = app_args.get('Kicad Cli', '') + board_file = app_args.get('Board File', '') + project_dir = app_args.get('Project Dir', '') + project_name = app_args.get('Project Name', 'project') + + if not kicad_cli or not board_file: + emit('render_error', {'error': 'Missing kicad-cli or board-file arguments'}) + return + + # Create output filename + render_filename = f'{project_name}_iso_view.png' + render_path = os.path.join(project_dir if project_dir else os.path.dirname(board_file), render_filename) + + # Render PCB with isometric view + emit('render_status', {'status': 'Rendering PCB image...'}) + cmd = [ + kicad_cli, 'pcb', 'render', board_file, + '--rotate', '25,0,45', + '-o', render_path + ] + + result = subprocess.run(cmd, capture_output=True, text=True) + + if result.returncode != 0: + error_msg = f'PCB render failed:\nCommand: {" ".join(cmd)}\nStderr: {result.stderr}\nStdout: {result.stdout}' + emit('render_error', {'error': error_msg}) + return + + if not os.path.exists(render_path): + emit('render_error', {'error': 'Rendered image was not created'}) + return + + emit('render_complete', {'path': render_path, 'filename': render_filename}) + + except Exception as e: + emit('render_error', {'error': str(e)}) + @socketio.on('sync_libraries') def handle_sync_libraries(): try: @@ -599,6 +678,12 @@ def handle_delete_variant(data): @socketio.on('activate_variant') def handle_activate_variant(data): try: + import pygetwindow as gw + import pyautogui + import time + import win32gui + import win32con + manager = get_variant_manager() if not manager: emit('variant_error', {'error': 'No project loaded'}) @@ -608,30 +693,78 @@ def handle_activate_variant(data): schematic_file = app_args.get('Schematic File', '') kicad_cli = app_args.get('Kicad Cli', 'kicad-cli') - # First, sync the current variant from schematic to capture any manual changes - current_variant = manager.get_active_variant() + # Step 1: Save and close schematic window + emit('variant_status', {'status': 'Looking for KiCad schematic window...'}) + + all_windows = gw.getAllTitles() + windows = gw.getWindowsWithTitle('Schematic Editor') + + window_found = False + if not windows: + schematic_windows = [w for w in all_windows if 'kicad' in w.lower() and 'schematic' in w.lower()] + if schematic_windows: + windows = gw.getWindowsWithTitle(schematic_windows[0]) + window_found = len(windows) > 0 + else: + window_found = True + + # If window is found, save and close it + if window_found: + window = windows[0] + emit('variant_status', {'status': f'Saving and closing: {window.title}'}) + + hwnd = window._hWnd + rect = win32gui.GetWindowRect(hwnd) + x, y, x2, y2 = rect + width = x2 - x + + # Click to activate + click_x = x + width // 2 + click_y = y + 10 + pyautogui.click(click_x, click_y) + time.sleep(0.5) + + # Save + pyautogui.hotkey('ctrl', 's') + time.sleep(1.0) + + # Close + win32gui.PostMessage(hwnd, win32con.WM_CLOSE, 0, 0) + time.sleep(1.0) + + # Try Alt+F4 if still open + if win32gui.IsWindow(hwnd): + try: + rect = win32gui.GetWindowRect(hwnd) + x, y, x2, y2 = rect + click_x = x + (x2 - x) // 2 + click_y = y + 10 + pyautogui.click(click_x, click_y) + time.sleep(0.3) + except: + pass + pyautogui.hotkey('alt', 'F4') + time.sleep(1.0) + + # Step 2: Sync current variant from schematic + current_variant = manager.get_active_variant() + emit('variant_status', {'status': f'Syncing current variant "{current_variant}"...'}) - print(f"Syncing current variant '{current_variant}' before switching...") - # Import and call sync function directly instead of subprocess from sync_variant import sync_variant_from_schematic try: sync_success = sync_variant_from_schematic(schematic_file, current_variant) if sync_success: - print(f"Successfully synced variant '{current_variant}'") - # Reload the manager to get the updated data manager = get_variant_manager() else: print(f"Warning: Sync of variant '{current_variant}' failed") except Exception as e: print(f"Error during sync: {e}") - import traceback - traceback.print_exc() - # Now activate the new variant + # Step 3: Activate new variant and apply to schematic + emit('variant_status', {'status': f'Activating variant "{name}"...'}) success = manager.set_active_variant(name) if success: - # Apply new variant to schematic apply_script_path = os.path.join(os.path.dirname(__file__), 'apply_variant.py') result = subprocess.run( @@ -640,15 +773,39 @@ def handle_activate_variant(data): text=True ) - if result.returncode == 0: - emit('variant_updated', {'message': f'Synced "{current_variant}", then activated and applied "{name}"'}) - else: + if result.returncode != 0: error_msg = result.stderr if result.stderr else result.stdout emit('variant_error', {'error': f'Failed to apply variant: {error_msg}'}) + return else: emit('variant_error', {'error': f'Variant "{name}" not found'}) + return + + # Step 4: Reopen schematic editor + emit('variant_status', {'status': 'Waiting 2 seconds before reopening...'}) + time.sleep(2.0) + + emit('variant_status', {'status': 'Reopening schematic editor...'}) + + kicad_bin_dir = r"C:\Program Files\KiCad\9.0\bin" + if not os.path.exists(kicad_bin_dir): + kicad_bin_dir = r"C:\Program Files\KiCad\8.0\bin" + + eeschema_exe = os.path.join(kicad_bin_dir, "eeschema.exe") + + if os.path.exists(eeschema_exe): + subprocess.Popen([eeschema_exe, schematic_file], shell=False) + time.sleep(2.0) + else: + emit('variant_status', {'status': f'Warning: eeschema.exe not found at {eeschema_exe}'}) + + emit('variant_updated', {'message': f'Activated variant "{name}" and reopened schematic'}) + + except ImportError as e: + emit('variant_error', {'error': f'Missing library: {str(e)}. Install: pip install pygetwindow pyautogui pywin32'}) except Exception as e: - emit('variant_error', {'error': str(e)}) + import traceback + emit('variant_error', {'error': f'{str(e)}\n{traceback.format_exc()}'}) @socketio.on('get_variant_parts') def handle_get_variant_parts(data): @@ -860,6 +1017,149 @@ def handle_test_window_interaction(): import traceback emit('window_test_error', {'error': f'{str(e)}\n\nTraceback:\n{traceback.format_exc()}'}) +# --------------------------------------------------------------------------- +# Library Management +# --------------------------------------------------------------------------- + +def get_db_connection(): + """Get ODBC connection to the parts database""" + try: + # Use DSN connection as configured in KiCad database library + conn_str = "DSN=UM_KiCad_Parts;" + conn = pyodbc.connect(conn_str) + return conn + except Exception as e: + print(f"Database connection error: {e}") + return None + +@socketio.on('search_parts') +def handle_search_parts(data): + try: + search_query = data.get('query', '').strip() + + conn = get_db_connection() + if not conn: + emit('library_error', {'error': 'Could not connect to database'}) + return + + cursor = conn.cursor() + + # Build search query - search across ipn, mpn, manufacturer, and description + # Column names in SQLite are lowercase + if search_query: + sql = """ + SELECT ipn, mpn, manufacturer, description + FROM parts + WHERE ipn LIKE ? + OR mpn LIKE ? + OR manufacturer LIKE ? + OR description LIKE ? + ORDER BY ipn + LIMIT 100 + """ + search_param = f'%{search_query}%' + cursor.execute(sql, (search_param, search_param, search_param, search_param)) + else: + # No search query - return first 100 parts + sql = """ + SELECT ipn, mpn, manufacturer, description + FROM parts + ORDER BY ipn + LIMIT 100 + """ + cursor.execute(sql) + + rows = cursor.fetchall() + + parts = [] + for row in rows: + parts.append({ + 'ipn': row.ipn if row.ipn else '', + 'mpn': row.mpn if row.mpn else '', + 'manufacturer': row.manufacturer if row.manufacturer else '', + 'description': row.description if row.description else '' + }) + + cursor.close() + conn.close() + + emit('library_search_results', {'parts': parts, 'count': len(parts)}) + + except Exception as e: + import traceback + emit('library_error', {'error': f'{str(e)}\n\nTraceback:\n{traceback.format_exc()}'}) + +# --------------------------------------------------------------------------- +# BOM Generation +# --------------------------------------------------------------------------- + +@socketio.on('generate_bom') +def handle_generate_bom(): + try: + manager = get_variant_manager() + if not manager: + emit('bom_error', {'error': 'No project loaded'}) + return + + schematic_file = app_args.get('Schematic File', '') + board_file = app_args.get('Board File', '') + project_name = app_args.get('Project Name', 'project') + project_dir = app_args.get('Project Dir', '') + + if not schematic_file: + emit('bom_error', {'error': 'No schematic file specified'}) + return + + # Get active variant and DNP parts + active_variant = manager.get_active_variant() + dnp_uuids = manager.get_dnp_parts(active_variant) + + emit('bom_status', {'status': 'Generating BOMs...'}) + + # Call BOM generator script + bom_script_path = os.path.join(os.path.dirname(__file__), 'bom_generator.py') + + # Build command with optional PCB file + cmd = [sys.executable, bom_script_path, schematic_file, project_name, active_variant, json.dumps(dnp_uuids)] + if board_file: + cmd.append(board_file) + + result = subprocess.run( + cmd, + capture_output=True, + text=True + ) + + if result.returncode == 0: + # Create ZIP of all BOMs + output_dir = project_dir if project_dir else os.path.dirname(schematic_file) + base_name = f"{project_name}_{active_variant}" + + zip_filename = f"{base_name}_BOMs.zip" + zip_path = os.path.join(output_dir, zip_filename) + + with zipfile.ZipFile(zip_path, 'w') as zipf: + # Add all BOM files + bom_files = [ + f"{base_name}_BOM.xlsx", + f"{base_name}_Not_Populated.csv", + f"{base_name}_BOM_Top.xlsx", + f"{base_name}_BOM_Bottom.xlsx" + ] + + for bom_file in bom_files: + full_path = os.path.join(output_dir, bom_file) + if os.path.exists(full_path): + zipf.write(full_path, bom_file) + + emit('bom_complete', {'path': zip_path, 'filename': zip_filename}) + else: + emit('bom_error', {'error': f'BOM generation failed: {result.stderr}'}) + + except Exception as e: + import traceback + emit('bom_error', {'error': f'{str(e)}\n\nTraceback:\n{traceback.format_exc()}'}) + def shutdown_server(): print("Server stopped") os._exit(0) diff --git a/bom_generator.py b/bom_generator.py new file mode 100644 index 0000000..cda8f74 --- /dev/null +++ b/bom_generator.py @@ -0,0 +1,445 @@ +""" +BOM Generator for KiCad projects +Generates fitted, unfitted, top-side, and bottom-side BOMs +""" + +import os +from collections import defaultdict +from openpyxl import Workbook +from openpyxl.styles import Font, Alignment, PatternFill +import csv + + +def find_hierarchical_sheets(schematic_file): + """ + Find all hierarchical sheet files referenced in the schematic + Returns list of sheet file paths + """ + sheet_files = [] + base_dir = os.path.dirname(schematic_file) + + with open(schematic_file, 'r', encoding='utf-8') as f: + lines = f.readlines() + + in_sheet = False + for line in lines: + stripped = line.strip() + + if stripped.startswith('(sheet'): + in_sheet = True + elif in_sheet and '(property "Sheetfile"' in line: + parts = line.split('"') + if len(parts) >= 4: + sheet_filename = parts[3] + sheet_path = os.path.join(base_dir, sheet_filename) + if os.path.exists(sheet_path): + sheet_files.append(sheet_path) + # Recursively find sheets in the child schematic + child_sheets = find_hierarchical_sheets(sheet_path) + sheet_files.extend(child_sheets) + in_sheet = False + + return sheet_files + + +def parse_pcb_for_component_sides(pcb_file): + """ + Parse PCB file to determine which side each component is on + Returns dict mapping reference -> 'Top' or 'Bottom' + """ + component_sides = {} + + if not os.path.exists(pcb_file): + return component_sides + + with open(pcb_file, 'r', encoding='utf-8') as f: + lines = f.readlines() + + in_footprint = False + current_ref = None + current_layer = None + footprint_indent = None + + for i, line in enumerate(lines): + stripped = line.strip() + indent_level = len(line) - len(line.lstrip()) + + # Detect start of footprint + if stripped.startswith('(footprint'): + in_footprint = True + current_ref = None + current_layer = None + footprint_indent = indent_level + + # Detect end of footprint + elif in_footprint and stripped == ')' and indent_level == footprint_indent: + # Store the side mapping + if current_ref and current_layer: + side = 'Top' if current_layer == 'F.Cu' else 'Bottom' if current_layer == 'B.Cu' else '-' + component_sides[current_ref] = side + in_footprint = False + current_ref = None + current_layer = None + + # Extract reference + elif in_footprint and '(property "Reference"' in line: + parts = line.split('"') + if len(parts) >= 4: + current_ref = parts[3] + + # Extract layer + elif in_footprint and '(layer' in stripped: + parts = line.split('"') + if len(parts) >= 2: + current_layer = parts[1] + + return component_sides + + +def parse_single_schematic(schematic_file, dnp_uuids=None): + """ + Parse a single schematic file for components + """ + if dnp_uuids is None: + dnp_uuids = [] + + components = [] + + with open(schematic_file, 'r', encoding='utf-8') as f: + lines = f.readlines() + + in_symbol = False + current_component = {} + symbol_start_indent = None + + for i, line in enumerate(lines): + stripped = line.strip() + indent_level = len(line) - len(line.lstrip()) + + # Detect start of symbol + if stripped.startswith('(symbol'): + in_symbol = True + current_component = { + 'reference': '', + 'value': '', + 'footprint': '', + 'ipn': '', + 'mpn': '', + 'manufacturer': '', + 'description': '', + 'datasheet': '', + 'side': '-', + 'is_dnp': False, + 'uuid': '' + } + symbol_start_indent = indent_level + + # Detect end of symbol + elif in_symbol and stripped == ')' and indent_level == symbol_start_indent: + # Only add if we have a reference (filter out power symbols, etc.) + if current_component['reference'] and not current_component['reference'].startswith('#'): + # Check if lib_id contains "power:" + if 'lib_id' in current_component and 'power:' not in current_component.get('lib_id', ''): + components.append(current_component.copy()) + in_symbol = False + current_component = {} + + # Extract lib_id + elif in_symbol and '(lib_id' in stripped: + parts = line.split('"') + if len(parts) >= 2: + current_component['lib_id'] = parts[1] + + # Extract UUID (comes after DNP line) + elif in_symbol and '(dnp' in stripped: + # Look forward for UUID + for j in range(i + 1, min(len(lines), i + 5)): + if '(uuid' in lines[j]: + if '\t(uuid' in lines[j] or ' (uuid' in lines[j]: + uuid_parts = lines[j].split('"') + if len(uuid_parts) >= 2: + current_component['uuid'] = uuid_parts[1] + # Check if DNP + if current_component['uuid'] in dnp_uuids or '(dnp yes)' in stripped or stripped == '(dnp)': + current_component['is_dnp'] = True + break + + # Extract properties + elif in_symbol and '(property' in line: + parts = line.split('"') + if len(parts) >= 4: + prop_name = parts[1] + prop_value = parts[3] + + if prop_name == 'Reference': + current_component['reference'] = prop_value + elif prop_name == 'Value': + current_component['value'] = prop_value + elif prop_name == 'Footprint': + current_component['footprint'] = prop_value + elif prop_name == 'IPN': + current_component['ipn'] = prop_value + elif prop_name == 'MPN': + current_component['mpn'] = prop_value + elif prop_name == 'Manufacturer': + current_component['manufacturer'] = prop_value + elif prop_name == 'Description': + current_component['description'] = prop_value + elif prop_name == 'Datasheet': + current_component['datasheet'] = prop_value + + return components + + +def parse_schematic_for_bom(schematic_file, dnp_uuids=None, pcb_file=None): + """ + Parse schematic hierarchy and PCB file to extract component information for BOM generation + Traverses all hierarchical sheets and gets side information from PCB + + Returns list of components with properties: + - reference: Component reference designator + - value: Component value + - footprint: Footprint name + - ipn: Internal Part Number + - mpn: Manufacturer Part Number + - manufacturer: Manufacturer name + - description: Component description + - side: Top/Bottom/- (from PCB layer) + - is_dnp: Whether component is DNP (Do Not Place) + - uuid: Component UUID + """ + if dnp_uuids is None: + dnp_uuids = [] + + # Get component side information from PCB + component_sides = {} + if pcb_file and os.path.exists(pcb_file): + print(f"Parsing PCB for component sides: {pcb_file}") + component_sides = parse_pcb_for_component_sides(pcb_file) + + # Parse root schematic + print(f"Parsing root schematic: {schematic_file}") + components = parse_single_schematic(schematic_file, dnp_uuids) + + # Find and parse hierarchical sheets + sheet_files = find_hierarchical_sheets(schematic_file) + for sheet_file in sheet_files: + print(f"Parsing hierarchical sheet: {sheet_file}") + sheet_components = parse_single_schematic(sheet_file, dnp_uuids) + components.extend(sheet_components) + + # Apply side information from PCB + for comp in components: + if comp['reference'] in component_sides: + comp['side'] = component_sides[comp['reference']] + + print(f"Total components found: {len(components)}") + return components + + +def group_components_for_bom(components): + """ + Group components by IPN and side for BOM generation + Returns list of grouped components with consolidated references + """ + # Group by (ipn, side, value, footprint, manufacturer, mpn) + groups = defaultdict(list) + + for comp in components: + # Create grouping key + key = ( + comp.get('ipn', ''), + comp.get('side', '-'), + comp.get('value', ''), + comp.get('footprint', ''), + comp.get('manufacturer', ''), + comp.get('mpn', ''), + comp.get('description', '') + ) + groups[key].append(comp) + + # Create BOM lines + bom_lines = [] + line_num = 1 + + for key, comps in sorted(groups.items()): + ipn, side, value, footprint, manufacturer, mpn, description = key + + # Sort references naturally (R1, R2, R10, etc.) + refs = sorted([c['reference'] for c in comps], key=lambda x: (x.rstrip('0123456789'), int(''.join(filter(str.isdigit, x)) or 0))) + refdes_str = ', '.join(refs) + + # Determine SMD/TH from footprint + smd_th = 'SMD' if 'SMD' in footprint.upper() or any(x in footprint for x in ['0402', '0603', '0805', '1206']) else 'TH' + + bom_line = { + 'line_num': line_num, + 'refdes': refdes_str, + 'ipn': ipn if ipn else 'N/A', + 'qty': len(comps), + 'description': description, + 'footprint': footprint, + 'manufacturer': manufacturer, + 'mpn': mpn, + 'notes': '', + 'smd_th': smd_th, + 'side': side, + 'populated': True # Since we're only including fitted parts + } + + bom_lines.append(bom_line) + line_num += 1 + + return bom_lines + + +def generate_main_bom_xlsx(bom_lines, output_file, project_name, variant_name): + """ + Generate main BOM in XLSX format matching the reference format + """ + wb = Workbook() + ws = wb.active + ws.title = "BOM" + + # Set column widths + ws.column_dimensions['A'].width = 5 + ws.column_dimensions['B'].width = 60 + ws.column_dimensions['C'].width = 12 + ws.column_dimensions['D'].width = 6 + ws.column_dimensions['E'].width = 40 + ws.column_dimensions['F'].width = 25 + ws.column_dimensions['G'].width = 15 + ws.column_dimensions['H'].width = 20 + ws.column_dimensions['I'].width = 15 + ws.column_dimensions['J'].width = 8 + ws.column_dimensions['K'].width = 8 + ws.column_dimensions['L'].width = 10 + + # Add header row (row 2) + headers = ['#', 'RefDes', 'GLE P/N', 'Qty', 'Description', 'Footprint', + 'Mfg 1', 'Mfg 1 P/N', 'Notes', 'SMD/TH', 'Side', 'Populated', + 'Mfg 2', 'Mfg 2 P/N', 'Mfg 3', 'Mfg 3 P/N', 'Mfg 4', 'Mfg 4 P/N', + 'Mfg 5', 'Mfg 5 P/N'] + + for col_idx, header in enumerate(headers, 1): + cell = ws.cell(row=2, column=col_idx) + cell.value = header + cell.font = Font(bold=True) + cell.fill = PatternFill(start_color='D3D3D3', end_color='D3D3D3', fill_type='solid') + cell.alignment = Alignment(horizontal='left', vertical='center') + + # Add BOM lines + for idx, bom_line in enumerate(bom_lines, 3): + ws.cell(row=idx, column=1, value=bom_line['line_num']) + ws.cell(row=idx, column=2, value=bom_line['refdes']) + ws.cell(row=idx, column=3, value=bom_line['ipn']) + ws.cell(row=idx, column=4, value=bom_line['qty']) + ws.cell(row=idx, column=5, value=bom_line['description']) + ws.cell(row=idx, column=6, value=bom_line['footprint']) + ws.cell(row=idx, column=7, value=bom_line['manufacturer']) + ws.cell(row=idx, column=8, value=bom_line['mpn']) + ws.cell(row=idx, column=9, value=bom_line['notes']) + ws.cell(row=idx, column=10, value=bom_line['smd_th']) + ws.cell(row=idx, column=11, value=bom_line['side']) + ws.cell(row=idx, column=12, value=bom_line['populated']) + + wb.save(output_file) + print(f"Generated main BOM: {output_file}") + + +def generate_unfitted_bom_csv(components, output_file, project_name, variant_name): + """ + Generate unfitted (DNP) parts BOM in CSV format + """ + # Filter only DNP parts + dnp_parts = [c for c in components if c['is_dnp']] + + # Sort by reference + dnp_parts.sort(key=lambda x: (x['reference'].rstrip('0123456789'), + int(''.join(filter(str.isdigit, x['reference'])) or 0))) + + with open(output_file, 'w', newline='', encoding='utf-8') as csvfile: + writer = csv.writer(csvfile, quoting=csv.QUOTE_ALL) + + # Header + writer.writerow(['Components Not Populated Report']) + writer.writerow(['Variant', variant_name, project_name]) + writer.writerow(['Assembly', '-', '']) + writer.writerow(['----------']) + + # DNP parts + for part in dnp_parts: + writer.writerow([part['reference'], 'Not populated']) + + writer.writerow(['----------']) + + print(f"Generated unfitted BOM: {output_file}") + + +def generate_top_bom_xlsx(bom_lines, output_file, project_name, variant_name): + """ + Generate top-side only BOM in XLSX format + """ + # Filter only top-side parts + top_lines = [line for line in bom_lines if line['side'] == 'Top'] + generate_main_bom_xlsx(top_lines, output_file, project_name, f"{variant_name} (Top)") + + +def generate_bottom_bom_xlsx(bom_lines, output_file, project_name, variant_name): + """ + Generate bottom-side only BOM in XLSX format + """ + # Filter only bottom-side parts + bottom_lines = [line for line in bom_lines if line['side'] == 'Bottom'] + generate_main_bom_xlsx(bottom_lines, output_file, project_name, f"{variant_name} (Bottom)") + + +if __name__ == '__main__': + import sys + if len(sys.argv) < 4: + print("Usage: python bom_generator.py [dnp_uuids_json] [pcb_file]") + sys.exit(1) + + schematic_file = sys.argv[1] + project_name = sys.argv[2] + variant_name = sys.argv[3] + + dnp_uuids = [] + if len(sys.argv) > 4: + import json + dnp_uuids = json.loads(sys.argv[4]) + + pcb_file = None + if len(sys.argv) > 5: + pcb_file = sys.argv[5] + + # Parse schematic with hierarchical sheets and PCB side info + components = parse_schematic_for_bom(schematic_file, dnp_uuids, pcb_file) + + # Filter fitted parts for main BOM + fitted_components = [c for c in components if not c['is_dnp']] + + # Group components + bom_lines = group_components_for_bom(fitted_components) + + # Generate output files + output_dir = os.path.dirname(schematic_file) + base_name = f"{project_name}_{variant_name}" + + # Main BOM + generate_main_bom_xlsx(bom_lines, os.path.join(output_dir, f"{base_name}_BOM.xlsx"), + project_name, variant_name) + + # Unfitted BOM + generate_unfitted_bom_csv(components, os.path.join(output_dir, f"{base_name}_Not_Populated.csv"), + project_name, variant_name) + + # Top BOM + generate_top_bom_xlsx(bom_lines, os.path.join(output_dir, f"{base_name}_BOM_Top.xlsx"), + project_name, variant_name) + + # Bottom BOM + generate_bottom_bom_xlsx(bom_lines, os.path.join(output_dir, f"{base_name}_BOM_Bottom.xlsx"), + project_name, variant_name) + + print(f"BOM generation complete for variant: {variant_name}") diff --git a/templates/index.html b/templates/index.html index bc25627..ed9f529 100644 --- a/templates/index.html +++ b/templates/index.html @@ -8,7 +8,8 @@