- 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
210 lines
6.7 KiB
Python
210 lines
6.7 KiB
Python
#!/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()
|