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:
900
app.py
900
app.py
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|||||||
Reference in New Issue
Block a user