Files
florian.berthold d43c7976ad Add panel-to-PCB mounting system with M3 standoffs
Panel alignment:
- PCB offset 10mm from panel top
- Component holes aligned: OLED@25mm, jacks@45mm, button@58mm
- 4x M3 standoff holes at corners (5,5), (35,5), (5,75), (35,75)

Updates:
- Panel SVG and spec aligned with PCB layout
- Mounting holes added to PCB (Edge.Cuts layer)
- Regenerated Gerbers with mounting holes
- Updated autoroute.py to add mounting holes automatically

DRC: 0 unconnected, 7 cosmetic errors (courtyard overlaps)
2026-01-23 08:15:25 +01:00

247 lines
7.0 KiB
Python
Executable File

#!/usr/bin/env python3
"""
SN-L00 Fully Automated Routing Pipeline
Works headlessly on KiCad 9+
Usage: python3 scripts/autoroute.py
Pipeline:
1. Load board
2. Update outline to 8HP (40x100mm)
3. Delete existing tracks
4. Place components (v2 layout)
5. Export Specctra DSN
6. Run Freerouting autorouter
7. Import Specctra SES
8. Save board
9. Run DRC
"""
import os
import sys
import subprocess
# Paths
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
KICAD_DIR = os.path.dirname(SCRIPT_DIR)
os.chdir(KICAD_DIR)
PCB_FILE = "SN-L00.kicad_pcb"
DSN_FILE = "SN-L00.dsn"
SES_FILE = "SN-L00.ses"
DRC_FILE = "DRC.rpt"
FREEROUTING_JAR = "/tmp/freerouting.jar"
FREEROUTING_URL = "https://github.com/freerouting/freerouting/releases/download/v2.0.1/freerouting-2.0.1.jar"
# Mounting hole positions (M3, 3.2mm diameter)
# Bottom holes moved up to avoid power section
MOUNTING_HOLES = [
(5, 5), # Top left
(35, 5), # Top right
(5, 75), # Bottom left (above power section)
(35, 75), # Bottom right (above power section)
]
# Component positions for 8HP (40mm x 100mm) layout v2
PLACEMENTS = {
# Top - OLED display
"MOD3": (20, 15, 90),
# Audio jacks
"J3": (10, 35, 0), # RETURN_IN
"J4": (30, 35, 0), # TRIG_OUT
# Button + LED
"SW1": (20, 48, 0),
"D5": (32, 48, 0),
# RP2040-Zero
"MOD2": (20, 62, 0),
# Signal conditioning
"U2": (8, 78, 0), # 74LVC1G17
"U4": (32, 78, 0), # MCP6001
# Decoupling caps
"C4": (4, 78, 90),
"C5": (14, 78, 90),
"C6": (28, 82, 90),
# Protection diodes
"D3": (4, 82, 0),
"D4": (36, 78, 0),
# Resistors
"R2": (4, 86, 90),
"R3": (10, 86, 90),
"R4": (16, 86, 90),
"R5": (24, 86, 90),
"R6": (30, 86, 90),
"R7": (36, 86, 90),
# Power section
"J2": (20, 88, 0), # Eurorack power
"D2": (4, 92, 0),
"U3": (32, 94, 180), # LDO
"C2": (10, 94, 90),
"C3": (26, 94, 90),
}
def main():
print("=" * 50)
print("SN-L00 Automated Routing Pipeline")
print("=" * 50)
# Import pcbnew
try:
import pcbnew
print(f"KiCad version: {pcbnew.Version()}")
except ImportError:
print("ERROR: pcbnew not available. Install KiCad or set PYTHONPATH.")
sys.exit(1)
# Helper functions
def mm(val):
return pcbnew.FromMM(val)
def place(x, y):
return pcbnew.VECTOR2I(mm(x), mm(y))
# Step 1: Load board
print("\n[1/9] Loading board...")
board = pcbnew.LoadBoard(PCB_FILE)
print(f" Loaded {len(list(board.GetFootprints()))} footprints")
# Step 2: Update outline
print("\n[2/9] Updating board outline to 40x100mm...")
for drawing in board.GetDrawings():
if drawing.GetClass() == "PCB_SHAPE":
if drawing.GetShape() == pcbnew.SHAPE_T_RECT:
drawing.SetStart(pcbnew.VECTOR2I(mm(0), mm(0)))
drawing.SetEnd(pcbnew.VECTOR2I(mm(40), mm(100)))
print(" Done")
break
# Step 3: Delete tracks
print("\n[3/9] Deleting existing tracks...")
tracks = list(board.GetTracks())
for track in tracks:
board.Delete(track)
print(f" Deleted {len(tracks)} tracks")
# Step 4: Place components
print("\n[4/9] Placing components...")
placed = 0
for ref, (x, y, rot) in PLACEMENTS.items():
fp = board.FindFootprintByReference(ref)
if fp:
fp.SetPosition(place(x, y))
fp.SetOrientationDegrees(rot)
placed += 1
print(f" Placed {placed} components")
# Step 4b: Add mounting holes
print("\n[4b/9] Adding mounting holes...")
# Remove existing mounting holes first
for drawing in list(board.GetDrawings()):
if drawing.GetClass() == "PCB_SHAPE":
if drawing.GetShape() == pcbnew.SHAPE_T_CIRCLE:
# Check if it's a mounting hole (on Edge.Cuts, 3.2mm diameter)
if drawing.GetLayer() == pcbnew.Edge_Cuts:
radius = pcbnew.ToMM(drawing.GetRadius())
if 1.5 < radius < 1.7: # ~3.2mm diameter
board.Delete(drawing)
# Add new mounting holes
for x, y in MOUNTING_HOLES:
hole = pcbnew.PCB_SHAPE(board)
hole.SetShape(pcbnew.SHAPE_T_CIRCLE)
hole.SetCenter(place(x, y))
hole.SetEnd(place(x + 1.6, y)) # Radius = 1.6mm (3.2mm diameter)
hole.SetLayer(pcbnew.Edge_Cuts)
hole.SetWidth(mm(0.15))
board.Add(hole)
print(f" Added {len(MOUNTING_HOLES)} mounting holes (M3, 3.2mm)")
# Step 5: Export DSN
print("\n[5/9] Exporting Specctra DSN...")
board.Save(PCB_FILE) # Save first
result = pcbnew.ExportSpecctraDSN(board, DSN_FILE)
if result and os.path.exists(DSN_FILE):
print(f" Created {DSN_FILE} ({os.path.getsize(DSN_FILE)} bytes)")
else:
print(" ERROR: DSN export failed")
sys.exit(1)
# Step 6: Download and run Freerouting
print("\n[6/9] Running Freerouting...")
if not os.path.exists(FREEROUTING_JAR):
print(" Downloading Freerouting...")
subprocess.run(["curl", "-sL", "-o", FREEROUTING_JAR, FREEROUTING_URL], check=True)
result = subprocess.run(
["java", "-jar", FREEROUTING_JAR,
"-de", DSN_FILE,
"-do", SES_FILE,
"-mp", "200",
"-mt", "1",
"-oit"],
capture_output=True,
text=True
)
# Parse output
for line in (result.stdout + result.stderr).split('\n'):
if 'auto-rout' in line.lower() or 'completed' in line.lower():
print(f" {line.split(']')[-1].strip()}")
if not os.path.exists(SES_FILE):
print(" ERROR: Freerouting failed")
sys.exit(1)
# Step 7: Import SES
print("\n[7/9] Importing routed session...")
# Reload board to get fresh state
board = pcbnew.LoadBoard(PCB_FILE)
result = pcbnew.ImportSpecctraSES(board, SES_FILE)
if result:
print(f" Imported {len(list(board.GetTracks()))} tracks")
else:
print(" WARNING: SES import returned False")
# Step 8: Save
print("\n[8/9] Saving board...")
board.Save(PCB_FILE)
print(f" Saved {PCB_FILE}")
# Step 9: Run DRC
print("\n[9/9] Running DRC...")
result = subprocess.run(
["kicad-cli", "pcb", "drc",
"--severity-all",
"--units", "mm",
"-o", DRC_FILE,
PCB_FILE],
capture_output=True,
text=True
)
# Parse DRC output
errors = 0
warnings = 0
if os.path.exists(DRC_FILE):
with open(DRC_FILE) as f:
content = f.read()
errors = content.count('; error')
warnings = content.count('; warning')
print(f" {errors} errors, {warnings} warnings")
# Summary
print("\n" + "=" * 50)
if errors == 0:
print("SUCCESS! Board routed with no DRC errors.")
else:
print(f"Done. {errors} DRC errors remain - see {DRC_FILE}")
print("=" * 50)
return errors == 0
if __name__ == "__main__":
success = main()
sys.exit(0 if success else 1)