Files

582 lines
27 KiB
Python

#!/usr/bin/env python3
"""Erzeugt Schaltplan (.kicad_sch) + Projekt-Libs aus design.py (eine Quelle für Schaltplan und Platine)."""
import re, uuid, os, sys, json
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from design import PARTS, PROJECT, NOCONNECT
SYMDIR = '/usr/share/kicad/symbols'
HERE = os.path.dirname(os.path.abspath(__file__))
U = lambda: str(uuid.uuid5(uuid.NAMESPACE_URL, PROJECT + ':' + '|'.join(map(str, _ctr.__next__()))))
def _gen():
i = 0
while True:
i += 1; yield (i,)
_ctr = _gen()
# ---------- minimal s-expression helpers ----------
def tokenize(s):
return re.findall(r'\(|\)|"(?:\\.|[^"\\])*"|[^\s()"]+', s)
def parse(tokens, i=0):
out = []
while i < len(tokens):
t = tokens[i]
if t == '(':
sub, i = parse(tokens, i + 1); out.append(sub)
elif t == ')':
return out, i + 1
else:
out.append(t); i += 1
return out, i
def dump(x, ind=0):
if not isinstance(x, list):
return x
if all(not isinstance(e, list) for e in x):
return '(' + ' '.join(x) + ')'
s = '(' + ' '.join(e for e in x[:2] if not isinstance(e, list))
rest = x[2:] if len(x) > 1 and not isinstance(x[1], list) else x[1:]
if isinstance(x[1], list):
s = '(' + x[0]; rest = x[1:]
for e in rest:
s += '\n' + ' ' * (ind + 1) + (dump(e, ind + 1) if isinstance(e, list) else e)
return s + ')'
def q(s):
return '"' + str(s).replace('\\', '\\\\').replace('"', '\\"') + '"'
_libcache = {}
def lib_symbol(libid):
lib, name = libid.split(':')
if lib == PROJECT:
path = os.path.join(HERE, PROJECT + '.kicad_sym')
else:
path = os.path.join(SYMDIR, lib + '.kicad_sym')
if path not in _libcache:
_libcache[path] = parse(tokenize(open(path).read()))[0][0]
for e in _libcache[path]:
if isinstance(e, list) and e[0] == 'symbol' and e[1] == q(name):
if any(isinstance(k, list) and k[0] == 'extends' for k in e):
raise SystemExit('extends not supported: ' + libid)
import copy
s = copy.deepcopy(e); s[1] = q(libid)
return s
raise SystemExit('symbol not found ' + libid)
def pins_of(sym):
res = {}
def walk(x):
for e in x:
if isinstance(e, list):
if e[0] == 'pin':
at = next(k for k in e if isinstance(k, list) and k[0] == 'at')
num = next(k for k in e if isinstance(k, list) and k[0] == 'number')[1].strip('"')
res[num] = (float(at[1]), float(at[2]), int(float(at[3])) if len(at) > 3 else 0)
else:
walk(e)
walk(sym)
return res
# ---------- project symbol library (Wemos, ADS1115 module) ----------
def rect_symbol(name, ref, left, right, w=15.24, desc=''):
"""left/right: list of (number, name, type). pins 2.54 apart."""
n = max(len(left), len(right))
h = (n + 1) * 2.54
top = h / 2
pins = []
for i, (num, nm, tp) in enumerate(left):
y = top - 2.54 * (i + 1)
pins.append(f'(pin {tp} line (at {-w/2-2.54:.2f} {y:.2f} 0) (length 2.54) (name {q(nm)} (effects (font (size 1.27 1.27)))) (number {q(num)} (effects (font (size 1.27 1.27)))))')
for i, (num, nm, tp) in enumerate(right):
y = top - 2.54 * (i + 1)
pins.append(f'(pin {tp} line (at {w/2+2.54:.2f} {y:.2f} 180) (length 2.54) (name {q(nm)} (effects (font (size 1.27 1.27)))) (number {q(num)} (effects (font (size 1.27 1.27)))))')
return f'''(symbol {q(name)} (pin_names (offset 1.016)) (exclude_from_sim no) (in_bom yes) (on_board yes)
(property "Reference" {q(ref)} (at 0 {top+1.27:.2f} 0) (effects (font (size 1.27 1.27))))
(property "Value" {q(name)} (at 0 {-top-1.27:.2f} 0) (effects (font (size 1.27 1.27))))
(property "Footprint" "" (at 0 0 0) (effects (font (size 1.27 1.27)) (hide yes)))
(property "Datasheet" "" (at 0 0 0) (effects (font (size 1.27 1.27)) (hide yes)))
(property "Description" {q(desc)} (at 0 0 0) (effects (font (size 1.27 1.27)) (hide yes)))
(symbol {q(name + '_0_1')} (rectangle (start {-w/2:.2f} {top:.2f}) (end {w/2:.2f} {-top:.2f}) (stroke (width 0.254) (type default)) (fill (type background))))
(symbol {q(name + '_1_1')} {' '.join(pins)}))'''
def write_projlib():
wemos = rect_symbol('WEMOS_D1_mini', 'A',
[('1', 'RST', 'input'), ('2', 'A0', 'input'), ('3', 'D0/GPIO16', 'bidirectional'), ('4', 'D5/GPIO14', 'bidirectional'),
('5', 'D6/GPIO12', 'bidirectional'), ('6', 'D7/GPIO13', 'bidirectional'), ('7', 'D8/GPIO15', 'bidirectional'), ('8', '3V3', 'power_out')],
[('9', 'TX/GPIO1', 'bidirectional'), ('10', 'RX/GPIO3', 'bidirectional'), ('11', 'D1/GPIO5', 'bidirectional'), ('12', 'D2/GPIO4', 'bidirectional'),
('13', 'D3/GPIO0', 'bidirectional'), ('14', 'D4/GPIO2', 'bidirectional'), ('15', 'GND', 'power_in'), ('16', '5V', 'power_in')],
w=30.48, desc='Wemos D1 mini (ESP8266) auf 2x 1x8 Buchsenleisten')
ads = rect_symbol('ADS1115_Modul', 'A',
[('1', 'VDD', 'power_in'), ('2', 'GND', 'power_in'), ('3', 'SCL', 'input'), ('4', 'SDA', 'bidirectional'), ('5', 'ADDR', 'input')],
[('6', 'ALRT', 'output'), ('7', 'A0', 'input'), ('8', 'A1', 'input'), ('9', 'A2', 'input'), ('10', 'A3', 'input')],
w=12.7, desc='ADS1115 16-bit ADC Breakout (HW-ADS1115/CJMCU, 1x10 Pin)')
ct = rect_symbol('ZMCT103C', 'T',
[('3', 'P1 (L_SW)', 'passive'), ('4', 'P2 (L_PUMP)', 'passive')],
[('1', 'S1', 'passive'), ('2', 'S2', 'passive')],
w=15.24, desc='Stromwandler ZMCT103C 1000:1, Primär = Drahtbrücke durchs Loch')
mx = rect_symbol('MAX6675_Modul', 'A',
[('1', 'GND', 'power_in'), ('2', 'VCC', 'power_in'), ('3', 'SCK', 'input'), ('4', 'CS', 'input'), ('5', 'SO', 'output')],
[], w=12.7, desc='MAX6675 Thermoelement-Modul (Typ K), 1x5 Pin')
open(os.path.join(HERE, PROJECT + '.kicad_sym'), 'w').write(
'(kicad_symbol_lib (version 20231120) (generator "gen_sch.py")\n' + wemos + '\n' + ads + '\n' + ct + '\n' + mx + '\n)\n')
# ---------- geometry ----------
import math, heapq
from sch_layout import BLOCKS, LABELS, PSYM, NOTES as LNOTES
GRID = 1.27
POWERNETS = ('GND', '+5V', '+3V3')
def pin_lengths(sym):
res = {}
def walk(x):
for e in x:
if isinstance(e, list):
if e[0] == 'pin':
num = next(k for k in e if isinstance(k, list) and k[0] == 'number')[1].strip('"')
ln = next((k for k in e if isinstance(k, list) and k[0] == 'length'), ['length', '2.54'])
res[num] = float(ln[1])
else:
walk(e)
walk(sym)
return res
def graphic_points(sym):
pts = []
def walk(x):
for e in x:
if isinstance(e, list):
if e[0] in ('rectangle',):
for k in e:
if isinstance(k, list) and k[0] in ('start', 'end'):
pts.append((float(k[1]), float(k[2])))
elif e[0] in ('polyline', 'bezier'):
for k in e:
if isinstance(k, list) and k[0] == 'pts':
pts.extend((float(p[1]), float(p[2])) for p in k[1:])
elif e[0] == 'circle':
c = next(k for k in e if isinstance(k, list) and k[0] == 'center')
r = float(next(k for k in e if isinstance(k, list) and k[0] == 'radius')[1])
pts.extend([(float(c[1]) - r, float(c[2]) - r), (float(c[1]) + r, float(c[2]) + r)])
elif e[0] == 'arc':
for k in e:
if isinstance(k, list) and k[0] in ('start', 'mid', 'end'):
pts.append((float(k[1]), float(k[2])))
elif e[0] in ('pin', 'property'):
pass
else:
walk(e)
walk(sym)
return pts
def xf(px, py, rot, mirror):
"""lib point (y up) -> screen offset (y down)"""
x, y = px, -py
if mirror == 'y':
x = -x
elif mirror == 'x':
y = -y
r = math.radians(rot)
c, s = round(math.cos(r)), round(math.sin(r))
return (x * c + y * s, -x * s + y * c)
DIRV = {0: (1, 0), 90: (0, -1), 180: (-1, 0), 270: (0, 1)} # screen
def vdir(v):
return {(1, 0): 0, (0, -1): 90, (-1, 0): 180, (0, 1): 270}[(int(round(v[0])), int(round(v[1])))]
def G(v):
return int(round(v / GRID))
# ---------- router ----------
class Grid:
def __init__(self):
self.block = {} # cell -> 'hard'
self.soft = set()
self.occ = {} # cell -> {net: set(dirs 'h'/'v')}
self.nodes = {} # cell -> net (pins, label anchors, wire corners/ends: no foreign wire may pass)
self.edges = {} # net -> set of (cellA, cellB)
def add_edge(self, net, a, b):
e = (min(a, b), max(a, b))
self.edges.setdefault(net, set()).add(e)
d = 'h' if a[1] == b[1] else 'v'
for c in (a, b):
self.occ.setdefault(c, {}).setdefault(net, set()).add(d)
def tree(self, net):
cells = set()
for a, b in self.edges.get(net, ()):
cells.add(a); cells.add(b)
cells |= {c for c, n in self.nodes.items() if n == net}
return cells
def passable(self, c, net, d):
"""may net pass straight through c in direction d ('h'/'v')?"""
if c in self.block:
return False
n = self.nodes.get(c)
if n is not None and n != net:
return False
o = self.occ.get(c, {})
for other, dirs in o.items():
if other == net:
continue
if d in dirs or len(dirs) != 1:
return False
# other must go straight through (2 edges same axis)
deg = sum(1 for (a, b) in self.edges[other] if a == c or b == c)
if deg != 2:
return False
return True
def can_turn_or_end(self, c, net):
o = self.occ.get(c, {})
return all(other == net for other in o) and self.nodes.get(c, net) == net and c not in self.block
def route(self, net, src, targets, bounds):
"""A* from src to any target cell; returns list of cells"""
(x0, y0, x1, y1) = bounds
def h(c):
return min(abs(c[0] - t[0]) + abs(c[1] - t[1]) for t in targets) if len(targets) < 40 else 0
start = (src, None)
pq = [(h(src), 0, src, None)]
best = {start: 0}
prev = {}
while pq:
f, g, c, d = heapq.heappop(pq)
if g > best.get((c, d), 1e9):
continue
if c in targets and c != src:
if self.can_turn_or_end(c, net) or (d is not None and self._t_ok(c, net)):
path = [c]; k = (c, d)
while k in prev:
k = prev[k]; path.append(k[0])
return path[::-1]
for nd, (dx, dy) in DIRV.items():
n = (c[0] + dx, c[1] + dy)
if not (x0 <= n[0] <= x1 and y0 <= n[1] <= y1):
continue
if d is not None and nd != d and (nd - d) % 180 == 0:
continue # no reversing
turning = d is not None and nd != d
if turning and c != src and not self.can_turn_or_end(c, net):
continue
if c == src and d is None:
pass
ax = 'h' if dx else 'v'
if n in targets:
cost = 1
else:
if not self.passable(n, net, ax):
continue
cost = 1 + (6 if n in self.soft else 0) + (3 if self.occ.get(n) else 0)
cost += 4 if turning else 0
ng = g + cost
if ng < best.get((n, nd), 1e9):
best[(n, nd)] = ng; prev[(n, nd)] = (c, d)
heapq.heappush(pq, (ng + h(n), ng, n, nd))
return None
def _t_ok(self, c, net):
return self.can_turn_or_end(c, net)
# ---------- schematic ----------
def main():
write_projlib()
root = str(uuid.uuid5(uuid.NAMESPACE_URL, PROJECT + ':root'))
parts = {p['ref']: p for p in PARTS}
place = {}
blockof = {}
for b, d in BLOCKS.items():
for ref, v in d.items():
place[ref] = v; blockof[ref] = b
missing = set(parts) - set(place)
if missing:
raise SystemExit('nicht platziert: %s' % sorted(missing))
libsyms = {}
grid = Grid()
items = []
symuuids = {}
pinpos = {} # (ref, num) -> (cell, outward dir)
textboxes = []
for ref, p in parts.items():
lid = p['sym']
if lid not in libsyms:
libsyms[lid] = lib_symbol(lid)
sym = libsyms[lid]
x0, y0, rot, mir = place[ref]
pins = pins_of(sym); plen = pin_lengths(sym)
# body
gp = [xf(px, py, rot, mir) for px, py in graphic_points(sym)]
if gp:
bx0 = min(v[0] for v in gp) + x0; bx1 = max(v[0] for v in gp) + x0
by0 = min(v[1] for v in gp) + y0; by1 = max(v[1] for v in gp) + y0
for i in range(G(bx0), G(bx1) + 1):
for j in range(G(by0), G(by1) + 1):
grid.block[(i, j)] = ref
else:
bx0 = bx1 = x0; by0 = by1 = y0
for num, (px, py, ang) in pins.items():
ox, oy = xf(px, py, rot, mir)
cx, cy = x0 + ox, y0 + oy
ldx, ldy = xf(math.cos(math.radians(ang)), math.sin(math.radians(ang)), rot, mir) # toward body
out = vdir((-ldx, -ldy))
cell = (G(cx), G(cy))
pinpos[(ref, num)] = (cell, out)
# pin line cells (between endpoint and body) are blocked
L = int(round(plen.get(num, 2.54) / GRID))
for k in range(1, L + 1):
grid.block[(cell[0] + int(round(ldx)) * k, cell[1] + int(round(ldy)) * k)] = ref
grid.nodes[cell] = None # filled with net below
su = str(uuid.uuid5(uuid.NAMESPACE_URL, PROJECT + ':sym:' + ref))
symuuids[ref] = su
# fields: right of body for vertical/narrow parts, above for wide parts
pc = [xf(px, py, rot, mir) for px, py, a in pins.values()]
wide = len(pc) == 2 and abs(pc[0][1] - pc[1][1]) < 0.1 and not lid.startswith(PROJECT)
big = lid.startswith(PROJECT) or lid.startswith('Regulator') or lid.startswith('Connector') or lid.startswith('Jumper') or ref.startswith('H')
if ref.startswith('#'):
rpos = vpos = None
elif big:
rpos = (round((bx0 + bx1) / 2, 2), round(by0 - 3.81, 2), 'center'); vpos = (round((bx0 + bx1) / 2, 2), round(by0 - 1.6, 2), 'center')
if ref.startswith('H'):
rpos = (x0 + 3.81, y0 - 1.27, 'left'); vpos = (x0 + 3.81, y0 + 1.27, 'left')
elif wide:
rpos = (round((bx0 + bx1) / 2, 2), round(by0 - 1.9, 2), 'center'); vpos = (round((bx0 + bx1) / 2, 2), round(by1 + 2.0, 2), 'center')
else:
rpos = (round(bx1 + 1.6, 2), round((by0 + by1) / 2 - 0.8, 2), 'left'); vpos = (round(bx1 + 1.6, 2), round((by0 + by1) / 2 + 1.6, 2), 'left')
for pos in (rpos, vpos):
if pos:
for i in range(G(pos[0]) - (3 if pos[2] == 'center' else 0), G(pos[0]) + 5):
grid.soft.add((i, G(pos[1]))); grid.soft.add((i, G(pos[1]) - 1))
props = [('Reference', ref, rpos), ('Value', p['val'], vpos), ('Footprint', p['fp'], None), ('Datasheet', '', None),
('Description', p.get('desc', ''), None)]
if p.get('mpn'):
props.append(('Bezugsquelle', p['mpn'], None))
pr = ''
for k, v, pos in props:
if pos is None:
pr += f' (property {q(k)} {q(v)} (at {x0} {y0} 0) (effects (font (size 1.27 1.27)) (hide yes)))\n'
else:
just = '' if pos[2] == 'center' else ' (justify left)'
fa = 90 if rot in (90, 270) else 0
pr += f' (property {q(k)} {q(v)} (at {pos[0]:.2f} {pos[1]:.2f} {fa}) (effects (font (size 1.27 1.27)){just}))\n'
pinstr = ''.join(f' (pin {q(n)} (uuid {U()}))\n' for n in pins)
dnp = 'yes' if p.get('dnp') else 'no'
onb = 'no' if p.get('board') is False else 'yes'
inbom = 'no' if ref.startswith('H') or ref.startswith('#') else 'yes'
mirs = f' (mirror {mir})' if mir else ''
items.append(f'''(symbol (lib_id {q(lid)}) (at {x0} {y0} {rot}){mirs} (unit 1) (exclude_from_sim no) (in_bom {inbom}) (on_board {onb}) (dnp {dnp}) (uuid {su})
{pr}{pinstr} (instances (project {q(PROJECT)} (path {q('/' + root)} (reference {q(ref)}) (unit 1)))))''')
for (ref, num), (cell, _o) in pinpos.items():
grid.block.pop(cell, None)
dx, dy = DIRV[_o]
for k in range(1, 4): # Grafik (z.B. LED-Pfeile) darf den Pinausgang nicht zustellen
c2 = (cell[0] + dx * k, cell[1] + dy * k)
if grid.block.get(c2) == ref:
grid.block.pop(c2)
if parts[ref]['sym'].startswith('power:'):
for dx in (-1, 0, 1):
grid.block.pop((cell[0] + dx, cell[1]), None)
# nets -> pins grouped per block
netpins = {}
for ref, p in parts.items():
for num, net in p['pins'].items():
cell, out = pinpos[(ref, num)]
if net is None:
grid.nodes[cell] = '__nc__'
cx, cy = cell[0] * GRID, cell[1] * GRID
items.append(f'(no_connect (at {cx:.2f} {cy:.2f}) (uuid {U()}))')
continue
grid.nodes[cell] = net
netpins.setdefault((net, blockof[ref] if net in POWERNETS else '*'), []).append((ref, num))
for ref, p in parts.items(): # pins without a net entry (shouldn't exist)
pass
def W(a, b):
items.append(f'(wire (pts (xy {a[0]*GRID:.2f} {a[1]*GRID:.2f}) (xy {b[0]*GRID:.2f} {b[1]*GRID:.2f})) (stroke (width 0) (type default)) (uuid {U()}))')
pwr_n = [0]
def power_symbol(net, cell, out=None):
"""power symbol with its pin at cell. out = direction of the stub that leads to it (None = free)."""
plid = 'power:' + net
if plid not in libsyms:
libsyms[plid] = lib_symbol(plid)
if net == 'GND':
rot = 180 if out == 90 else ((out - 270) % 360 if out in (0, 180) else 0)
else:
rot = 180 if out == 270 else ((out - 90) % 360 if out in (0, 180) else 0)
pwr_n[0] += 1
ref = '#PWR' + str(pwr_n[0]).zfill(3)
ex, ey = cell[0] * GRID, cell[1] * GRID
bodyd = ({0: 270, 90: 0, 180: 90, 270: 180} if net == 'GND' else {0: 90, 90: 180, 180: 270, 270: 0})[rot]
bdx, bdy = DIRV[bodyd]
tdx, tdy = bdx * (3.3 + 0.45 * len(net)), bdy * 4.0
items.append(f'''(symbol (lib_id {q(plid)}) (at {ex:.2f} {ey:.2f} {rot}) (unit 1) (exclude_from_sim no) (in_bom yes) (on_board yes) (dnp no) (uuid {U()})
(property "Reference" {q(ref)} (at {ex:.2f} {ey:.2f} 0) (effects (font (size 1.27 1.27)) (hide yes)))
(property "Value" {q(net)} (at {ex + tdx:.2f} {ey + tdy:.2f} {90 if rot in (90, 270) else 0}) (effects (font (size 1.0 1.0))))
(property "Footprint" "" (at {ex:.2f} {ey:.2f} 0) (effects (font (size 1.27 1.27)) (hide yes)))
(property "Datasheet" "" (at {ex:.2f} {ey:.2f} 0) (effects (font (size 1.27 1.27)) (hide yes)))
(pin "1" (uuid {U()}))
(instances (project {q(PROJECT)} (path {q('/' + root)} (reference {q(ref)}) (unit 1)))))''')
for k in range(1, 3):
for w in (-1, 0, 1):
c2 = (cell[0] + bdx * k + (w if bdy else 0), cell[1] + bdy * k + (w if bdx else 0))
grid.block.setdefault(c2, 'pwr')
def label(net, cell, d):
ang, just = {'right': (0, 'left'), 'left': (180, 'right'), 'up': (90, 'left'), 'down': (270, 'right')}[d]
items.append(f'(label {q(net)} (at {cell[0]*GRID:.2f} {cell[1]*GRID:.2f} {ang}) (effects (font (size 1.27 1.27)) (justify {just} bottom)) (uuid {U()}))')
grid.nodes[cell] = net
dx, dy = DIRV[{'right': 0, 'left': 180, 'up': 90, 'down': 270}[d]]
for k in range(1, len(net) + 2):
grid.block.setdefault((cell[0] + dx * k, cell[1] + dy * k), 'lbl')
if dx:
grid.block.setdefault((cell[0] + dx * k, cell[1] - 1), 'lbl')
# explicit labels + power symbols become nodes of their (net, block)
extra = {}
for b, net, x, y, d in LABELS:
c = (G(x), G(y)); label(net, c, d); extra.setdefault((net, b), []).append(c)
for b, net, x, y in PSYM:
c = (G(x), G(y)); grid.nodes[c] = net; power_symbol(net, c, None); extra.setdefault((net, b), []).append(c)
for (nx, ny, size, text) in LNOTES: # Überschriften nicht überfahren
for i in range(G(nx) - 1, G(nx + len(text) * size * 0.95) + 2):
for j in range(G(ny - size * 1.3), G(ny) + 2):
grid.block.setdefault((i, j), 'note')
# 1) stubs for everything that is not wired inside its block
wired = []
for (net, b), pl in sorted(netpins.items()):
nodes = [pinpos[x][0] for x in pl] + extra.get((net, b), [])
if net in POWERNETS and (net, b) not in extra:
for x in pl:
cell, out = pinpos[x]
dx, dy = DIRV[out]; e = (cell[0] + 2 * dx, cell[1] + 2 * dy)
grid.add_edge(net, cell, (cell[0] + dx, cell[1] + dy)); grid.add_edge(net, (cell[0] + dx, cell[1] + dy), e)
grid.nodes[e] = net
W(cell, e); _emitted.add((net, cell, e)); power_symbol(net, e, out)
elif len(nodes) == 1:
cell, out = pinpos[pl[0]]
dx, dy = DIRV[out]; e = (cell[0] + 2 * dx, cell[1] + 2 * dy)
grid.add_edge(net, cell, (cell[0] + dx, cell[1] + dy)); grid.add_edge(net, (cell[0] + dx, cell[1] + dy), e)
W(cell, e); _emitted.add((net, cell, e))
label(net, e, {0: 'right', 180: 'left', 90: 'up', 270: 'down'}[out])
else:
wired.append((net, b, nodes, pl))
# 2) route wired nets: shortest nets first
def span(n):
xs = [c[0] for c in n[2]]; ys = [c[1] for c in n[2]]
return (max(xs) - min(xs)) + (max(ys) - min(ys))
failed = []
for net, b, nodes, pl in sorted(wired, key=span):
xs = [c[0] for c in nodes]; ys = [c[1] for c in nodes]
bounds = (min(xs) - 40, min(ys) - 40, max(xs) + 40, max(ys) + 40)
# start from the node nearest to the centroid
cx, cy = sum(xs) / len(xs), sum(ys) / len(ys)
order = sorted(nodes, key=lambda c: abs(c[0] - cx) + abs(c[1] - cy))
connected = {order[0]}
for c in order[1:]:
if c in grid.tree(net) & connected or c in connected:
continue
tgt = (grid.tree(net) & (connected | {cc for e in grid.edges.get(net, ()) for cc in e})) | connected
# pins must leave in their outward direction: start one step out if it is a pin
path = grid.route(net, c, tgt, bounds)
if path is None:
if os.environ.get('DBG'):
print('DBG', net, 'src', c, 'block', grid.block.get(c), 'node', grid.nodes.get(c), 'occ', grid.occ.get(c), 'ntgt', len(tgt))
for dx,dy in ((1,0),(-1,0),(0,1),(0,-1)):
n=(c[0]+dx,c[1]+dy); print(' nb', n, grid.block.get(n), grid.nodes.get(n), grid.occ.get(n))
failed.append((net, b, c)); continue
for a, bb in zip(path, path[1:]):
grid.add_edge(net, a, bb)
connected |= set(path)
if failed:
print('ROUTING FAILED:', failed)
# 2b) name every wired signal net with a small label on a straight wire run (keeps PCB net names)
labelled = {(net, b) for b, net, x, y, d in LABELS} | {(net, b) for b, net, x, y in PSYM}
for net, b, nodes, pl in wired:
if net in POWERNETS or (net, b) in labelled:
continue
es = grid.edges.get(net, set())
hcells = {}
for a, c in es:
if a[1] == c[1]:
hcells.setdefault(a, set()).add(c); hcells.setdefault(c, set()).add(a)
need = int(len(net) * 1.0) + 1
best = None
for c in sorted(hcells, key=lambda c: (c[1], c[0])):
run = [c]
while (run[-1][0] + 1, run[-1][1]) in hcells.get(run[-1], ()) and len(run) <= need:
run.append((run[-1][0] + 1, run[-1][1]))
if len(run) > need and all((r[0], r[1] - 1) not in grid.block and (r[0], r[1] - 1) not in grid.soft for r in run):
best = c; break
if best is None:
# fallback: first pin of the net
best = nodes[0]
print('label on pin for', net)
cx, cy = best[0] * GRID, best[1] * GRID
items.append(f'(label {q(net)} (at {cx:.2f} {cy:.2f} 0) (effects (font (size 1.0 1.0)) (justify left bottom)) (uuid {U()}))')
for k in range(need + 1):
grid.block.setdefault((best[0] + k, best[1] - 1), 'lbl')
# 3) emit wires from edge sets (split at corners, junctions, nodes)
for net, es in grid.edges.items():
adj = {}
for a, b in es:
adj.setdefault(a, set()).add(b); adj.setdefault(b, set()).add(a)
def is_break(c):
nb = adj[c]
if len(nb) != 2 or grid.nodes.get(c) == net:
return True
a, b = list(nb)
return not (a[0] == b[0] or a[1] == b[1])
done = set()
stub_set = set()
for start in adj:
if not is_break(start):
continue
for n in adj[start]:
e = (min(start, n), max(start, n))
if e in done:
continue
path = [start, n]; done.add(e)
while not is_break(path[-1]):
nxt = [m for m in adj[path[-1]] if m != path[-2]][0]
done.add((min(path[-1], nxt), max(path[-1], nxt))); path.append(nxt)
# stubs already emitted for power/single labels: skip if both ends are stub cells
if (net, path[0], path[-1]) in _emitted or (net, path[-1], path[0]) in _emitted:
continue
W(path[0], path[-1])
for c, nb in adj.items():
if len(nb) >= 3:
items.append(f'(junction (at {c[0]*GRID:.2f} {c[1]*GRID:.2f}) (diameter 0) (color 0 0 0 0) (uuid {U()}))')
for (x, y, size, text) in LNOTES:
items.append(f'(text {q(text)} (exclude_from_sim no) (at {x} {y} 0) (effects (font (size {size} {size}) (bold yes)) (justify left bottom)) (uuid {U()}))')
libtxt = '\n'.join(dump(v, 1) for v in libsyms.values())
sch = f'''(kicad_sch (version 20231120) (generator "eeschema") (generator_version "8.0") (uuid {root}) (paper "A3")
(title_block (title "Ofensteuerung (Wemos D1 mini)") (date "2026-11-07") (rev "1.0") (company "Ramiro / OpenClaw")
(comment 1 "Pumpenrelais 230 V + Thermostat parallel + Strommessung, Abgas Typ K, 3x DS18B20, Servo-Luftklappe, PWM-Pumpe")
(comment 2 "Erzeugt mit pcb/gen_sch.py aus design.py + sch_layout.py - Änderungen dort, dann neu erzeugen"))
(lib_symbols
{libtxt})
{chr(10).join(items)}
(sheet_instances (path "/" (page "1")))
)
'''
open(os.path.join(HERE, PROJECT + '.kicad_sch'), 'w').write(sch)
json.dump(symuuids, open(os.path.join(HERE, 'symuuids.json'), 'w'), indent=1)
print('ok', len(PARTS), 'parts', 'failed' if failed else '')
_emitted = set()
if __name__ == '__main__':
main()