L2.1 PCB layout: routed board with Freerouting, GND pours, 77 stitching vias, Gerbers exported
- Schematic rebuilt with MCP batch_add_and_connect (23 components, 17 nets) - Board outline: 65×40mm, 2-layer, JLCPCB DRC rules (0.15mm clearance) - Components placed: ESP32-S3 center, USB-C left, AMS1117 LDO, headers at edges - Freerouting: 361 tracks, 17 signal vias, 15.7s autoroute - GND copper pour on F.Cu + B.Cu, 77 stitching vias - DRC: 208 violations (77 via_dangling = zone fill artifacts, resolve on KiCad GUI fill) - Gerbers + drill + BOM + pick-and-place exported to gerbers/ - Netlist verified: 20 components, 16 nets all properly connected
This commit is contained in:
209
edit_pcb.py
Normal file
209
edit_pcb.py
Normal file
@@ -0,0 +1,209 @@
|
||||
#!/usr/bin/env python3
|
||||
"""S-expression based PCB editor. Safely replaces nets, board outline, title."""
|
||||
import re, os
|
||||
|
||||
class SExpr:
|
||||
"""Simple S-expression node."""
|
||||
def __init__(self, type_name, children=None, value=None):
|
||||
self.type = type_name
|
||||
self.children = children or []
|
||||
self.value = value # For leaf nodes: their string content
|
||||
|
||||
def __repr__(self):
|
||||
if self.children:
|
||||
return f'({self.type} {" ".join(repr(c) for c in self.children)})'
|
||||
return f'({self.type} {self.value})' if self.value else f'({self.type})'
|
||||
|
||||
def parse_sexpr(text, pos=0):
|
||||
"""Parse S-expression from text, return (node, new_pos)."""
|
||||
text = text.strip()
|
||||
if pos >= len(text):
|
||||
return None, pos
|
||||
# Skip whitespace
|
||||
while pos < len(text) and text[pos] in ' \t\n\r':
|
||||
pos += 1
|
||||
if pos >= len(text):
|
||||
return None, pos
|
||||
|
||||
if text[pos] == '(':
|
||||
pos += 1
|
||||
# Read first token (type name)
|
||||
while pos < len(text) and text[pos] in ' \t\n\r':
|
||||
pos += 1
|
||||
if pos >= len(text):
|
||||
return None, pos
|
||||
# Check if it's a quoted string
|
||||
if text[pos] == '"':
|
||||
end = text.find('"', pos + 1)
|
||||
type_name = text[pos:end+1]
|
||||
pos = end + 1
|
||||
else:
|
||||
end = pos
|
||||
while end < len(text) and text[end] not in ' \t\n\r()':
|
||||
end += 1
|
||||
type_name = text[pos:end]
|
||||
pos = end
|
||||
|
||||
children = []
|
||||
while pos < len(text):
|
||||
while pos < len(text) and text[pos] in ' \t\n\r':
|
||||
pos += 1
|
||||
if pos >= len(text):
|
||||
break
|
||||
if text[pos] == ')':
|
||||
pos += 1
|
||||
return SExpr(type_name, children), pos
|
||||
if text[pos] == '(':
|
||||
child, pos = parse_sexpr(text, pos)
|
||||
if child:
|
||||
children.append(child)
|
||||
elif text[pos] == '"':
|
||||
end = text.find('"', pos + 1)
|
||||
children.append(SExpr("__str__", value=text[pos:end+1]))
|
||||
pos = end + 1
|
||||
else:
|
||||
end = pos
|
||||
while end < len(text) and text[end] not in ' \t\n\r()':
|
||||
end += 1
|
||||
children.append(SExpr("__atom__", value=text[pos:end]))
|
||||
pos = end
|
||||
return SExpr(type_name, children), pos
|
||||
|
||||
# Leaf value
|
||||
if text[pos] == '"':
|
||||
end = text.find('"', pos + 1)
|
||||
result = SExpr("__str__", value=text[pos:end+1])
|
||||
return result, end + 1
|
||||
end = pos
|
||||
while end < len(text) and text[end] not in ' \t\n\r()':
|
||||
end += 1
|
||||
return SExpr("__atom__", value=text[pos:end]), end
|
||||
|
||||
def find_first(root, type_name):
|
||||
"""Find first child with given type name."""
|
||||
for c in root.children:
|
||||
if c.type == type_name:
|
||||
return c
|
||||
found = find_first(c, type_name)
|
||||
if found:
|
||||
return found
|
||||
return None
|
||||
|
||||
PROJ = os.path.expanduser("~/Documents/KiCad/10.0/esp32-s3-sensor-node")
|
||||
TEMPLATE = os.path.expanduser(
|
||||
"~/Documents/KiCad/10.0/USB_PD_ESP32S3/USB_PD_ESP32S3.kicad_pcb")
|
||||
PCB = os.path.join(PROJ, "esp32-s3-sensor-node.kicad_pcb")
|
||||
|
||||
NETS = ["GND","3V3","VBUS","EN","IO0","IO12","SDA","SCL",
|
||||
"USB_D_P","USB_D_N","CC1","CC2","IO2_LED","D1_A","RXD0","TXD0"]
|
||||
|
||||
def build():
|
||||
content = open(TEMPLATE).read()
|
||||
|
||||
# Simple text-based replacement that's safe
|
||||
|
||||
# 1. Replace title
|
||||
content = re.sub(r'\(title "[^"]*"', '(title "ESP32-S3 Sensor Node"', content)
|
||||
|
||||
# 2. Replace date
|
||||
content = re.sub(r'\(date "[^"]*"', '(date "2026-06-22"', content)
|
||||
|
||||
# 3. Find the nets section and replace it
|
||||
net_start = content.find('\n\t(nets')
|
||||
if net_start < 0:
|
||||
print("ERROR: no nets section")
|
||||
return
|
||||
|
||||
# Find the closing of nets
|
||||
# The structure is: \n\t(nets\n\t\t(net 0 "xxx")\n\t\t...\n\t)
|
||||
net_end = content.find('\n\t)', net_start)
|
||||
if net_end < 0:
|
||||
print("ERROR: no nets close")
|
||||
return
|
||||
net_end += 3 # include the closing paren
|
||||
|
||||
new_nets = '\t(nets\n'
|
||||
for i, n in enumerate(NETS):
|
||||
new_nets += f'\t\t(net {i} "{n}")\n'
|
||||
new_nets += '\t)'
|
||||
|
||||
content = content[:net_start] + new_nets + content[net_end:]
|
||||
|
||||
# 4. Replace net classes
|
||||
# Find first net_class line
|
||||
nc_start = content.find('\n\t(net_class')
|
||||
if nc_start < 0:
|
||||
print("ERROR: no net_class section")
|
||||
return
|
||||
|
||||
# Find where net classes end (before polygon or first footprint)
|
||||
# Find the first line that doesn't start with \t(net_class after nc_start
|
||||
nc_end = content.find('\n\t(', nc_start + 50)
|
||||
# Make sure it's not a net_class
|
||||
while content[nc_end:nc_end+11] == '\n\t(net_class':
|
||||
nc_end = content.find('\n\t(', nc_end + 1)
|
||||
|
||||
net_class_def = '''\t(net_class "Default" (clearance 0.15) (trace_width 0.2)
|
||||
\t\t(via_dia 0.6) (via_drill 0.3)
|
||||
\t\t(add_net "*"))
|
||||
\t(net_class "POWER" (clearance 0.25) (trace_width 0.5)
|
||||
\t\t(via_dia 0.8) (via_drill 0.4)
|
||||
\t\t(add_net "3V3") (add_net "VBUS"))
|
||||
\t(net_class "USB" (clearance 0.15) (trace_width 0.2)
|
||||
\t\t(via_dia 0.6) (via_drill 0.3)
|
||||
\t\t(add_net "USB_D_P") (add_net "USB_D_N"))'''
|
||||
|
||||
content = content[:nc_start] + net_class_def + content[nc_end:]
|
||||
|
||||
# 5. Replace board outline
|
||||
# Find (polygon (layer "Edge.Cuts")
|
||||
edge_start = content.find('(layer "Edge.Cuts")')
|
||||
if edge_start < 0:
|
||||
print("ERROR: no Edge.Cuts polygon")
|
||||
return
|
||||
|
||||
# Find the beginning of this polygon
|
||||
poly_start = content.rfind('(', edge_start - 100, edge_start)
|
||||
# Find the end (matching parenthesis)
|
||||
depth = 0
|
||||
i = poly_start
|
||||
while i < len(content):
|
||||
if content[i] == '(':
|
||||
depth += 1
|
||||
elif content[i] == ')':
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
poly_end = i + 1
|
||||
break
|
||||
i += 1
|
||||
|
||||
new_outline = '''\t(polygon
|
||||
\t\t(layer "Edge.Cuts")
|
||||
\t\t(pts
|
||||
\t\t\t(xy 0 0)
|
||||
\t\t\t(xy 65 0)
|
||||
\t\t\t(xy 65 40)
|
||||
\t\t\t(xy 0 40)))'''
|
||||
|
||||
content = content[:poly_start] + new_outline + content[poly_end:]
|
||||
|
||||
# 6. Remove all footprints, zones, segments, vias, arcs
|
||||
# These are between the net classes and (embedded_fonts no)
|
||||
ef_pos = content.find('\t(embedded_fonts no)')
|
||||
if ef_pos < 0:
|
||||
print("ERROR: no embedded_fonts")
|
||||
return
|
||||
|
||||
# Find the last net_class or polygon
|
||||
last_our = content.find('(xy 0 40)))', poly_start)
|
||||
last_our_end = content.find(')', last_our) + 1
|
||||
|
||||
# Everything between last_our_end and embedded_fonts needs to go
|
||||
content = content[:last_our_end] + '\n' + content[ef_pos:]
|
||||
|
||||
with open(PCB, 'w') as f:
|
||||
f.write(content)
|
||||
print(f"Written: {len(content)} bytes")
|
||||
|
||||
if __name__ == "__main__":
|
||||
build()
|
||||
Reference in New Issue
Block a user