main
TylerK Board update: 4 unconnected items (from 13), 0 electrical and 0 mechanical DRC findings, silkscreen findings 73 -> 10;… 337e32d 1mo ago
#!/usr/bin/env python3
"""Silkscreen legibility pass: place reference designators so they are readable.

The board generator drops each footprint's reference designator wherever its
library happens to put it -- typically centred on the part. On a board this
dense that lands 22 of them on top of exposed pads and 27 of them on top of
each other, which is 49 of the 73 cosmetic DRC findings and, more to the
point, a silkscreen an assembler cannot read.

This pass moves each reference to the nearest position that collides with
nothing: not with mask-opened copper (KiCad's silk_over_copper rule is about
pads, not tracks -- a track is under solder mask), not with another
footprint's silk outline, and not with a reference already placed. References
that have nowhere to go at all are moved to F.Fab rather than left overlapping
something; the assembly drawing still carries them, which is what a real
layout does in a dense cluster.

Board-level section labels are handled the same way, and dropped if they
cannot be placed -- a heading printed across a pin field is worse than no
heading.

Nothing electrical is touched. Only silkscreen geometry moves.
"""
import math, re, sys
from collections import defaultdict

import geomcheck as G

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

SILK = "F.SilkS"
CLEAR = 0.15            # mm of air we want around silk


# --------------------------------------------------------------------------
# geometry helpers
# --------------------------------------------------------------------------
def text_extent(s, size_x, size_y, thickness=0.15):
    """Bounding box of KiCad stroke text, measured from real KiCad plots.

    Calibrated rather than guessed: a 2-character string at size 1.0 plots
    1.619mm wide and a 3-character one 2.620mm, i.e. 1.0mm per character less
    a constant 0.38mm, with the stroke thickness already inside that.
    """
    n = max(len(s), 1)
    return ((n - 0.38) * size_x + 0.15, size_y + 0.15)


class Box:
    """An axis-aligned box in board coordinates."""
    __slots__ = ("x0", "y0", "x1", "y1")

    def __init__(self, cx, cy, w, h):
        self.x0, self.y0 = cx - w / 2, cy - h / 2
        self.x1, self.y1 = cx + w / 2, cy + h / 2

    def hits(self, o, pad=0.0):
        return not (self.x1 + pad < o.x0 or o.x1 + pad < self.x0 or
                    self.y1 + pad < o.y0 or o.y1 + pad < self.y0)


def seg_box(x0, y0, x1, y1, w):
    return Box((x0 + x1) / 2, (y0 + y1) / 2,
               abs(x1 - x0) + w, abs(y1 - y0) + w)


def fp_world(fx, fy, frot, lx, ly):
    th = math.radians(frot)
    ct, st = math.cos(th), math.sin(th)
    return fx + lx * ct + ly * st, fy + ly * ct - lx * st


