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 shutil
|
||||
import json
|
||||
import re
|
||||
import math
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from flask import Flask, render_template, request, send_file, jsonify
|
||||
from flask_socketio import SocketIO, emit
|
||||
import time
|
||||
from PyPDF2 import PdfMerger
|
||||
from PyPDF2 import PdfMerger, PdfReader, PdfWriter
|
||||
from reportlab.pdfgen import canvas
|
||||
from reportlab.lib.pagesizes import letter
|
||||
from variant_manager import VariantManager
|
||||
import pyodbc
|
||||
from io import BytesIO
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config['SECRET_KEY'] = 'secret!'
|
||||
socketio = SocketIO(app, cors_allowed_origins="*")
|
||||
socketio = SocketIO(app,
|
||||
cors_allowed_origins="*",
|
||||
ping_timeout=120, # 2 minutes timeout
|
||||
ping_interval=25) # Send ping every 25 seconds
|
||||
|
||||
# Store arguments
|
||||
app_args = {}
|
||||
connected_clients = set()
|
||||
heartbeat_timeout = 5 # seconds
|
||||
shutdown_timer = None # Track shutdown timer to cancel if client reconnects
|
||||
|
||||
# Configuration
|
||||
config_file = 'config.json'
|
||||
@@ -45,6 +55,543 @@ def save_config():
|
||||
with open(config_file, 'w') as f:
|
||||
json.dump(app_config, f, indent=2)
|
||||
|
||||
def add_text_overlay_to_pdf(input_pdf_path, output_pdf_path, text, x=50, y=750, font_size=14):
|
||||
"""Add text overlay to upper left corner of PDF"""
|
||||
# Read the existing PDF
|
||||
reader = PdfReader(input_pdf_path)
|
||||
writer = PdfWriter()
|
||||
|
||||
# Create text overlay
|
||||
packet = BytesIO()
|
||||
can = canvas.Canvas(packet, pagesize=letter)
|
||||
can.setFont("Helvetica-Bold", font_size)
|
||||
can.drawString(x, y, text)
|
||||
can.save()
|
||||
|
||||
# Move to the beginning of the BytesIO buffer
|
||||
packet.seek(0)
|
||||
overlay_pdf = PdfReader(packet)
|
||||
|
||||
# Merge overlay with each page
|
||||
for page in reader.pages:
|
||||
page.merge_page(overlay_pdf.pages[0])
|
||||
writer.add_page(page)
|
||||
|
||||
# Write output
|
||||
with open(output_pdf_path, 'wb') as output_file:
|
||||
writer.write(output_file)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# KiCad Library Functions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def get_kicad_lib_path():
|
||||
"""Get the KiCad library path from UM_KICAD environment variable."""
|
||||
um_kicad = os.environ.get('UM_KICAD')
|
||||
if not um_kicad:
|
||||
return None
|
||||
return os.path.join(um_kicad, 'lib')
|
||||
|
||||
def get_symbol_libraries():
|
||||
"""Get list of all symbol library files."""
|
||||
lib_path = get_kicad_lib_path()
|
||||
if not lib_path:
|
||||
return []
|
||||
|
||||
symbols_dir = os.path.join(lib_path, 'symbols')
|
||||
if not os.path.exists(symbols_dir):
|
||||
return []
|
||||
|
||||
libraries = []
|
||||
for filename in os.listdir(symbols_dir):
|
||||
if filename.endswith('.kicad_sym'):
|
||||
libraries.append({
|
||||
'name': filename,
|
||||
'path': os.path.join(symbols_dir, filename)
|
||||
})
|
||||
|
||||
return sorted(libraries, key=lambda x: x['name'])
|
||||
|
||||
def get_footprint_libraries():
|
||||
"""Get list of all footprint libraries (.pretty directories)."""
|
||||
lib_path = get_kicad_lib_path()
|
||||
if not lib_path:
|
||||
return []
|
||||
|
||||
footprints_dir = os.path.join(lib_path, 'footprints')
|
||||
if not os.path.exists(footprints_dir):
|
||||
return []
|
||||
|
||||
libraries = []
|
||||
for item in os.listdir(footprints_dir):
|
||||
item_path = os.path.join(footprints_dir, item)
|
||||
if os.path.isdir(item_path) and item.endswith('.pretty'):
|
||||
libraries.append({
|
||||
'name': item,
|
||||
'path': item_path
|
||||
})
|
||||
|
||||
return sorted(libraries, key=lambda x: x['name'])
|
||||
|
||||
def parse_kicad_symbol_file(file_path):
|
||||
"""Parse a KiCad symbol library file and extract symbol names."""
|
||||
symbols = []
|
||||
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# Find all symbols (top-level only, not sub-symbols)
|
||||
symbol_pattern = r'^\t\(symbol\s+"([^"]+)"'
|
||||
symbol_names = re.findall(symbol_pattern, content, re.MULTILINE)
|
||||
|
||||
# Filter out sub-symbols (those with _X_Y suffix)
|
||||
for symbol_name in symbol_names:
|
||||
if not re.match(r'.*_\d+_\d+$', symbol_name):
|
||||
symbols.append(symbol_name)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error parsing {file_path}: {e}")
|
||||
|
||||
return sorted(symbols)
|
||||
|
||||
def get_footprints_in_library(library_path):
|
||||
"""Get list of all footprints in a .pretty library."""
|
||||
footprints = []
|
||||
|
||||
try:
|
||||
for filename in os.listdir(library_path):
|
||||
if filename.endswith('.kicad_mod'):
|
||||
footprint_name = filename[:-10] # Remove .kicad_mod extension
|
||||
footprints.append(footprint_name)
|
||||
except Exception as e:
|
||||
print(f"Error reading footprints from {library_path}: {e}")
|
||||
|
||||
return sorted(footprints)
|
||||
|
||||
def extract_symbol_graphics(file_path, symbol_name):
|
||||
"""Extract graphical elements from a symbol for rendering."""
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# Find the symbol definition
|
||||
symbol_pattern = re.compile(r'^\t\(symbol\s+"' + re.escape(symbol_name) + r'"\s*$', re.MULTILINE)
|
||||
match = symbol_pattern.search(content)
|
||||
|
||||
if not match:
|
||||
return None
|
||||
|
||||
symbol_start = match.start()
|
||||
|
||||
# Find the end of this symbol
|
||||
next_symbol_pattern = re.compile(r'^\t\(symbol\s+"[^"]+"', re.MULTILINE)
|
||||
next_match = next_symbol_pattern.search(content, symbol_start + len(symbol_name) + 10)
|
||||
|
||||
if next_match:
|
||||
symbol_block = content[symbol_start:next_match.start()]
|
||||
else:
|
||||
end_pattern = re.compile(r'^\)', re.MULTILINE)
|
||||
end_match = end_pattern.search(content, symbol_start + 1)
|
||||
if end_match:
|
||||
symbol_block = content[symbol_start:end_match.start()]
|
||||
else:
|
||||
symbol_block = content[symbol_start:]
|
||||
|
||||
# Filter out alternate De Morgan representations
|
||||
symbol_block = re.sub(
|
||||
r'^\t\t\(symbol\s+"' + re.escape(symbol_name) + r'_(\d+)_([2-9])".*?^\t\t\)',
|
||||
'',
|
||||
symbol_block,
|
||||
flags=re.MULTILINE | re.DOTALL
|
||||
)
|
||||
|
||||
return symbol_block
|
||||
except Exception as e:
|
||||
print(f"Error extracting graphics for {symbol_name}: {e}")
|
||||
return None
|
||||
|
||||
def render_symbol_to_svg(symbol_block):
|
||||
"""Convert symbol graphics to SVG."""
|
||||
if not symbol_block:
|
||||
return None
|
||||
|
||||
svg_elements = []
|
||||
min_x, min_y, max_x, max_y = 0, 0, 0, 0
|
||||
|
||||
# Parse polylines
|
||||
for polyline in re.finditer(r'\(polyline\n(.*?)\n\t+\)', symbol_block, re.DOTALL):
|
||||
polyline_text = polyline.group(0)
|
||||
points = []
|
||||
|
||||
# Extract points
|
||||
pts_match = re.search(r'\(pts\s+(.*?)\n\s*\)', polyline_text, re.DOTALL)
|
||||
if pts_match:
|
||||
pts_content = pts_match.group(1)
|
||||
xy_pattern = r'\(xy\s+([-\d.]+)\s+([-\d.]+)\)'
|
||||
for match in re.finditer(xy_pattern, pts_content):
|
||||
x, y = float(match.group(1)), -float(match.group(2))
|
||||
points.append((x, y))
|
||||
|
||||
if points:
|
||||
stroke_width = 0.254
|
||||
stroke_match = re.search(r'\(width\s+([-\d.]+)\)', polyline_text)
|
||||
if stroke_match:
|
||||
stroke_width = float(stroke_match.group(1))
|
||||
|
||||
fill_type = 'none'
|
||||
fill_match = re.search(r'\(fill\s+\(type\s+(\w+)\)', polyline_text)
|
||||
if fill_match:
|
||||
fill_type = fill_match.group(1)
|
||||
|
||||
path_d = f"M {points[0][0]} {points[0][1]}"
|
||||
for x, y in points[1:]:
|
||||
path_d += f" L {x} {y}"
|
||||
|
||||
fill_color = 'none' if fill_type == 'none' else '#FFFFCC'
|
||||
if fill_type == 'outline':
|
||||
path_d += ' Z'
|
||||
fill_color = '#FFFFCC'
|
||||
|
||||
svg_elements.append(f'<path d="{path_d}" stroke="#CC0000" stroke-width="{stroke_width}" fill="{fill_color}"/>')
|
||||
|
||||
for x, y in points:
|
||||
min_x, max_x = min(min_x, x), max(max_x, x)
|
||||
min_y, max_y = min(min_y, y), max(max_y, y)
|
||||
|
||||
# Parse circles
|
||||
for circle in re.finditer(r'\(circle\n(.*?)\n\t+\)', symbol_block, re.DOTALL):
|
||||
circle_text = circle.group(0)
|
||||
center_match = re.search(r'\(center\s+([-\d.]+)\s+([-\d.]+)\)', circle_text)
|
||||
radius_match = re.search(r'\(radius\s+([-\d.]+)\)', circle_text)
|
||||
|
||||
if center_match and radius_match:
|
||||
cx, cy = float(center_match.group(1)), -float(center_match.group(2))
|
||||
radius = float(radius_match.group(1))
|
||||
|
||||
stroke_width = 0.254
|
||||
stroke_match = re.search(r'\(width\s+([-\d.]+)\)', circle_text)
|
||||
if stroke_match:
|
||||
stroke_width = float(stroke_match.group(1))
|
||||
|
||||
fill_type = 'none'
|
||||
fill_match = re.search(r'\(fill\s+\(type\s+(\w+)\)', circle_text)
|
||||
if fill_match:
|
||||
fill_type = fill_match.group(1)
|
||||
|
||||
fill_color = 'none' if fill_type == 'none' else '#FFFFCC'
|
||||
svg_elements.append(f'<circle cx="{cx}" cy="{cy}" r="{radius}" stroke="#CC0000" stroke-width="{stroke_width}" fill="{fill_color}"/>')
|
||||
|
||||
min_x, max_x = min(min_x, cx - radius), max(max_x, cx + radius)
|
||||
min_y, max_y = min(min_y, cy - radius), max(max_y, cy + radius)
|
||||
|
||||
# Parse rectangles
|
||||
for rect in re.finditer(r'(\t+)\(rectangle\n.*?\n\1\)', symbol_block, re.DOTALL):
|
||||
rect_text = rect.group(0)
|
||||
start_match = re.search(r'\(start\s+([-\d.]+)\s+([-\d.]+)\)', rect_text)
|
||||
end_match = re.search(r'\(end\s+([-\d.]+)\s+([-\d.]+)\)', rect_text)
|
||||
|
||||
if start_match and end_match:
|
||||
x1, y1 = float(start_match.group(1)), -float(start_match.group(2))
|
||||
x2, y2 = float(end_match.group(1)), -float(end_match.group(2))
|
||||
width = abs(x2 - x1)
|
||||
height = abs(y2 - y1)
|
||||
x = min(x1, x2)
|
||||
y = min(y1, y2)
|
||||
|
||||
stroke_width = 0.254
|
||||
stroke_match = re.search(r'\(width\s+([-\d.]+)\)', rect_text)
|
||||
if stroke_match:
|
||||
stroke_width = max(float(stroke_match.group(1)), 0.1)
|
||||
|
||||
fill_type = 'none'
|
||||
fill_match = re.search(r'\(fill\s+\(type\s+(\w+)\)', rect_text)
|
||||
if fill_match:
|
||||
fill_type = fill_match.group(1)
|
||||
|
||||
fill_color = 'none' if fill_type == 'none' else '#FFFFCC'
|
||||
svg_elements.append(f'<rect x="{x}" y="{y}" width="{width}" height="{height}" stroke="#CC0000" stroke-width="{stroke_width}" fill="{fill_color}"/>')
|
||||
|
||||
min_x, max_x = min(min_x, x), max(max_x, x + width)
|
||||
min_y, max_y = min(min_y, y), max(max_y, y + height)
|
||||
|
||||
# Parse text elements
|
||||
for text in re.finditer(r'\(text\s+"([^"]*)".*?\n\t+\)', symbol_block, re.DOTALL):
|
||||
text_str = text.group(1)
|
||||
text_block = text.group(0)
|
||||
at_match = re.search(r'\(at\s+([-\d.]+)\s+([-\d.]+)', text_block)
|
||||
|
||||
if at_match and text_str:
|
||||
x, y = float(at_match.group(1)), -float(at_match.group(2))
|
||||
size = 1.27
|
||||
size_match = re.search(r'\(size\s+([-\d.]+)', text_block)
|
||||
if size_match:
|
||||
size = float(size_match.group(1))
|
||||
|
||||
svg_elements.append(f'<text x="{x}" y="{y}" font-size="{size}" fill="#000080" font-family="Arial">{text_str}</text>')
|
||||
|
||||
# Parse pins
|
||||
for pin in re.finditer(r'(\t+)\(pin\s+\w+\s+\w+\n.*?\n\1\)', symbol_block, re.DOTALL):
|
||||
pin_text = pin.group(0)
|
||||
at_match = re.search(r'\(at\s+([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)\)', pin_text)
|
||||
length_match = re.search(r'\(length\s+([-\d.]+)\)', pin_text)
|
||||
number_match = re.search(r'\(number\s+"([^"]*)"', pin_text)
|
||||
name_match = re.search(r'\(name\s+"([^"]*)"', pin_text)
|
||||
|
||||
if at_match and length_match:
|
||||
x, y = float(at_match.group(1)), -float(at_match.group(2))
|
||||
angle = float(at_match.group(3))
|
||||
length = float(length_match.group(1))
|
||||
number = number_match.group(1) if number_match else ""
|
||||
name = name_match.group(1) if name_match else ""
|
||||
|
||||
# Filter out placeholder names
|
||||
if name == "~":
|
||||
name = ""
|
||||
|
||||
angle_rad = math.radians(angle)
|
||||
x2 = x + length * math.cos(angle_rad)
|
||||
y2 = y - length * math.sin(angle_rad)
|
||||
|
||||
svg_elements.append(f'<line x1="{x}" y1="{y}" x2="{x2}" y2="{y2}" stroke="#00CC00" stroke-width="0.254"/>')
|
||||
svg_elements.append(f'<circle cx="{x2}" cy="{y2}" r="0.5" fill="#00CC00"/>')
|
||||
|
||||
# Pin number at the connection point (outside)
|
||||
if number:
|
||||
# Position number outside the pin with more offset
|
||||
num_offset = 1.5
|
||||
num_x = x - math.cos(angle_rad) * num_offset
|
||||
num_y = y + math.sin(angle_rad) * num_offset
|
||||
# Determine text anchor based on pin direction
|
||||
anchor = "middle"
|
||||
if angle == 0: # Right - number on left side
|
||||
anchor = "end"
|
||||
num_x = x - num_offset
|
||||
elif angle == 180: # Left - number on right side
|
||||
anchor = "start"
|
||||
num_x = x + num_offset
|
||||
elif angle == 90: # Down - number on top
|
||||
anchor = "middle"
|
||||
num_y = y - num_offset
|
||||
elif angle == 270: # Up - number on bottom
|
||||
anchor = "middle"
|
||||
num_y = y + num_offset
|
||||
svg_elements.append(f'<text x="{num_x}" y="{num_y}" font-size="1.8" fill="#006600" font-family="Arial" text-anchor="{anchor}" dominant-baseline="middle">{number}</text>')
|
||||
|
||||
# Pin name at the inner end (inside symbol)
|
||||
if name:
|
||||
# Position name inside the symbol with more offset from pin end
|
||||
name_offset = 1.2
|
||||
name_x = x2 - math.cos(angle_rad) * name_offset
|
||||
name_y = y2 + math.sin(angle_rad) * name_offset
|
||||
# Determine text anchor based on pin direction
|
||||
anchor = "middle"
|
||||
if angle == 0: # Right - name inside on right
|
||||
anchor = "start"
|
||||
name_x = x2 + name_offset
|
||||
elif angle == 180: # Left - name inside on left
|
||||
anchor = "end"
|
||||
name_x = x2 - name_offset
|
||||
elif angle == 90: # Down - name inside below
|
||||
anchor = "middle"
|
||||
name_y = y2 + name_offset
|
||||
elif angle == 270: # Up - name inside above
|
||||
anchor = "middle"
|
||||
name_y = y2 - name_offset
|
||||
svg_elements.append(f'<text x="{name_x}" y="{name_y}" font-size="1.6" fill="#003366" font-family="Arial" text-anchor="{anchor}" dominant-baseline="middle">{name}</text>')
|
||||
|
||||
min_x = min(min_x, x, x2)
|
||||
max_x = max(max_x, x, x2)
|
||||
min_y = min(min_y, y, y2)
|
||||
max_y = max(max_y, y, y2)
|
||||
|
||||
if not svg_elements:
|
||||
return '<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100"><text x="100" y="50" text-anchor="middle" fill="#999">No preview</text></svg>'
|
||||
|
||||
# Add padding
|
||||
padding = 5
|
||||
min_x -= padding
|
||||
min_y -= padding
|
||||
max_x += padding
|
||||
max_y += padding
|
||||
|
||||
width = max_x - min_x
|
||||
height = max_y - min_y
|
||||
|
||||
scale = 10
|
||||
|
||||
svg = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="{min_x} {min_y} {width} {height}" width="{width * scale}" height="{height * scale}">
|
||||
<rect width="100%" height="100%" fill="white"/>
|
||||
{''.join(svg_elements)}
|
||||
</svg>'''
|
||||
|
||||
return svg
|
||||
|
||||
def render_footprint_to_svg(file_path):
|
||||
"""Render a KiCad footprint to SVG."""
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
svg_elements = []
|
||||
min_x, min_y, max_x, max_y = 0, 0, 0, 0
|
||||
|
||||
# Parse fp_line elements (silkscreen/fab lines)
|
||||
for line in re.finditer(r'\(fp_line\s+(.*?)\n\t\)', content, re.DOTALL):
|
||||
line_text = line.group(1)
|
||||
start_match = re.search(r'\(start\s+([-\d.]+)\s+([-\d.]+)\)', line_text)
|
||||
end_match = re.search(r'\(end\s+([-\d.]+)\s+([-\d.]+)\)', line_text)
|
||||
width_match = re.search(r'\(width\s+([-\d.]+)\)', line_text)
|
||||
layer_match = re.search(r'\(layer\s+"([^"]+)"\)', line_text)
|
||||
|
||||
if start_match and end_match:
|
||||
x1, y1 = float(start_match.group(1)), float(start_match.group(2))
|
||||
x2, y2 = float(end_match.group(1)), float(end_match.group(2))
|
||||
width = float(width_match.group(1)) if width_match else 0.15
|
||||
layer = layer_match.group(1) if layer_match else "F.SilkS"
|
||||
|
||||
# Color by layer
|
||||
color = "#CC00CC" if "SilkS" in layer else "#888888"
|
||||
|
||||
svg_elements.append(f'<line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" stroke="{color}" stroke-width="{width}"/>')
|
||||
|
||||
min_x = min(min_x, x1, x2)
|
||||
max_x = max(max_x, x1, x2)
|
||||
min_y = min(min_y, y1, y2)
|
||||
max_y = max(max_y, y1, y2)
|
||||
|
||||
# Parse fp_circle elements
|
||||
for circle in re.finditer(r'\(fp_circle\s+(.*?)\n\t\)', content, re.DOTALL):
|
||||
circle_text = circle.group(1)
|
||||
center_match = re.search(r'\(center\s+([-\d.]+)\s+([-\d.]+)\)', circle_text)
|
||||
end_match = re.search(r'\(end\s+([-\d.]+)\s+([-\d.]+)\)', circle_text)
|
||||
width_match = re.search(r'\(width\s+([-\d.]+)\)', circle_text)
|
||||
layer_match = re.search(r'\(layer\s+"([^"]+)"\)', circle_text)
|
||||
|
||||
if center_match and end_match:
|
||||
cx, cy = float(center_match.group(1)), float(center_match.group(2))
|
||||
ex, ey = float(end_match.group(1)), float(end_match.group(2))
|
||||
radius = ((ex - cx)**2 + (ey - cy)**2)**0.5
|
||||
width = float(width_match.group(1)) if width_match else 0.15
|
||||
layer = layer_match.group(1) if layer_match else "F.SilkS"
|
||||
|
||||
color = "#CC00CC" if "SilkS" in layer else "#888888"
|
||||
|
||||
svg_elements.append(f'<circle cx="{cx}" cy="{cy}" r="{radius}" stroke="{color}" stroke-width="{width}" fill="none"/>')
|
||||
|
||||
min_x = min(min_x, cx - radius)
|
||||
max_x = max(max_x, cx + radius)
|
||||
min_y = min(min_y, cy - radius)
|
||||
max_y = max(max_y, cy + radius)
|
||||
|
||||
# Parse fp_rect elements
|
||||
for rect in re.finditer(r'\(fp_rect\s+(.*?)\n\t\)', content, re.DOTALL):
|
||||
rect_text = rect.group(1)
|
||||
start_match = re.search(r'\(start\s+([-\d.]+)\s+([-\d.]+)\)', rect_text)
|
||||
end_match = re.search(r'\(end\s+([-\d.]+)\s+([-\d.]+)\)', rect_text)
|
||||
width_match = re.search(r'\(width\s+([-\d.]+)\)', rect_text)
|
||||
layer_match = re.search(r'\(layer\s+"([^"]+)"\)', rect_text)
|
||||
|
||||
if start_match and end_match:
|
||||
x1, y1 = float(start_match.group(1)), float(start_match.group(2))
|
||||
x2, y2 = float(end_match.group(1)), float(end_match.group(2))
|
||||
width = float(width_match.group(1)) if width_match else 0.15
|
||||
layer = layer_match.group(1) if layer_match else "F.SilkS"
|
||||
|
||||
color = "#CC00CC" if "SilkS" in layer else "#888888"
|
||||
|
||||
w = abs(x2 - x1)
|
||||
h = abs(y2 - y1)
|
||||
x = min(x1, x2)
|
||||
y = min(y1, y2)
|
||||
|
||||
svg_elements.append(f'<rect x="{x}" y="{y}" width="{w}" height="{h}" stroke="{color}" stroke-width="{width}" fill="none"/>')
|
||||
|
||||
min_x = min(min_x, x)
|
||||
max_x = max(max_x, x + w)
|
||||
min_y = min(min_y, y)
|
||||
max_y = max(max_y, y + h)
|
||||
|
||||
# Parse pads
|
||||
for pad in re.finditer(r'\(pad\s+"([^"]+)"\s+(\w+)\s+(\w+)\s+(.*?)\n\t\)', content, re.DOTALL):
|
||||
pad_num = pad.group(1)
|
||||
pad_type = pad.group(2) # smd, thru_hole, np_thru_hole
|
||||
pad_shape = pad.group(3) # rect, circle, oval, roundrect
|
||||
pad_params = pad.group(4)
|
||||
|
||||
at_match = re.search(r'\(at\s+([-\d.]+)\s+([-\d.]+)(?:\s+([-\d.]+))?\)', pad_params)
|
||||
size_match = re.search(r'\(size\s+([-\d.]+)\s+([-\d.]+)\)', pad_params)
|
||||
|
||||
if at_match and size_match:
|
||||
px, py = float(at_match.group(1)), float(at_match.group(2))
|
||||
rotation = float(at_match.group(3)) if at_match.group(3) else 0
|
||||
width, height = float(size_match.group(1)), float(size_match.group(2))
|
||||
|
||||
# Color based on pad type
|
||||
pad_color = "#C87533" if pad_type == "smd" else "#FFD700"
|
||||
|
||||
# Create transform for rotation (only add if rotation is non-zero)
|
||||
transform_attr = f' transform="rotate({rotation} {px} {py})"' if rotation != 0 else ''
|
||||
|
||||
if pad_shape == "rect":
|
||||
x = px - width / 2
|
||||
y = py - height / 2
|
||||
svg_elements.append(f'<rect x="{x}" y="{y}" width="{width}" height="{height}" fill="{pad_color}" stroke="#8B4513" stroke-width="0.05"{transform_attr}/>')
|
||||
elif pad_shape == "circle":
|
||||
r = width / 2
|
||||
svg_elements.append(f'<circle cx="{px}" cy="{py}" r="{r}" fill="{pad_color}" stroke="#8B4513" stroke-width="0.05"/>')
|
||||
elif pad_shape == "oval":
|
||||
rx = width / 2
|
||||
ry = height / 2
|
||||
svg_elements.append(f'<ellipse cx="{px}" cy="{py}" rx="{rx}" ry="{ry}" fill="{pad_color}" stroke="#8B4513" stroke-width="0.05"{transform_attr}/>')
|
||||
elif pad_shape == "roundrect":
|
||||
# Extract roundrect_rratio if available
|
||||
rratio_match = re.search(r'\(roundrect_rratio\s+([-\d.]+)\)', pad_params)
|
||||
rratio = float(rratio_match.group(1)) if rratio_match else 0.25
|
||||
|
||||
# Calculate corner radius based on the smaller dimension
|
||||
corner_radius = min(width, height) * rratio
|
||||
|
||||
x = px - width / 2
|
||||
y = py - height / 2
|
||||
svg_elements.append(f'<rect x="{x}" y="{y}" width="{width}" height="{height}" rx="{corner_radius}" ry="{corner_radius}" fill="{pad_color}" stroke="#8B4513" stroke-width="0.05"{transform_attr}/>')
|
||||
|
||||
# Add pad number
|
||||
svg_elements.append(f'<text x="{px}" y="{py}" font-size="0.8" fill="white" font-family="Arial" text-anchor="middle" dominant-baseline="middle">{pad_num}</text>')
|
||||
|
||||
min_x = min(min_x, px - width / 2)
|
||||
max_x = max(max_x, px + width / 2)
|
||||
min_y = min(min_y, py - height / 2)
|
||||
max_y = max(max_y, py + height / 2)
|
||||
|
||||
if not svg_elements:
|
||||
return '<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100"><text x="100" y="50" text-anchor="middle" fill="#999">No preview</text></svg>'
|
||||
|
||||
# Add padding (as percentage of footprint size for better scaling)
|
||||
width = max_x - min_x
|
||||
height = max_y - min_y
|
||||
padding = max(width, height) * 0.15 # 15% padding
|
||||
|
||||
min_x -= padding
|
||||
min_y -= padding
|
||||
max_x += padding
|
||||
max_y += padding
|
||||
|
||||
width = max_x - min_x
|
||||
height = max_y - min_y
|
||||
|
||||
# Don't set fixed width/height - let it scale to fill container while maintaining aspect ratio
|
||||
svg = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="{min_x} {min_y} {width} {height}" preserveAspectRatio="xMidYMid meet">
|
||||
<rect width="100%" height="100%" fill="#2C2C2C"/>
|
||||
{''.join(svg_elements)}
|
||||
</svg>'''
|
||||
|
||||
return svg
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error rendering footprint: {e}")
|
||||
return None
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
# Reconstruct the command line that invoked this app
|
||||
@@ -65,18 +612,29 @@ def index():
|
||||
|
||||
@socketio.on('connect')
|
||||
def handle_connect():
|
||||
global shutdown_timer
|
||||
|
||||
# Cancel shutdown timer if client reconnects
|
||||
if shutdown_timer is not None:
|
||||
print("Client reconnected, canceling shutdown")
|
||||
shutdown_timer.cancel()
|
||||
shutdown_timer = None
|
||||
|
||||
connected_clients.add(request.sid)
|
||||
print(f"Client connected: {request.sid}")
|
||||
|
||||
@socketio.on('disconnect')
|
||||
def handle_disconnect():
|
||||
global shutdown_timer
|
||||
|
||||
connected_clients.discard(request.sid)
|
||||
print(f"Client disconnected: {request.sid}")
|
||||
|
||||
# Shutdown if no clients connected
|
||||
# Shutdown if no clients connected after waiting for potential reconnect
|
||||
if not connected_clients:
|
||||
print("No clients connected. Shutting down...")
|
||||
threading.Timer(1.0, shutdown_server).start()
|
||||
print("No clients connected. Waiting 10 seconds for reconnect before shutting down...")
|
||||
shutdown_timer = threading.Timer(10.0, shutdown_server)
|
||||
shutdown_timer.start()
|
||||
|
||||
@socketio.on('heartbeat')
|
||||
def handle_heartbeat():
|
||||
@@ -1191,6 +1749,8 @@ def handle_create_part(data):
|
||||
description = data.get('description', '').strip()
|
||||
class_value = data.get('class', '').strip()
|
||||
datasheet = data.get('datasheet', '').strip()
|
||||
symbol = data.get('symbol', '').strip()
|
||||
footprint = data.get('footprint', '').strip()
|
||||
|
||||
if not ipn:
|
||||
emit('library_error', {'error': 'IPN is required'})
|
||||
@@ -1213,9 +1773,9 @@ def handle_create_part(data):
|
||||
|
||||
# Insert new part
|
||||
cursor.execute("""
|
||||
INSERT INTO parts (ipn, manufacturer, mpn, description, class, datasheet)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""", (ipn, manufacturer, mpn, description, class_value, datasheet))
|
||||
INSERT INTO parts (ipn, manufacturer, mpn, description, class, datasheet, symbol, footprint)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (ipn, manufacturer, mpn, description, class_value, datasheet, symbol, footprint))
|
||||
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
@@ -1227,6 +1787,108 @@ def handle_create_part(data):
|
||||
import traceback
|
||||
emit('library_error', {'error': f'{str(e)}\n\nTraceback:\n{traceback.format_exc()}'})
|
||||
|
||||
@socketio.on('get_symbol_libraries')
|
||||
def handle_get_symbol_libraries():
|
||||
try:
|
||||
libraries = get_symbol_libraries()
|
||||
emit('symbol_libraries_result', {'libraries': libraries})
|
||||
except Exception as e:
|
||||
import traceback
|
||||
emit('library_error', {'error': f'{str(e)}\n\nTraceback:\n{traceback.format_exc()}'})
|
||||
|
||||
@socketio.on('get_symbols_in_library')
|
||||
def handle_get_symbols_in_library(data):
|
||||
try:
|
||||
library_path = data.get('library_path', '')
|
||||
if not library_path or not os.path.exists(library_path):
|
||||
emit('library_error', {'error': 'Invalid library path'})
|
||||
return
|
||||
|
||||
symbols = parse_kicad_symbol_file(library_path)
|
||||
emit('symbols_in_library_result', {'symbols': symbols})
|
||||
except Exception as e:
|
||||
import traceback
|
||||
emit('library_error', {'error': f'{str(e)}\n\nTraceback:\n{traceback.format_exc()}'})
|
||||
|
||||
@socketio.on('render_symbol')
|
||||
def handle_render_symbol(data):
|
||||
try:
|
||||
library_path = data.get('library_path', '')
|
||||
symbol_name = data.get('symbol_name', '')
|
||||
|
||||
if not library_path or not symbol_name:
|
||||
emit('library_error', {'error': 'Missing library path or symbol name'})
|
||||
return
|
||||
|
||||
if not os.path.exists(library_path):
|
||||
emit('library_error', {'error': 'Library file not found'})
|
||||
return
|
||||
|
||||
symbol_block = extract_symbol_graphics(library_path, symbol_name)
|
||||
svg = render_symbol_to_svg(symbol_block)
|
||||
|
||||
if svg:
|
||||
emit('symbol_render_result', {'svg': svg, 'symbol_name': symbol_name})
|
||||
else:
|
||||
emit('library_error', {'error': f'Could not render symbol {symbol_name}'})
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
emit('library_error', {'error': f'{str(e)}\n\nTraceback:\n{traceback.format_exc()}'})
|
||||
|
||||
@socketio.on('get_footprint_libraries')
|
||||
def handle_get_footprint_libraries():
|
||||
try:
|
||||
libraries = get_footprint_libraries()
|
||||
emit('footprint_libraries_result', {'libraries': libraries})
|
||||
except Exception as e:
|
||||
import traceback
|
||||
emit('library_error', {'error': f'{str(e)}\n\nTraceback:\n{traceback.format_exc()}'})
|
||||
|
||||
@socketio.on('get_footprints_in_library')
|
||||
def handle_get_footprints_in_library(data):
|
||||
try:
|
||||
library_path = data.get('library_path', '')
|
||||
if not library_path or not os.path.exists(library_path):
|
||||
emit('library_error', {'error': 'Invalid library path'})
|
||||
return
|
||||
|
||||
footprints = get_footprints_in_library(library_path)
|
||||
emit('footprints_in_library_result', {'footprints': footprints})
|
||||
except Exception as e:
|
||||
import traceback
|
||||
emit('library_error', {'error': f'{str(e)}\n\nTraceback:\n{traceback.format_exc()}'})
|
||||
|
||||
@socketio.on('render_footprint')
|
||||
def handle_render_footprint(data):
|
||||
try:
|
||||
library_path = data.get('library_path', '')
|
||||
footprint_name = data.get('footprint_name', '')
|
||||
|
||||
if not library_path or not footprint_name:
|
||||
emit('library_error', {'error': 'Missing library path or footprint name'})
|
||||
return
|
||||
|
||||
if not os.path.exists(library_path):
|
||||
emit('library_error', {'error': 'Library directory not found'})
|
||||
return
|
||||
|
||||
footprint_file = os.path.join(library_path, f'{footprint_name}.kicad_mod')
|
||||
if not os.path.exists(footprint_file):
|
||||
emit('library_error', {'error': f'Footprint file not found: {footprint_name}.kicad_mod'})
|
||||
return
|
||||
|
||||
svg = render_footprint_to_svg(footprint_file)
|
||||
|
||||
if svg:
|
||||
emit('footprint_render_result', {'svg': svg, 'footprint_name': footprint_name})
|
||||
else:
|
||||
emit('library_error', {'error': f'Could not render footprint {footprint_name}'})
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
emit('library_error', {'error': f'{str(e)}\n\nTraceback:\n{traceback.format_exc()}'})
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# BOM Generation
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1298,6 +1960,228 @@ def handle_generate_bom():
|
||||
import traceback
|
||||
emit('bom_error', {'error': f'{str(e)}\n\nTraceback:\n{traceback.format_exc()}'})
|
||||
|
||||
@socketio.on('build_all')
|
||||
def handle_build_all():
|
||||
"""Generate all manufacturing outputs: PDFs, Gerbers, Drill, ODB++, STEP, BOMs"""
|
||||
try:
|
||||
kicad_cli = app_args.get('Kicad Cli', '')
|
||||
sch_file = app_args.get('Schematic File', '')
|
||||
board_file = app_args.get('Board File', '')
|
||||
project_dir = app_args.get('Project Dir', '')
|
||||
project_name = app_args.get('Project Name', 'project')
|
||||
variant = app_args.get('Variant', 'default')
|
||||
|
||||
if not kicad_cli or not sch_file or not board_file:
|
||||
emit('build_error', {'error': 'Schematic or board file not configured'})
|
||||
return
|
||||
|
||||
if not os.path.exists(sch_file):
|
||||
emit('build_error', {'error': f'Schematic file not found: {sch_file}'})
|
||||
return
|
||||
|
||||
if not os.path.exists(board_file):
|
||||
emit('build_error', {'error': f'Board file not found: {board_file}'})
|
||||
return
|
||||
|
||||
# Create output directory
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
output_dir = os.path.join(project_dir, f'manufacturing_{timestamp}')
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
# ===== STEP 1: Generate Schematic PDF =====
|
||||
emit('build_progress', {'percent': 5, 'status': 'Generating schematic PDF...', 'message': 'Starting schematic PDF generation'})
|
||||
|
||||
schematics_dir = os.path.join(output_dir, 'schematics')
|
||||
os.makedirs(schematics_dir, exist_ok=True)
|
||||
|
||||
sch_pdf_path = os.path.join(schematics_dir, f'{project_name}_schematic.pdf')
|
||||
cmd = [kicad_cli, 'sch', 'export', 'pdf', sch_file, '-o', sch_pdf_path]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
|
||||
if result.returncode != 0:
|
||||
emit('build_error', {'error': f'Schematic PDF generation failed: {result.stderr}'})
|
||||
return
|
||||
|
||||
# ===== STEP 2: Generate Board Layer PDFs =====
|
||||
emit('build_progress', {'percent': 10, 'status': 'Generating board layer PDFs...', 'message': 'Schematic PDF complete'})
|
||||
|
||||
board_dir = os.path.join(output_dir, 'board')
|
||||
os.makedirs(board_dir, exist_ok=True)
|
||||
temp_pdf_dir = os.path.join(output_dir, 'temp_pdfs')
|
||||
os.makedirs(temp_pdf_dir, exist_ok=True)
|
||||
|
||||
# All layers to export
|
||||
layers = [
|
||||
('F.Cu', 'Top_Copper'),
|
||||
('B.Cu', 'Bottom_Copper'),
|
||||
('F.Silkscreen', 'Top_Silkscreen'),
|
||||
('B.Silkscreen', 'Bottom_Silkscreen'),
|
||||
('F.Mask', 'Top_Soldermask'),
|
||||
('B.Mask', 'Bottom_Soldermask'),
|
||||
('F.Paste', 'Top_Paste'),
|
||||
('B.Paste', 'Bottom_Paste'),
|
||||
('Edge.Cuts', 'Board_Outline'),
|
||||
('F.Fab', 'Top_Fabrication'),
|
||||
('B.Fab', 'Bottom_Fabrication'),
|
||||
]
|
||||
|
||||
pdf_files = []
|
||||
for layer_name, file_suffix in layers:
|
||||
pdf_path_temp = os.path.join(temp_pdf_dir, f'{file_suffix}_temp.pdf')
|
||||
pdf_path = os.path.join(temp_pdf_dir, f'{file_suffix}.pdf')
|
||||
|
||||
# Include Edge.Cuts on every layer except the Edge.Cuts layer itself
|
||||
if layer_name == 'Edge.Cuts':
|
||||
layers_to_export = layer_name
|
||||
else:
|
||||
layers_to_export = f"{layer_name},Edge.Cuts"
|
||||
|
||||
cmd = [
|
||||
kicad_cli, 'pcb', 'export', 'pdf',
|
||||
board_file,
|
||||
'-l', layers_to_export,
|
||||
'--include-border-title',
|
||||
'-o', pdf_path_temp
|
||||
]
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if result.returncode == 0:
|
||||
# Add layer name text overlay
|
||||
layer_text = f"Layer: {file_suffix.replace('_', ' ')}"
|
||||
add_text_overlay_to_pdf(pdf_path_temp, pdf_path, layer_text)
|
||||
pdf_files.append(pdf_path)
|
||||
|
||||
# Merge all PDFs into one
|
||||
if pdf_files:
|
||||
emit('build_progress', {'percent': 25, 'status': 'Merging board layer PDFs...', 'message': f'Generated {len(pdf_files)} layer PDFs'})
|
||||
merged_pdf_path = os.path.join(board_dir, f'{project_name}.pdf')
|
||||
merger = PdfMerger()
|
||||
|
||||
for pdf in pdf_files:
|
||||
merger.append(pdf)
|
||||
|
||||
merger.write(merged_pdf_path)
|
||||
merger.close()
|
||||
|
||||
# Delete temp PDF directory
|
||||
shutil.rmtree(temp_pdf_dir)
|
||||
|
||||
# ===== STEP 3: Generate Gerbers =====
|
||||
emit('build_progress', {'percent': 35, 'status': 'Generating Gerbers...', 'message': 'Board PDFs complete, starting Gerber generation'})
|
||||
|
||||
gerber_dir = os.path.join(output_dir, 'gerbers')
|
||||
os.makedirs(gerber_dir, exist_ok=True)
|
||||
|
||||
cmd = [kicad_cli, 'pcb', 'export', 'gerbers', board_file, '-o', gerber_dir]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
|
||||
if result.returncode != 0:
|
||||
emit('build_error', {'error': f'Gerber generation failed: {result.stderr}'})
|
||||
return
|
||||
|
||||
# ===== STEP 4: Generate Drill Files =====
|
||||
emit('build_progress', {'percent': 50, 'status': 'Generating drill files...', 'message': 'Gerbers complete'})
|
||||
|
||||
cmd = [kicad_cli, 'pcb', 'export', 'drill', board_file, '-o', gerber_dir]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
|
||||
if result.returncode != 0:
|
||||
emit('build_progress', {'percent': 55, 'status': 'Drill file warning', 'message': f'Drill generation had issues: {result.stderr}'})
|
||||
|
||||
# ===== STEP 5: Generate ODB++ =====
|
||||
emit('build_progress', {'percent': 60, 'status': 'Generating ODB++...', 'message': 'Drill files complete'})
|
||||
|
||||
odb_dir = os.path.join(output_dir, 'odb')
|
||||
os.makedirs(odb_dir, exist_ok=True)
|
||||
odb_file = os.path.join(odb_dir, f'{project_name}.zip')
|
||||
cmd = [kicad_cli, 'pcb', 'export', 'odb', board_file, '-o', odb_file]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
|
||||
if result.returncode != 0:
|
||||
emit('build_progress', {'percent': 65, 'status': 'ODB++ generation skipped', 'message': 'ODB++ not available, continuing...'})
|
||||
else:
|
||||
emit('build_progress', {'percent': 65, 'status': 'ODB++ complete', 'message': 'ODB++ generation successful'})
|
||||
|
||||
# ===== STEP 6: Export STEP =====
|
||||
emit('build_progress', {'percent': 70, 'status': 'Exporting STEP model...', 'message': 'Starting 3D model export'})
|
||||
|
||||
step_file = os.path.join(output_dir, f'{project_name}.step')
|
||||
cmd = [kicad_cli, 'pcb', 'export', 'step', board_file, '-o', step_file, '--subst-models']
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
|
||||
if result.returncode != 0:
|
||||
emit('build_progress', {'percent': 75, 'status': 'STEP export failed', 'message': f'STEP export error: {result.stderr}'})
|
||||
else:
|
||||
emit('build_progress', {'percent': 75, 'status': 'STEP export complete', 'message': 'STEP model generated'})
|
||||
|
||||
# ===== STEP 7: Generate BOMs =====
|
||||
emit('build_progress', {'percent': 80, 'status': 'Generating BOMs...', 'message': 'Starting BOM generation'})
|
||||
|
||||
bom_script = os.path.join(os.path.dirname(__file__), 'bom_generator.py')
|
||||
|
||||
# BOM generator expects: schematic_file, project_name, variant_name, [dnp_uuids_json], [pcb_file]
|
||||
cmd = [sys.executable, bom_script, sch_file, project_name, variant, '[]']
|
||||
if board_file:
|
||||
cmd.append(board_file)
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
|
||||
if result.returncode != 0:
|
||||
emit('build_progress', {'percent': 90, 'status': 'BOM generation failed', 'message': f'BOM error: {result.stderr}'})
|
||||
else:
|
||||
# Move BOM files from schematic directory to output directory
|
||||
bom_base_name = f'{project_name}_{variant}'
|
||||
bom_files = [
|
||||
f'{bom_base_name}_BOM.xlsx',
|
||||
f'{bom_base_name}_Not_Populated.csv',
|
||||
f'{bom_base_name}_BOM_Top.xlsx',
|
||||
f'{bom_base_name}_BOM_Bottom.xlsx'
|
||||
]
|
||||
|
||||
for bom_file in bom_files:
|
||||
src_path = os.path.join(project_dir, bom_file)
|
||||
if os.path.exists(src_path):
|
||||
dst_path = os.path.join(output_dir, bom_file)
|
||||
shutil.move(src_path, dst_path)
|
||||
|
||||
emit('build_progress', {'percent': 90, 'status': 'BOMs complete', 'message': 'BOM files generated'})
|
||||
|
||||
# ===== STEP 8: Create ZIP Package =====
|
||||
emit('build_progress', {'percent': 95, 'status': 'Packaging outputs...', 'message': 'Creating ZIP archive'})
|
||||
|
||||
zip_filename = f'{project_name}_manufacturing_{timestamp}.zip'
|
||||
zip_path = os.path.join(project_dir, zip_filename)
|
||||
|
||||
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
|
||||
# Add all files from output directory
|
||||
for root, dirs, files in os.walk(output_dir):
|
||||
for file in files:
|
||||
file_path = os.path.join(root, file)
|
||||
arcname = os.path.relpath(file_path, output_dir)
|
||||
zipf.write(file_path, arcname)
|
||||
|
||||
emit('build_progress', {'percent': 100, 'status': 'Build complete!', 'message': f'Package ready: {zip_filename}'})
|
||||
emit('build_complete', {'download_url': f'/download_build/{zip_filename}', 'filename': zip_filename})
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
emit('build_error', {'error': f'{str(e)}\n\nTraceback:\n{traceback.format_exc()}'})
|
||||
|
||||
@app.route('/download_build/<filename>')
|
||||
def download_build(filename):
|
||||
"""Serve the manufacturing output ZIP file"""
|
||||
sch_file = app_args.get('Schematic File', '')
|
||||
if not sch_file:
|
||||
return "Configuration error", 500
|
||||
|
||||
project_dir = os.path.dirname(sch_file)
|
||||
file_path = os.path.join(project_dir, filename)
|
||||
|
||||
if not os.path.exists(file_path):
|
||||
return "File not found", 404
|
||||
|
||||
return send_file(file_path, as_attachment=True, download_name=filename)
|
||||
|
||||
def shutdown_server():
|
||||
print("Server stopped")
|
||||
os._exit(0)
|
||||
|
||||
Reference in New Issue
Block a user