Add complete manufacturing build pipeline with enhanced features

Build Pipeline:
- Generate schematic PDF with all sheets
- Generate 11 individual board layer PDFs (copper, silkscreen, soldermask, paste, fabrication, outline)
- Add "Layer: <name>" text overlay to each board layer PDF
- Merge all board PDFs into single document
- Generate Gerbers with all layers
- Generate drill files in Excellon format
- Generate ODB++ package (optional)
- Export STEP 3D model
- Generate BOMs for active variant
- Package all outputs into timestamped ZIP file
- Real-time progress bar (0-100%) with status updates
- Detailed build log with timestamps
- Auto-download ZIP on completion

Symbol & Footprint Library Integration:
- Browse KiCad symbol libraries from UM_KICAD environment variable
- Live SVG preview of selected symbols with pins, graphics, and labels
- Browse KiCad footprint libraries (.pretty directories)
- Live SVG preview of selected footprints with pads and silkscreen
- Associate symbols and footprints with parts in database
- Store as LibraryName:ComponentName format

WebSocket Connection Improvements:
- Increase ping timeout to 120 seconds (from 60s default)
- Add 25-second ping interval to keep connections alive
- Wait 10 seconds for reconnection before shutdown (handles page refresh)
- Cancel shutdown timer when client reconnects
- Use hidden link download to preserve WebSocket connection (not window.location)

PDF Text Overlay:
- Add reportlab and PyPDF2 imports for PDF manipulation
- Add add_text_overlay_to_pdf() helper function
- Overlay layer names in upper left corner of board PDFs
- Use Helvetica-Bold 14pt font at position (50, 750)

Bug Fixes:
- Fix BOM generator argument order (schematic, project, variant, dnp_uuids, pcb_file)
- Pass empty JSON array '[]' for dnp_uuids instead of output directory
- Move generated BOM files from project dir to output dir for packaging
- Fix datetime import (was missing)
- Use app_args instead of config for getting schematic/board file paths

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
brentperteet
2026-02-23 13:01:35 -06:00
parent bcb2c70e93
commit 0de59dabfc
2 changed files with 1190 additions and 19 deletions

900
app.py
View File

@@ -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'<path d="{path_d}" stroke="#CC0000" stroke-width="{stroke_width}" fill="{fill_color}"/>')
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'<circle cx="{cx}" cy="{cy}" r="{radius}" stroke="#CC0000" stroke-width="{stroke_width}" fill="{fill_color}"/>')
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'<rect x="{x}" y="{y}" width="{width}" height="{height}" stroke="#CC0000" stroke-width="{stroke_width}" fill="{fill_color}"/>')
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 x="{x}" y="{y}" font-size="{size}" fill="#000080" font-family="Arial">{text_str}</text>')
# 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'<line x1="{x}" y1="{y}" x2="{x2}" y2="{y2}" stroke="#00CC00" stroke-width="0.254"/>')
svg_elements.append(f'<circle cx="{x2}" cy="{y2}" r="0.5" fill="#00CC00"/>')
# 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'<text x="{num_x}" y="{num_y}" font-size="1.8" fill="#006600" font-family="Arial" text-anchor="{anchor}" dominant-baseline="middle">{number}</text>')
# 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'<text x="{name_x}" y="{name_y}" font-size="1.6" fill="#003366" font-family="Arial" text-anchor="{anchor}" dominant-baseline="middle">{name}</text>')
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 '<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100"><text x="100" y="50" text-anchor="middle" fill="#999">No preview</text></svg>'
# 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'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="{min_x} {min_y} {width} {height}" width="{width * scale}" height="{height * scale}">
<rect width="100%" height="100%" fill="white"/>
{''.join(svg_elements)}
</svg>'''
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'<line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" stroke="{color}" stroke-width="{width}"/>')
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'<circle cx="{cx}" cy="{cy}" r="{radius}" stroke="{color}" stroke-width="{width}" fill="none"/>')
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'<rect x="{x}" y="{y}" width="{w}" height="{h}" stroke="{color}" stroke-width="{width}" fill="none"/>')
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'<rect x="{x}" y="{y}" width="{width}" height="{height}" fill="{pad_color}" stroke="#8B4513" stroke-width="0.05"{transform_attr}/>')
elif pad_shape == "circle":
r = width / 2
svg_elements.append(f'<circle cx="{px}" cy="{py}" r="{r}" fill="{pad_color}" stroke="#8B4513" stroke-width="0.05"/>')
elif pad_shape == "oval":
rx = width / 2
ry = height / 2
svg_elements.append(f'<ellipse cx="{px}" cy="{py}" rx="{rx}" ry="{ry}" fill="{pad_color}" stroke="#8B4513" stroke-width="0.05"{transform_attr}/>')
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'<rect x="{x}" y="{y}" width="{width}" height="{height}" rx="{corner_radius}" ry="{corner_radius}" fill="{pad_color}" stroke="#8B4513" stroke-width="0.05"{transform_attr}/>')
# Add pad number
svg_elements.append(f'<text x="{px}" y="{py}" font-size="0.8" fill="white" font-family="Arial" text-anchor="middle" dominant-baseline="middle">{pad_num}</text>')
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 '<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100"><text x="100" y="50" text-anchor="middle" fill="#999">No preview</text></svg>'
# 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'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="{min_x} {min_y} {width} {height}" preserveAspectRatio="xMidYMid meet">
<rect width="100%" height="100%" fill="#2C2C2C"/>
{''.join(svg_elements)}
</svg>'''
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/<filename>')
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)

