Add library browser, BOM generation, and UI improvements

- 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 <noreply@anthropic.com>
This commit is contained in:
brentperteet
2026-02-22 19:26:36 -06:00
parent ab8d5c0c14
commit 55235f222b
3 changed files with 1026 additions and 23 deletions

330
app.py
View File

@@ -13,6 +13,7 @@ from flask_socketio import SocketIO, emit
import time import time
from PyPDF2 import PdfMerger from PyPDF2 import PdfMerger
from variant_manager import VariantManager from variant_manager import VariantManager
import pyodbc
app = Flask(__name__) app = Flask(__name__)
app.config['SECRET_KEY'] = 'secret!' app.config['SECRET_KEY'] = 'secret!'
@@ -57,7 +58,10 @@ def index():
cmd_parts.append(arg) cmd_parts.append(arg)
invocation_cmd = ' '.join(cmd_parts) 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') @socketio.on('connect')
def handle_connect(): def handle_connect():
@@ -265,6 +269,81 @@ def handle_generate_gerbers():
except Exception as e: except Exception as e:
emit('gerber_error', {'error': str(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') @socketio.on('sync_libraries')
def handle_sync_libraries(): def handle_sync_libraries():
try: try:
@@ -599,6 +678,12 @@ def handle_delete_variant(data):
@socketio.on('activate_variant') @socketio.on('activate_variant')
def handle_activate_variant(data): def handle_activate_variant(data):
try: try:
import pygetwindow as gw
import pyautogui
import time
import win32gui
import win32con
manager = get_variant_manager() manager = get_variant_manager()
if not manager: if not manager:
emit('variant_error', {'error': 'No project loaded'}) emit('variant_error', {'error': 'No project loaded'})
@@ -608,30 +693,78 @@ def handle_activate_variant(data):
schematic_file = app_args.get('Schematic File', '') schematic_file = app_args.get('Schematic File', '')
kicad_cli = app_args.get('Kicad Cli', 'kicad-cli') kicad_cli = app_args.get('Kicad Cli', 'kicad-cli')
# First, sync the current variant from schematic to capture any manual changes # Step 1: Save and close schematic window
current_variant = manager.get_active_variant() 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 from sync_variant import sync_variant_from_schematic
try: try:
sync_success = sync_variant_from_schematic(schematic_file, current_variant) sync_success = sync_variant_from_schematic(schematic_file, current_variant)
if sync_success: if sync_success:
print(f"Successfully synced variant '{current_variant}'")
# Reload the manager to get the updated data
manager = get_variant_manager() manager = get_variant_manager()
else: else:
print(f"Warning: Sync of variant '{current_variant}' failed") print(f"Warning: Sync of variant '{current_variant}' failed")
except Exception as e: except Exception as e:
print(f"Error during sync: {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) success = manager.set_active_variant(name)
if success: if success:
# Apply new variant to schematic
apply_script_path = os.path.join(os.path.dirname(__file__), 'apply_variant.py') apply_script_path = os.path.join(os.path.dirname(__file__), 'apply_variant.py')
result = subprocess.run( result = subprocess.run(
@@ -640,15 +773,39 @@ def handle_activate_variant(data):
text=True text=True
) )
if result.returncode == 0: if result.returncode != 0:
emit('variant_updated', {'message': f'Synced "{current_variant}", then activated and applied "{name}"'})
else:
error_msg = result.stderr if result.stderr else result.stdout error_msg = result.stderr if result.stderr else result.stdout
emit('variant_error', {'error': f'Failed to apply variant: {error_msg}'}) emit('variant_error', {'error': f'Failed to apply variant: {error_msg}'})
return
else: else:
emit('variant_error', {'error': f'Variant "{name}" not found'}) 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: 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') @socketio.on('get_variant_parts')
def handle_get_variant_parts(data): def handle_get_variant_parts(data):
@@ -860,6 +1017,149 @@ def handle_test_window_interaction():
import traceback import traceback
emit('window_test_error', {'error': f'{str(e)}\n\nTraceback:\n{traceback.format_exc()}'}) 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(): def shutdown_server():
print("Server stopped") print("Server stopped")
os._exit(0) os._exit(0)

445
bom_generator.py Normal file
View File

@@ -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 <schematic_file> <project_name> <variant_name> [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}")

View File

@@ -8,7 +8,8 @@
<style> <style>
body { body {
font-family: Arial, sans-serif; font-family: Arial, sans-serif;
max-width: 800px; width: 80%;
max-width: 1536px;
margin: 50px auto; margin: 50px auto;
padding: 20px; padding: 20px;
background-color: #f5f5f5; background-color: #f5f5f5;
@@ -270,12 +271,21 @@
<h1>KiCad Manager</h1> <h1>KiCad Manager</h1>
{% if library_only_mode %}
<div class="tabs"> <div class="tabs">
<button class="tab active" onclick="switchTab('main')">Main</button> <button class="tab active" onclick="switchTab('library')">Library</button>
<button class="tab" onclick="switchTab('variants')">Variant Manager</button>
</div> </div>
{% else %}
<div class="tabs">
<button class="tab active" onclick="switchTab('debug')">Debug</button>
<button class="tab" onclick="switchTab('publish')">Publish</button>
<button class="tab" onclick="switchTab('library')">Library</button>
<button class="tab" onclick="switchTab('variants')">Variants</button>
</div>
{% endif %}
<div id="mainTab" class="tab-content active"> {% if not library_only_mode %}
<div id="debugTab" class="tab-content active">
<div class="command-container"> <div class="command-container">
<h2>Invocation Command</h2> <h2>Invocation Command</h2>
@@ -298,10 +308,16 @@
<h2>Actions</h2> <h2>Actions</h2>
<button id="generatePdfBtn" class="btn">Generate All PDFs (Schematic + Board Layers)</button> <button id="generatePdfBtn" class="btn">Generate All PDFs (Schematic + Board Layers)</button>
<button id="generateGerbersBtn" class="btn" style="margin-left: 10px;">Generate Gerbers & Drill Files</button> <button id="generateGerbersBtn" class="btn" style="margin-left: 10px;">Generate Gerbers & Drill Files</button>
<button id="exportStepBtn" class="btn" style="margin-left: 10px;">Export PCB to STEP</button>
<button id="renderPcbBtn" class="btn" style="margin-left: 10px;">Render PCB Image</button>
<button id="generateBomBtn" class="btn" style="margin-left: 10px;">Generate BOM (All Variants)</button>
<button id="syncLibrariesBtn" class="btn" style="margin-left: 10px;">Sync Symbol Libraries</button> <button id="syncLibrariesBtn" class="btn" style="margin-left: 10px;">Sync Symbol Libraries</button>
<button id="syncDbBtn" class="btn" style="margin-left: 10px;">Sync Parts to Database (R & C)</button> <button id="syncDbBtn" class="btn" style="margin-left: 10px;">Sync Parts to Database (R & C)</button>
<div id="message" class="message"></div> <div id="message" class="message"></div>
<div id="gerberMessage" class="message"></div> <div id="gerberMessage" class="message"></div>
<div id="stepMessage" class="message"></div>
<div id="renderMessage" class="message"></div>
<div id="bomMessage" class="message"></div>
<div id="syncMessage" class="message"></div> <div id="syncMessage" class="message"></div>
<div id="dbSyncMessage" class="message"></div> <div id="dbSyncMessage" class="message"></div>
</div> </div>
@@ -330,8 +346,51 @@
</div> </div>
</div> </div>
</div><!-- End mainTab --> </div><!-- End debugTab -->
<!-- Publish Tab -->
<div id="publishTab" class="tab-content">
<div class="container">
<h2>Build & Publish</h2>
<p>Generate all manufacturing outputs in a single operation with progress feedback.</p>
<div class="actions">
<h3>Coming Soon</h3>
<p>This tab will allow you to:</p>
<ul style="margin-left: 20px; line-height: 1.8;">
<li>Generate PDFs (Schematic + Board)</li>
<li>Export Gerbers & Drill Files</li>
<li>Export STEP model</li>
<li>Generate BOM</li>
<li>Run design checks</li>
</ul>
<p style="margin-top: 20px;">All with a single button press and real-time progress tracking.</p>
</div>
</div>
</div><!-- End publishTab -->
{% endif %}
<!-- Library Tab -->
<div id="libraryTab" class="tab-content {% if library_only_mode %}active{% endif %}">
<div class="container">
<h2>Parts Library Browser</h2>
<div style="margin: 20px 0;">
<input type="text" id="librarySearchInput" placeholder="Search by IPN, MPN, Manufacturer, or Description..."
style="width: 70%; padding: 10px; border: 1px solid #dee2e6; border-radius: 5px; font-size: 14px;">
<button id="librarySearchBtn" class="btn" style="margin-left: 10px;">Search</button>
<button id="libraryClearBtn" class="btn secondary" style="margin-left: 5px;">Clear</button>
</div>
<div id="libraryMessage" class="message"></div>
<div id="libraryResults" style="margin-top: 20px;">
<p style="color: #6c757d;">Enter a search query or click Search to browse all parts</p>
</div>
</div>
</div><!-- End libraryTab -->
{% if not library_only_mode %}
<!-- Variant Manager Tab --> <!-- Variant Manager Tab -->
<div id="variantsTab" class="tab-content"> <div id="variantsTab" class="tab-content">
<div class="container"> <div class="container">
@@ -381,8 +440,10 @@
<button class="btn" onclick="closeEditPartsModal()">Done</button> <button class="btn" onclick="closeEditPartsModal()">Done</button>
</div> </div>
</div> </div>
{% endif %}
<script> <script>
const libraryOnlyMode = {{ 'true' if library_only_mode else 'false' }};
const socket = io(); const socket = io();
const statusEl = document.getElementById('status'); const statusEl = document.getElementById('status');
@@ -414,6 +475,8 @@
socket.disconnect(); socket.disconnect();
}); });
// Only initialize project-specific features if not in library-only mode
if (!libraryOnlyMode) {
// PDF Generation // PDF Generation
const generatePdfBtn = document.getElementById('generatePdfBtn'); const generatePdfBtn = document.getElementById('generatePdfBtn');
const messageEl = document.getElementById('message'); const messageEl = document.getElementById('message');
@@ -486,6 +549,114 @@
generateGerbersBtn.disabled = false; generateGerbersBtn.disabled = false;
}); });
// STEP Export
const exportStepBtn = document.getElementById('exportStepBtn');
const stepMessageEl = document.getElementById('stepMessage');
function showStepMessage(text, type) {
stepMessageEl.textContent = text;
stepMessageEl.className = 'message ' + type;
stepMessageEl.style.display = 'block';
}
exportStepBtn.addEventListener('click', () => {
exportStepBtn.disabled = true;
showStepMessage('Exporting PCB to STEP...', 'info');
socket.emit('export_step');
});
socket.on('step_status', (data) => {
showStepMessage(data.status, 'info');
});
socket.on('step_complete', (data) => {
showStepMessage('STEP file generated successfully! Downloading...', 'success');
exportStepBtn.disabled = false;
// Trigger download
const link = document.createElement('a');
link.href = '/download/' + data.filename;
link.download = data.filename;
link.click();
});
socket.on('step_error', (data) => {
showStepMessage('Error: ' + data.error, 'error');
exportStepBtn.disabled = false;
});
// PCB Render
const renderPcbBtn = document.getElementById('renderPcbBtn');
const renderMessageEl = document.getElementById('renderMessage');
function showRenderMessage(text, type) {
renderMessageEl.textContent = text;
renderMessageEl.className = 'message ' + type;
renderMessageEl.style.display = 'block';
}
renderPcbBtn.addEventListener('click', () => {
renderPcbBtn.disabled = true;
showRenderMessage('Rendering PCB image...', 'info');
socket.emit('render_pcb');
});
socket.on('render_status', (data) => {
showRenderMessage(data.status, 'info');
});
socket.on('render_complete', (data) => {
showRenderMessage('PCB image rendered successfully! Downloading...', 'success');
renderPcbBtn.disabled = false;
// Trigger download
const link = document.createElement('a');
link.href = '/download/' + data.filename;
link.download = data.filename;
link.click();
});
socket.on('render_error', (data) => {
showRenderMessage('Error: ' + data.error, 'error');
renderPcbBtn.disabled = false;
});
// BOM Generation
const generateBomBtn = document.getElementById('generateBomBtn');
const bomMessageEl = document.getElementById('bomMessage');
function showBomMessage(text, type) {
bomMessageEl.textContent = text;
bomMessageEl.className = 'message ' + type;
bomMessageEl.style.display = 'block';
}
generateBomBtn.addEventListener('click', () => {
generateBomBtn.disabled = true;
showBomMessage('Generating BOMs...', 'info');
socket.emit('generate_bom');
});
socket.on('bom_status', (data) => {
showBomMessage(data.status, 'info');
});
socket.on('bom_complete', (data) => {
showBomMessage('BOMs generated successfully! Downloading ZIP archive...', 'success');
generateBomBtn.disabled = false;
// Trigger download
const link = document.createElement('a');
link.href = '/download/' + data.filename;
link.download = data.filename;
link.click();
});
socket.on('bom_error', (data) => {
showBomMessage('Error: ' + data.error, 'error');
generateBomBtn.disabled = false;
});
// Library Synchronization // Library Synchronization
const syncLibrariesBtn = document.getElementById('syncLibrariesBtn'); const syncLibrariesBtn = document.getElementById('syncLibrariesBtn');
const syncMessageEl = document.getElementById('syncMessage'); const syncMessageEl = document.getElementById('syncMessage');
@@ -663,16 +834,98 @@
}); });
// Show selected tab // Show selected tab
if (tabName === 'main') { if (tabName === 'debug') {
document.getElementById('mainTab').classList.add('active'); document.getElementById('debugTab').classList.add('active');
document.querySelectorAll('.tab')[0].classList.add('active'); document.querySelectorAll('.tab')[0].classList.add('active');
} else if (tabName === 'publish') {
document.getElementById('publishTab').classList.add('active');
document.querySelectorAll('.tab')[1].classList.add('active');
} else if (tabName === 'library') {
document.getElementById('libraryTab').classList.add('active');
document.querySelectorAll('.tab')[2].classList.add('active');
} else if (tabName === 'variants') { } else if (tabName === 'variants') {
document.getElementById('variantsTab').classList.add('active'); document.getElementById('variantsTab').classList.add('active');
document.querySelectorAll('.tab')[1].classList.add('active'); document.querySelectorAll('.tab')[3].classList.add('active');
loadVariants(); loadVariants();
} }
} }
// ========================================
// Library Browser
// ========================================
const librarySearchInput = document.getElementById('librarySearchInput');
const librarySearchBtn = document.getElementById('librarySearchBtn');
const libraryClearBtn = document.getElementById('libraryClearBtn');
const libraryMessageEl = document.getElementById('libraryMessage');
const libraryResultsEl = document.getElementById('libraryResults');
function showLibraryMessage(text, type) {
libraryMessageEl.textContent = text;
libraryMessageEl.className = 'message ' + type;
libraryMessageEl.style.display = 'block';
}
function searchParts() {
const query = librarySearchInput.value.trim();
showLibraryMessage('Searching...', 'info');
socket.emit('search_parts', { query: query });
}
librarySearchBtn.addEventListener('click', searchParts);
librarySearchInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
searchParts();
}
});
libraryClearBtn.addEventListener('click', () => {
librarySearchInput.value = '';
libraryResultsEl.innerHTML = '<p style="color: #6c757d;">Enter a search query or click Search to browse all parts</p>';
libraryMessageEl.style.display = 'none';
});
socket.on('library_search_results', (data) => {
libraryMessageEl.style.display = 'none';
if (data.parts.length === 0) {
libraryResultsEl.innerHTML = '<p style="color: #6c757d;">No parts found</p>';
return;
}
let html = `
<p style="margin-bottom: 10px;"><strong>Found ${data.count} part(s)</strong></p>
<table style="width: 100%; border-collapse: collapse; font-size: 14px;">
<thead>
<tr style="background: #f8f9fa; border-bottom: 2px solid #dee2e6;">
<th style="padding: 10px; text-align: left; border: 1px solid #dee2e6;">IPN</th>
<th style="padding: 10px; text-align: left; border: 1px solid #dee2e6;">MPN</th>
<th style="padding: 10px; text-align: left; border: 1px solid #dee2e6;">Manufacturer</th>
<th style="padding: 10px; text-align: left; border: 1px solid #dee2e6;">Description</th>
</tr>
</thead>
<tbody>
`;
for (const part of data.parts) {
html += `
<tr style="border-bottom: 1px solid #dee2e6;">
<td style="padding: 8px; border: 1px solid #dee2e6;">${part.ipn}</td>
<td style="padding: 8px; border: 1px solid #dee2e6;">${part.mpn}</td>
<td style="padding: 8px; border: 1px solid #dee2e6;">${part.manufacturer}</td>
<td style="padding: 8px; border: 1px solid #dee2e6;">${part.description}</td>
</tr>
`;
}
html += '</tbody></table>';
libraryResultsEl.innerHTML = html;
});
socket.on('library_error', (data) => {
showLibraryMessage('Error: ' + data.error, 'error');
});
// ======================================== // ========================================
// Variant Manager // Variant Manager
// ======================================== // ========================================
@@ -821,6 +1074,10 @@
}); });
} }
socket.on('variant_status', (data) => {
showVariantMessage(data.status, 'info');
});
socket.on('variant_updated', (data) => { socket.on('variant_updated', (data) => {
showVariantMessage(data.message, 'success'); showVariantMessage(data.message, 'success');
loadVariants(); loadVariants();
@@ -877,6 +1134,7 @@
event.target.style.display = 'none'; event.target.style.display = 'none';
} }
} }
} // End of if (!libraryOnlyMode)
</script> </script>
</body> </body>
</html> </html>