main
John Lauer README: component table per the wiki format, open-source section, no duplicate hero; generators at gen/ 39a59ef 7d ago
#!/usr/bin/env python3
"""Split the finished routing into (a) the start board's copper and (b) what AI Flow lands live:
placement moves for every part that is not a fixed interface part, and a routing plan (one entry per
polyline, layer markers for vias) for every maze-routed net and GND fan-out."""
import json, os, math
from collections import defaultdict
ROOT = os.path.join(os.path.dirname(__file__), "..")
R = json.load(open(os.path.join(ROOT, "design", "routes.json")))
LS = json.load(open(os.path.join(ROOT, "design", "routes_ls.json")))
lay = json.load(open(os.path.join(ROOT, "design", "layout.json")))
KL = {"L1": "F.Cu", "L3": "In2.Cu", "L5": "In4.Cu", "L6": "B.Cu"}
FIXED = {"J1", "J2", "J3", "J4", "J5", "H1", "H2"}

# live copper: everything the maze produced (routes_ls minus the fixed copper it inherited) plus GND fan-outs
if "fixed_tracks" in LS:
    fixed_T = set(map(tuple, LS["fixed_tracks"])); fixed_V = set(map(tuple, LS["fixed_vias"]))
else:   # older routes_ls.json: rebuild the fixed copper exactly as route_all does before routing
    import sys; sys.path.insert(0, os.path.dirname(__file__))
    import route_all as RA
    fixed_T = set(map(tuple, RA.T)); fixed_V = set(map(tuple, RA.V))
# taken from the FINAL routes (routes.json): the finishing pass prunes stubs and may rip and re-route a few nets, so
# the maze's own output is stale for those; GND copper is live only where the maze or the fan-out made it
ls_T = {tuple(t) for t in LS["tracks"]} - fixed_T; ls_V = {tuple(v) for v in LS["vias"]} - fixed_V
fan_T = set(map(tuple, R["fanout_tracks"])); fan_V = set(map(tuple, R["fanout_vias"]))
live_T = [t for t in map(tuple, R["tracks"]) if t not in fixed_T and (t[1] != "GND" or t in ls_T or t in fan_T)]
live_V = [v for v in map(tuple, R["vias"]) if v not in fixed_V and (v[2] != "GND" or v in ls_V or v in fan_V)]
live_Ts, live_Vs = set(live_T), set(live_V)
start_T = [t for t in map(tuple, R["tracks"]) if t not in live_Ts]
start_V = [v for v in map(tuple, R["vias"]) if v not in live_Vs]


def key(x, y):
    return (round(x, 3), round(y, 3))


def chains(net, tracks, vias):
    """Decompose one net's live copper into polylines; a via joins layers at one point."""
    adj = defaultdict(list)    # (layer, pt) -> [(edge id, other node)]
    edges = []
    for (L, n, x1, y1, x2, y2, w) in tracks:
        a, b = (L, key(x1, y1)), (L, key(x2, y2))
        eid = len(edges); edges.append((a, b, w))
        adj[a].append((eid, b)); adj[b].append((eid, a))
    viapts = {key(v[0], v[1]) for v in vias}
    lone = []
    for vp in viapts:            # via: connect every layer node at that point
        nodes = [nd for nd in list(adj) if nd[1] == vp]
        if len(nodes) < 2:       # copper on one layer only (the other side is a pad or a pour): a via of its own
            lone.append((vp, nodes[0][0] if nodes else "L1"))
        for i in range(len(nodes) - 1):
            a, b = nodes[i], nodes[i + 1]
            eid = len(edges); edges.append((a, b, None))
            adj[a].append((eid, b)); adj[b].append((eid, a))
    used = set()
    out = []
    order = sorted(adj, key=lambda nd: len(adj[nd]) % 2 == 0)   # odd-degree nodes first
    for start in order:
        while any(e not in used for e, _ in adj[start]):
            path = [start]; cur = start; w = None
            while True:
                nxt = [(e, o) for e, o in adj[cur] if e not in used]
                if not nxt:
                    break
                e, o = nxt[0]
                used.add(e)
                if edges[e][2] is not None:
                    w = edges[e][2]
                path.append(o); cur = o
            out.append((path, w))
    entries = []
    for path, w in out:
        pts = []
        layer = path[0][0]
        for i, (L, (x, y)) in enumerate(path):
            if i == 0:
                pts.append([x, y])
            elif L != layer:
                pts.append({"x": x, "y": y, "layer": KL[L]})
                layer = L
            elif pts and isinstance(pts[-1], dict) and abs(pts[-1]["x"] - x) < 1e-6 and abs(pts[-1]["y"] - y) < 1e-6:
                continue
            else:
                pts.append([x, y])
        # a via at the start of a path: express as a layer marker on the first point
        entries.append({"net": net, "width": w or 0.15, "viaSize": 0.4, "viaDrill": 0.2, "layer": KL[path[0][0]], "points": pts})
    vsize = {key(v[0], v[1]): (v[3], v[4]) for v in vias}
    for (x, y), L in lone:
        other = "B.Cu" if KL[L] == "F.Cu" else "F.Cu"
        d, h = vsize[(x, y)]
        entries.append({"net": net, "width": 0.15, "viaSize": d, "viaDrill": h, "layer": KL[L],
                        "points": [[x, y], {"x": x, "y": y, "layer": other}]})
    return entries


bynet_T, bynet_V = defaultdict(list), defaultdict(list)
for t in live_T:
    bynet_T[t[1]].append(t)
for v in live_V:
    bynet_V[v[2]].append(v)
plan = []
for net in sorted(set(bynet_T) | set(bynet_V)):
    plan += chains(net, bynet_T[net], bynet_V[net])
moves = {}
stage = {}
k = 0
for ref, (x, y, rot, side) in lay["placement"].items():
    if ref in FIXED:
        continue
    moves[ref] = {"x": x, "y": y, "rotation": rot}
    stage[ref] = (84.0 + 9.0 * (k % 4), 8.0 + 6.0 * (k // 4), rot, side)
    k += 1
json.dump({"nets": plan}, open(os.path.join(ROOT, "aiflow", "route-plan.json"), "w"), indent=0)
json.dump(moves, open(os.path.join(ROOT, "aiflow", "moves.json"), "w"), indent=1)
json.dump({"tracks": start_T, "vias": start_V, "zones": R["zones"], "texts": R.get("texts", []), "stage": stage},
          open(os.path.join(ROOT, "design", "routes_start.json"), "w"))
print(f"live: {len(plan)} plan entries ({len(live_T)} tracks, {len(live_V)} vias); moves {len(moves)} parts;"
      f" start board copper {len(start_T)} tracks, {len(start_V)} vias")