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 tempfile
import shutil import shutil
import json import json
import re
import math
from pathlib import Path from pathlib import Path
from datetime import datetime
from flask import Flask, render_template, request, send_file, jsonify from flask import Flask, render_template, request, send_file, jsonify
from flask_socketio import SocketIO, emit from flask_socketio import SocketIO, emit
import time 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 from variant_manager import VariantManager
import pyodbc import pyodbc
from io import BytesIO
app = Flask(__name__) app = Flask(__name__)
app.config['SECRET_KEY'] = 'secret!' 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 # Store arguments
app_args = {} app_args = {}
connected_clients = set() connected_clients = set()
heartbeat_timeout = 5 # seconds heartbeat_timeout = 5 # seconds
shutdown_timer = None # Track shutdown timer to cancel if client reconnects
# Configuration # Configuration
config_file = 'config.json' config_file = 'config.json'
@@ -45,6 +55,543 @@ def save_config():
with open(config_file, 'w') as f: with open(config_file, 'w') as f:
json.dump(app_config, f, indent=2) 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('/') @app.route('/')
def index(): def index():
# Reconstruct the command line that invoked this app # Reconstruct the command line that invoked this app
@@ -65,18 +612,29 @@ def index():
@socketio.on('connect') @socketio.on('connect')
def handle_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) connected_clients.add(request.sid)
print(f"Client connected: {request.sid}") print(f"Client connected: {request.sid}")
@socketio.on('disconnect') @socketio.on('disconnect')
def handle_disconnect(): def handle_disconnect():
global shutdown_timer
connected_clients.discard(request.sid) connected_clients.discard(request.sid)
print(f"Client disconnected: {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: if not connected_clients:
print("No clients connected. Shutting down...") print("No clients connected. Waiting 10 seconds for reconnect before shutting down...")
threading.Timer(1.0, shutdown_server).start() shutdown_timer = threading.Timer(10.0, shutdown_server)
shutdown_timer.start()
@socketio.on('heartbeat') @socketio.on('heartbeat')
def handle_heartbeat(): def handle_heartbeat():
@@ -1191,6 +1749,8 @@ def handle_create_part(data):
description = data.get('description', '').strip() description = data.get('description', '').strip()
class_value = data.get('class', '').strip() class_value = data.get('class', '').strip()
datasheet = data.get('datasheet', '').strip() datasheet = data.get('datasheet', '').strip()
symbol = data.get('symbol', '').strip()
footprint = data.get('footprint', '').strip()
if not ipn: if not ipn:
emit('library_error', {'error': 'IPN is required'}) emit('library_error', {'error': 'IPN is required'})
@@ -1213,9 +1773,9 @@ def handle_create_part(data):
# Insert new part # Insert new part
cursor.execute(""" cursor.execute("""
INSERT INTO parts (ipn, manufacturer, mpn, description, class, datasheet) INSERT INTO parts (ipn, manufacturer, mpn, description, class, datasheet, symbol, footprint)
VALUES (?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", (ipn, manufacturer, mpn, description, class_value, datasheet)) """, (ipn, manufacturer, mpn, description, class_value, datasheet, symbol, footprint))
conn.commit() conn.commit()
cursor.close() cursor.close()
@@ -1227,6 +1787,108 @@ def handle_create_part(data):
import traceback import traceback
emit('library_error', {'error': f'{str(e)}\n\nTraceback:\n{traceback.format_exc()}'}) 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 # BOM Generation
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -1298,6 +1960,228 @@ def handle_generate_bom():
import traceback import traceback
emit('bom_error', {'error': f'{str(e)}\n\nTraceback:\n{traceback.format_exc()}'}) 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(): def shutdown_server():
print("Server stopped") print("Server stopped")
os._exit(0) os._exit(0)

View File

@@ -356,16 +356,34 @@
<p>Generate all manufacturing outputs in a single operation with progress feedback.</p> <p>Generate all manufacturing outputs in a single operation with progress feedback.</p>
<div class="actions"> <div class="actions">
<h3>Coming Soon</h3> <button id="buildAllBtn" class="btn" style="font-size: 16px; padding: 12px 24px; margin: 20px 0;">
<p>This tab will allow you to:</p> 🚀 Build All Manufacturing Outputs
<ul style="margin-left: 20px; line-height: 1.8;"> </button>
<li>Generate PDFs (Schematic + Board)</li>
<li>Export Gerbers & Drill Files</li> <div id="buildProgress" style="display: none; margin-top: 30px;">
<li>Export STEP model</li> <h3>Build Progress</h3>
<li>Generate BOM</li>
<li>Run design checks</li> <!-- Progress Bar -->
</ul> <div style="background: #e0e0e0; border-radius: 10px; height: 30px; margin: 20px 0; position: relative; overflow: hidden;">
<p style="margin-top: 20px;">All with a single button press and real-time progress tracking.</p> <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> </div>
</div><!-- End publishTab --> </div><!-- End publishTab -->
@@ -427,6 +445,43 @@
<label style="display: block; font-weight: bold; margin-bottom: 5px;">Datasheet URL (optional):</label> <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;"> <input type="text" id="datasheetInput" placeholder="https://..." style="width: 100%; padding: 8px; border: 1px solid #dee2e6; border-radius: 5px; font-size: 14px;">
</div> </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="createPartBtn" class="btn" disabled>Create Part</button>
<button id="cancelCreatePartBtn" class="btn secondary" style="margin-left: 10px;">Cancel</button> <button id="cancelCreatePartBtn" class="btn secondary" style="margin-left: 10px;">Cancel</button>
</div> </div>
@@ -990,7 +1045,21 @@
const datasheetInput = document.getElementById('datasheetInput'); const datasheetInput = document.getElementById('datasheetInput');
const createPartBtn = document.getElementById('createPartBtn'); 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 missingPartsData = [];
let symbolLibraries = [];
let footprintLibraries = [];
let selectedSymbolLibraryPath = '';
let selectedFootprintLibraryPath = '';
let selectedSymbol = '';
let selectedFootprint = '';
function showCreatePartMessage(text, type) { function showCreatePartMessage(text, type) {
createPartMessage.textContent = text; createPartMessage.textContent = text;
@@ -1006,6 +1075,9 @@
createPartForm.style.display = 'none'; createPartForm.style.display = 'none';
createPartMessage.style.display = 'none'; createPartMessage.style.display = 'none';
socket.emit('get_missing_ipns'); 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 // Cancel and return to browser
@@ -1020,10 +1092,142 @@
manufacturerInput.value = ''; manufacturerInput.value = '';
mpnInput.value = ''; mpnInput.value = '';
datasheetInput.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; createPartBtn.disabled = true;
missingPartsData = []; 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) => { socket.on('missing_ipns_result', (data) => {
createPartFormLoading.style.display = 'none'; createPartFormLoading.style.display = 'none';
@@ -1085,7 +1289,9 @@
class: part.class, class: part.class,
manufacturer: part.manufacturer, manufacturer: part.manufacturer,
mpn: part.mpn, mpn: part.mpn,
datasheet: datasheetInput.value.trim() datasheet: datasheetInput.value.trim(),
symbol: selectedSymbol,
footprint: selectedFootprint
}); });
}); });
@@ -1110,6 +1316,16 @@
manufacturerInput.value = ''; manufacturerInput.value = '';
mpnInput.value = ''; mpnInput.value = '';
datasheetInput.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; createPartBtn.disabled = true;
if (missingPartsData.length === 0) { 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 // Variant Manager
// ======================================== // ========================================