# --------------------------------------------------------------------------
# reading the board
# --------------------------------------------------------------------------
def collect(text):
    """(obstacles, refs, labels) -- everything the placer has to respect."""
    obstacles = []          # Box, things silk must not touch
    refs = []               # placeable reference designators
    labels = []             # placeable board-level headings

    for blk in G._blocks(text, 'footprint'):
        m_ref = re.search(r'\(property "Reference" "([^"]*)"', blk)
        m_at = re.search(r'\(layer "[^"]*"\)\s*(?:\([^()]*\)\s*)*?\(uuid "[^"]*"\)'
                         r'\s*(?:\([^()]*\)\s*)*?\(at ([-\d.]+) ([-\d.]+)(?: ([-\d.]+))?\)',
                         blk)
        if not m_at:
            continue
        fx, fy = float(m_at.group(1)), float(m_at.group(2))
        frot = float(m_at.group(3) or 0)
        ref = m_ref.group(1) if m_ref else "?"

        # exposed copper: pads carry a mask opening, tracks do not
        for pb in G._blocks(blk, 'pad'):
            ms = re.search(r'\(size ([-\d.]+) ([-\d.]+)\)', pb)
            ma = re.search(r'\(at ([-\d.]+) ([-\d.]+)(?: ([-\d.]+))?\)', pb)
            if not (ms and ma):
                continue
            pw, ph = float(ms.group(1)), float(ms.group(2))
            prot = float(ma.group(3) or 0)
            x, y = fp_world(fx, fy, frot, float(ma.group(1)), float(ma.group(2)))
            # pad shape is oriented by its OWN angle; use the enclosing box
            if abs((prot % 180) - 90) < 1e-6:
                pw, ph = ph, pw
            obstacles.append(Box(x, y, pw, ph))

        # this footprint's own silk outline
        for tag in ("fp_line", "fp_rect", "fp_poly", "fp_circle", "fp_arc"):
            for gb in G._blocks(blk, tag):
                if f'"{SILK}"' not in gb:
                    continue
                pts = [(float(a), float(b)) for a, b in
                       re.findall(r'\((?:start|end|center|mid|xy) '
                                  r'([-\d.]+) ([-\d.]+)\)', gb)]
                if not pts:
                    continue
                w = re.search(r'\(width ([\d.]+)\)', gb)
                w = float(w.group(1)) if w else 0.12
                wp = [fp_world(fx, fy, frot, a, b) for a, b in pts]
                xs = [q[0] for q in wp]; ys = [q[1] for q in wp]
                obstacles.append(Box((min(xs) + max(xs)) / 2,
                                     (min(ys) + max(ys)) / 2,
                                     max(xs) - min(xs) + w,
                                     max(ys) - min(ys) + w))

        # the reference designator itself
        m_prop = re.search(r'\(property "Reference" "[^"]*"\s*\(at ([-\d.]+) '
                           r'([-\d.]+)(?: ([-\d.]+))?\)', blk)
        m_layer = re.search(r'\(property "Reference" "[^"]*".*?\(layer "([^"]+)"\)',
                            blk, re.S)
        m_size = re.search(r'\(property "Reference" "[^"]*".*?\(size ([\d.]+) ([\d.]+)\)',
                           blk, re.S)
        if m_prop and m_layer and m_layer.group(1) == SILK:
            sx = float(m_size.group(1)) if m_size else 1.0
            sy = float(m_size.group(2)) if m_size else 1.0
            refs.append({
                "ref": ref, "fx": fx, "fy": fy, "frot": frot,
                "lx": float(m_prop.group(1)), "ly": float(m_prop.group(2)),
                "lrot": float(m_prop.group(3) or 0),
                "sx": sx, "sy": sy,
                "span": text_extent(ref, sx, sy),
            })

    # board-level headings
    for gb in G._blocks(text, 'gr_text'):
        if f'"{SILK}"' not in gb:
            continue
        m_t = re.match(r'\(gr_text "([^"]*)"', gb)
        m_a = re.search(r'\(at ([-\d.]+) ([-\d.]+)(?: ([-\d.]+))?\)', gb)
        m_s = re.search(r'\(size ([\d.]+) ([\d.]+)\)', gb)
        if not (m_t and m_a):
            continue
        sx = float(m_s.group(1)) if m_s else 1.0
        sy = float(m_s.group(2)) if m_s else 1.0
        labels.append({"text": m_t.group(1), "x": float(m_a.group(1)),
                       "y": float(m_a.group(2)), "rot": float(m_a.group(3) or 0),
                       "sx": sx, "sy": sy,
                       "span": text_extent(m_t.group(1), sx, sy), "raw": gb})
    return obstacles, refs, labels


