main
TylerK Update 50 file(s) c277d7d 1mo ago
#!/usr/bin/env python3
"""Merge collinear runs of track segments into single segments.

The fine-grid hand-router works on a 0.025mm raster and emits one segment per
cell step, so a single straight 40mm trace can arrive as 1600 segments. That is
electrically identical to one segment and KiCad renders it identically, but it
is not harmless:

  * the electrothermal model rasterises each net's copper by unioning polygons,
    and a chain of thousands of 0.025mm slivers does not survive that cleanly --
    it reported five terminals as disconnected on copper that both KiCad's
    connectivity and an exact-geometry checker call one continuous piece;
  * the board file gets large, and every tool that parses it gets slower.

Merging is safe because it only ever replaces a chain of collinear, same-net,
same-width, same-layer segments joined end to end with the single segment they
already describe. Nothing moves, no width changes, and a branch touching the
middle of the chain still touches the merged segment at the same point.
"""
import math, re, sys
from collections import defaultdict

PCB = ("/mnt/c/Users/kirkt/Documents/mppt-power-system-mcu/"
       "mppt-power-system-mcu.kicad_pcb")
TOL = 1e-6


def main(path=PCB):
    text = open(path, errors="replace").read()
    # Segment blocks come from several writers and do not all order their
    # fields the same way, so pull each field out of the block individually
    # rather than demanding one layout. A single rigid pattern matched only
    # 2,625 of 7,240 segments and silently left the rest unmerged.
    blk_pat = re.compile(r'\t\(segment\n(?:\t\t\([^\n]*\)\n)+\t\)\n')
    segs, spans = [], []
    for m in blk_pat.finditer(text):
        blk = m.group(0)
        s = re.search(r'\(start ([-\d.]+) ([-\d.]+)\)', blk)
        e = re.search(r'\(end ([-\d.]+) ([-\d.]+)\)', blk)
        w = re.search(r'\(width ([\d.]+)\)', blk)
        L = re.search(r'\(layer "([^"]+)"\)', blk)
        n = re.search(r'\(net (?:\d+\s+)?"?([^")\n]*)"?\)', blk)
        if not (s and e and w and L):
            continue
        segs.append([float(s.group(1)), float(s.group(2)),
                     float(e.group(1)), float(e.group(2)),
                     w.group(1), L.group(1), (n.group(1) if n else "")])
        spans.append((m.start(), m.end()))

    if not segs:
        print("no segments matched -- file left alone")
        return 0

    # group by (net, layer, width); within a group chain by shared endpoint
    groups = defaultdict(list)
    for i, s in enumerate(segs):
        groups[(s[6], s[5], s[4])].append(i)

    drop = set()
    replace = {}
    for key, idxs in groups.items():
        ends = defaultdict(list)
        for i in idxs:
            ax, ay, bx, by = segs[i][:4]
            ends[(round(ax, 4), round(ay, 4))].append(i)
            ends[(round(bx, 4), round(by, 4))].append(i)
        used = set()
        for i in idxs:
            if i in used:
                continue
            ax, ay, bx, by = segs[i][:4]
            chain = [i]; used.add(i)
            # extend from both ends while the next segment is collinear
            for which in (0, 1):
                cx, cy = (ax, ay) if which == 0 else (bx, by)
                px, py = (bx, by) if which == 0 else (ax, ay)
                while True:
                    cand = [j for j in ends.get((round(cx, 4), round(cy, 4)), [])
                            if j not in used]
                    nxt = None
                    for j in cand:
                        jx0, jy0, jx1, jy1 = segs[j][:4]
                        if abs(jx0 - cx) < TOL and abs(jy0 - cy) < TOL:
                            ox, oy = jx1, jy1
                        elif abs(jx1 - cx) < TOL and abs(jy1 - cy) < TOL:
                            ox, oy = jx0, jy0
                        else:
                            continue
                        # collinear with the direction we arrived from?
                        v1x, v1y = cx - px, cy - py
                        v2x, v2y = ox - cx, oy - cy
                        if abs(v1x * v2y - v1y * v2x) > 1e-7:
                            continue
                        if v1x * v2x + v1y * v2y <= 0:      # doubles back
                            continue
                        nxt = (j, ox, oy); break
                    if not nxt:
                        break
                    j, ox, oy = nxt
                    used.add(j); chain.append(j)
                    px, py = cx, cy
                    cx, cy = ox, oy
                if which == 0:
                    ax, ay = cx, cy
                else:
                    bx, by = cx, cy
            if len(chain) > 1:
                replace[chain[0]] = (ax, ay, bx, by)
                drop.update(chain[1:])

    if not drop:
        print("nothing to merge")
        return 0

    out, pos = [], 0
    for i, (s0, s1) in enumerate(spans):
        out.append(text[pos:s0])
        if i in drop:
            pass
        elif i in replace:
            ax, ay, bx, by = replace[i]
            blk = text[s0:s1]
            blk = re.sub(r'\(start [-\d.]+ [-\d.]+\)', f'(start {ax:g} {ay:g})', blk, 1)
            blk = re.sub(r'\(end [-\d.]+ [-\d.]+\)', f'(end {bx:g} {by:g})', blk, 1)
            out.append(blk)
        else:
            out.append(text[s0:s1])
        pos = s1
    out.append(text[pos:])
    new = "".join(out)
    with open(path, "w") as fh:
        fh.write(new)
    print(f"merged {len(drop)} segment(s) away: {len(segs)} -> {len(segs)-len(drop)}")
    return 0


if __name__ == "__main__":
    sys.exit(main())