View File

@@ -356,16 +356,34 @@
<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>
<button id="buildAllBtn" class="btn" style="font-size: 16px; padding: 12px 24px; margin: 20px 0;">
🚀 Build All Manufacturing Outputs
</button>
<div id="buildProgress" style="display: none; margin-top: 30px;">
<h3>Build Progress</h3>
<!-- Progress Bar -->
<div style="background: #e0e0e0; border-radius: 10px; height: 30px; margin: 20px 0; position: relative; overflow: hidden;">
<div id="buildProgressBar" style="background: linear-gradient(90deg, #007bff, #0056b3); height: 100%; width: 0%; transition: width 0.3s ease; display: flex; align-items: center; justify-content: center;">
<span id="buildProgressPercent" style="color: white; font-weight: bold; font-size: 14px; position: absolute;"></span>
</div>
</div>
<!-- Current Status -->
<div id="buildStatus" style="padding: 15px; background: #f8f9fa; border-left: 4px solid #007bff; border-radius: 5px; margin-bottom: 20px;">
<strong>Status:</strong> <span id="buildStatusText">Initializing...</span>
</div>
<!-- Build Steps Log -->
<div style="background: #f8f9fa; border-radius: 8px; padding: 15px; max-height: 300px; overflow-y: auto;">
<h4 style="margin-top: 0;">Build Log:</h4>
<div id="buildLog" style="font-family: 'Courier New', monospace; font-size: 13px; line-height: 1.6;">
</div>
</div>
</div>
<div id="buildMessage" class="message" style="margin-top: 20px;"></div>
</div>
</div>
</div><!-- End publishTab -->
@@ -427,6 +445,43 @@
<label style="display: block; font-weight: bold; margin-bottom: 5px;">Datasheet URL (optional):</label>
<input type="text" id="datasheetInput" placeholder="https://..." style="width: 100%; padding: 8px; border: 1px solid #dee2e6; border-radius: 5px; font-size: 14px;">
</div>
<!-- Symbol Selection -->
<div style="margin-bottom: 15px;">
<label style="display: block; font-weight: bold; margin-bottom: 5px;">Symbol (optional):</label>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px;">
<div>
<select id="symbolLibrarySelect" style="width: 100%; padding: 8px; margin-bottom: 10px; border: 1px solid #dee2e6; border-radius: 5px; font-size: 14px;">
<option value="">-- Select Library --</option>
</select>
<select id="symbolSelect" style="width: 100%; padding: 8px; border: 1px solid #dee2e6; border-radius: 5px; font-size: 14px;" disabled>
<option value="">-- Select Symbol --</option>
</select>
</div>
<div id="symbolPreview" style="border: 1px solid #dee2e6; border-radius: 5px; padding: 10px; background: white; min-height: 150px; display: flex; align-items: center; justify-content: center; overflow: auto;">
<span style="color: #999;">No symbol selected</span>
</div>
</div>
</div>
<!-- Footprint Selection -->
<div style="margin-bottom: 15px;">
<label style="display: block; font-weight: bold; margin-bottom: 5px;">Footprint (optional):</label>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px;">
<div>
<select id="footprintLibrarySelect" style="width: 100%; padding: 8px; margin-bottom: 10px; border: 1px solid #dee2e6; border-radius: 5px; font-size: 14px;">
<option value="">-- Select Library --</option>
</select>
<select id="footprintSelect" style="width: 100%; padding: 8px; border: 1px solid #dee2e6; border-radius: 5px; font-size: 14px;" disabled>
<option value="">-- Select Footprint --</option>
</select>
</div>
<div id="footprintPreview" style="border: 1px solid #dee2e6; border-radius: 5px; padding: 10px; background: #2C2C2C; min-height: 150px; display: flex; align-items: center; justify-content: center; overflow: auto;">
<span style="color: #999;">No footprint selected</span>
</div>
</div>
</div>
<button id="createPartBtn" class="btn" disabled>Create Part</button>
<button id="cancelCreatePartBtn" class="btn secondary" style="margin-left: 10px;">Cancel</button>
</div>
@@ -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 = '<span style="color: #999;">No symbol selected</span>';
footprintLibrarySelect.value = '';
footprintSelect.value = '';
footprintSelect.disabled = true;
footprintPreview.innerHTML = '<span style="color: #999;">No footprint selected</span>';
selectedSymbol = '';
selectedFootprint = '';
createPartBtn.disabled = true;
missingPartsData = [];
});
// Symbol library handlers
socket.on('symbol_libraries_result', (data) => {
symbolLibraries = data.libraries;
symbolLibrarySelect.innerHTML = '<option value="">-- Select Library --</option>';
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 = '<option value="">-- Select Symbol --</option>';
symbolSelect.disabled = true;
symbolPreview.innerHTML = '<span style="color: #999;">No symbol selected</span>';
selectedSymbol = '';
return;
}
selectedSymbolLibraryPath = libraryPath;
symbolSelect.innerHTML = '<option value="">Loading...</option>';
symbolSelect.disabled = true;
socket.emit('get_symbols_in_library', { library_path: libraryPath });
});
socket.on('symbols_in_library_result', (data) => {
symbolSelect.innerHTML = '<option value="">-- Select Symbol --</option>';
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 = '<span style="color: #999;">No symbol selected</span>';
selectedSymbol = '';
return;
}
symbolPreview.innerHTML = '<span style="color: #999;">Loading...</span>';
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 = '<option value="">-- Select Library --</option>';
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 = '<option value="">-- Select Footprint --</option>';
footprintSelect.disabled = true;
footprintPreview.innerHTML = '<span style="color: #999;">No footprint selected</span>';
selectedFootprint = '';
return;
}
selectedFootprintLibraryPath = libraryPath;
footprintSelect.innerHTML = '<option value="">Loading...</option>';
footprintSelect.disabled = true;
socket.emit('get_footprints_in_library', { library_path: libraryPath });
});
socket.on('footprints_in_library_result', (data) => {
footprintSelect.innerHTML = '<option value="">-- Select Footprint --</option>';
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 = '<span style="color: #999;">No footprint selected</span>';
selectedFootprint = '';
return;
}
footprintPreview.innerHTML = '<span style="color: #999;">Loading...</span>';
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 = '<span style="color: #999;">No symbol selected</span>';
footprintLibrarySelect.value = '';
footprintSelect.value = '';
footprintSelect.disabled = true;
footprintPreview.innerHTML = '<span style="color: #999;">No footprint selected</span>';
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 += `<div style="color: ${color};">[${timestamp}] ${text}</div>`;
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
// ========================================