# --------------------------------------------------------------------------
# placement
# --------------------------------------------------------------------------
def place(obstacles, refs):
    """Greedy: hardest-to-place first, nearest clear slot wins."""
    placed = []
    moved = hidden = kept = 0
    # a reference boxed in on all sides is hardest, so do the crowded ones
    # first while there is still empty board to put them in
    def crowding(r):
        cx, cy = fp_world(r["fx"], r["fy"], r["frot"], r["lx"], r["ly"])
        b = Box(cx, cy, 6, 6)
        return -sum(1 for o in obstacles if b.hits(o))
    order = sorted(refs, key=crowding)

    for r in order:
        w, h = r["span"]
        # Where it already sits, first. Searching outward from the footprint
        # centre without trying the current position moves every single
        # reference even when it was perfectly readable where the library put
        # it -- 106 of 106 on the first run, which is churn, not a fix.
        cur_x, cur_y = fp_world(r["fx"], r["fy"], r["frot"], r["lx"], r["ly"])
        here = Box(cur_x, cur_y, w, h)
        if not any(here.hits(o, CLEAR) for o in obstacles) and \
                not any(here.hits(o, CLEAR) for o in placed):
            placed.append(here); kept += 1
            continue
        best = None
        # rings outward from the part, cardinal points first (they read best)
        for dist in [x * 0.25 for x in range(0, 25)]:
            cands = []
            for ang in (270, 90, 0, 180, 315, 45, 225, 135):
                a = math.radians(ang)
                cands.append((r["fx"] + math.cos(a) * dist,
                              r["fy"] + math.sin(a) * dist, 0.0))
            for cx, cy, trot in cands:
                bx = Box(cx, cy, w, h)
                if any(bx.hits(o, CLEAR) for o in obstacles):
                    continue
                if any(bx.hits(o, CLEAR) for o in placed):
                    continue
                best = (cx, cy, trot)
                break
            if best:
                break
        if best is None:
            r["hide"] = True
            hidden += 1
            continue
        cx, cy, trot = best
        # store back as a footprint-relative offset
        th = math.radians(r["frot"])
        dx, dy = cx - r["fx"], cy - r["fy"]
        r["nlx"] = dx * math.cos(th) - dy * math.sin(th)
        r["nly"] = dx * math.sin(th) + dy * math.cos(th)
        if abs(r["nlx"] - r["lx"]) > 1e-3 or abs(r["nly"] - r["ly"]) > 1e-3:
            moved += 1
        else:
            kept += 1
        placed.append(Box(cx, cy, w, h))
    return moved, kept, hidden, placed


def place_labels(obstacles, placed, labels):
    """Headings: nudge along the board, drop if there is nowhere legible."""
    moved = dropped = kept = 0
    for L in labels:
        w, h = L["span"]
        b = Box(L["x"], L["y"], w, h)
        if not any(b.hits(o, CLEAR) for o in obstacles + placed):
            kept += 1
            continue
        best = None
        for dy in [d * 0.5 for d in range(1, 25)]:
            for sgn in (-1, 1):
                cy = L["y"] + sgn * dy
                bb = Box(L["x"], cy, w, h)
                if 3 + h / 2 < cy < 132 - h / 2 and \
                        not any(bb.hits(o, CLEAR) for o in obstacles + placed):
                    best = cy; break
            if best is not None:
                break
        if best is None:
            L["drop"] = True; dropped += 1
        else:
            L["ny"] = best; moved += 1
            placed.append(Box(L["x"], best, w, h))
    return moved, kept, dropped


# --------------------------------------------------------------------------
# writing back
# --------------------------------------------------------------------------
def apply(text, refs, labels):
    out = text
    for r in refs:
        pat = re.compile(r'(\(property "Reference" "' + re.escape(r["ref"])
                         + r'"\s*\(at )([-\d.]+ [-\d.]+(?: [-\d.]+)?)(\))')
        if r.get("hide"):
            # keep it on the assembly drawing, off the silkscreen
            m = re.search(r'(\(property "Reference" "' + re.escape(r["ref"])
                          + r'".*?\(layer ")F\.SilkS(")', out, re.S)
            if m:
                out = out[:m.start()] + m.group(1) + "F.Fab" + m.group(2) + out[m.end():]
            continue
        if "nlx" not in r:
            continue
        out = pat.sub(lambda m: f'{m.group(1)}{r["nlx"]:.3f} {r["nly"]:.3f}{m.group(3)}',
                      out, count=1)
    for L in labels:
        if L.get("drop"):
            out = out.replace("\n\t" + L["raw"], "", 1)
            out = out.replace(L["raw"], "", 1)
        elif "ny" in L:
            new = re.sub(r'(\(at [-\d.]+ )[-\d.]+', lambda m: m.group(1) + f'{L["ny"]:.3f}',
                         L["raw"], count=1)
            out = out.replace(L["raw"], new, 1)
    return out


def main(path=PCB):
    text = open(path, errors='replace').read()
    obstacles, refs, labels = collect(text)
    print(f"{len(refs)} reference designators, {len(labels)} board labels, "
          f"{len(obstacles)} silk obstacles (pads + footprint outlines)")

    moved, kept, hidden, placed = place(obstacles, refs)
    print(f"references: {moved} moved, {kept} already clear, "
          f"{hidden} moved to F.Fab (nowhere legible on silk)")

    lmoved, lkept, ldropped = place_labels(obstacles, placed, labels)
    print(f"labels:     {lmoved} moved, {lkept} already clear, {ldropped} dropped")

    open(path, "w").write(apply(text, refs, labels))
    print("written")
    return 0


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