main
Ray Publish 0.2.7 db87ed5 1d ago
"""Read a .kicad_pcb with the Python standard library: footprints and pads (absolute positions), tracks, arcs,
vias, zones with their filled polygons per layer, and the aux origin. No KiCad install needed.

Coordinates are returned in mm in KiCad's own frame (x right, y DOWN). `design(p)` converts a point to the
board's design frame (origin at the aux origin when one is set, y up), which is how the reports print positions.
"""
import math, re

_TOK = re.compile(r'\(|\)|"(?:[^"\\]|\\.)*"|[^\s()]+')


def parse(text):
    stack = [[]]
    for t in _TOK.findall(text):
        if t == '(':
            stack.append([])
        elif t == ')':
            e = stack.pop(); stack[-1].append(e)
        else:
            stack[-1].append(t[1:-1] if t.startswith('"') else t)
    return stack[0][0]


def kids(e, head):
    return [c for c in e if isinstance(c, list) and c and c[0] == head]


def kid(e, head):
    k = kids(e, head)
    return k[0] if k else None


def num(v):
    return float(v)


def rot(x, y, deg):
    """KiCad's rotation in its y-down frame: a positive angle turns counter-clockwise as seen on screen."""
    a = math.radians(deg)
    return x * math.cos(a) + y * math.sin(a), -x * math.sin(a) + y * math.cos(a)


class Board:
    def __init__(self, path):
        self.path = path
        root = parse(open(path, encoding='utf-8').read())
        setup = kid(root, 'setup')
        ao = kid(setup, 'aux_axis_origin') if setup else None
        self.origin = (num(ao[1]), num(ao[2])) if ao else (0.0, 0.0)
        self.nets = {}
        for n in kids(root, 'net'):
            if len(n) >= 3: self.nets[n[1]] = n[2]
        self.pads, self.footprints = [], {}
        for f in kids(root, 'footprint'):
            at = kid(f, 'at'); fx, fy = num(at[1]), num(at[2]); frot = num(at[3]) if len(at) > 3 else 0.0
            ref = next((p[2] for p in kids(f, 'property') if p[1] == 'Reference'), None)
            if ref is None:
                fr = kid(f, 'fp_text'); ref = fr[2] if fr else '?'
            self.footprints[ref] = {'at': (fx, fy), 'rot': frot, 'layer': kid(f, 'layer')[1]}
            for p in kids(f, 'pad'):
                pat = kid(p, 'at'); px, py = num(pat[1]), num(pat[2]); prot = num(pat[3]) if len(pat) > 3 else 0.0
                dx, dy = rot(px, py, frot)
                size = kid(p, 'size'); w, h = num(size[1]), num(size[2])
                netk = kid(p, 'net'); net = (netk[2] if len(netk) > 2 else self.nets.get(netk[1], netk[1])) if netk else None
                layers = kid(p, 'layers')[1:] if kid(p, 'layers') else []
                self.pads.append({'ref': ref, 'num': p[1], 'kind': p[2], 'shape': p[3], 'x': fx + dx, 'y': fy + dy,
                                  'w': w, 'h': h, 'rot': prot, 'net': net, 'layers': layers})
        self.tracks, self.vias = [], []
        for s in kids(root, 'segment'):
            st, en = kid(s, 'start'), kid(s, 'end')
            self.tracks.append({'a': (num(st[1]), num(st[2])), 'b': (num(en[1]), num(en[2])), 'w': num(kid(s, 'width')[1]),
                                'layer': kid(s, 'layer')[1], 'net': self._net(s), 'arc': False})
        for s in kids(root, 'arc'):
            st, md, en = kid(s, 'start'), kid(s, 'mid'), kid(s, 'end')
            self.tracks.append({'a': (num(st[1]), num(st[2])), 'b': (num(en[1]), num(en[2])), 'mid': (num(md[1]), num(md[2])),
                                'w': num(kid(s, 'width')[1]), 'layer': kid(s, 'layer')[1], 'net': self._net(s), 'arc': True})
        for v in kids(root, 'via'):
            at = kid(v, 'at')
            self.vias.append({'x': num(at[1]), 'y': num(at[2]), 'd': num(kid(v, 'size')[1]), 'net': self._net(v)})
        self.zones = []
        for z in kids(root, 'zone'):
            net = self._net(z)
            fills = []
            for fp in kids(z, 'filled_polygon'):
                lay = kid(fp, 'layer')[1]
                pts = [(num(q[1]), num(q[2])) for q in kid(fp, 'pts') if isinstance(q, list) and q[0] == 'xy']
                fills.append({'layer': lay, 'pts': pts})
            lk = kid(z, 'layers') or kid(z, 'layer')
            self.zones.append({'net': net, 'layers': list(lk[1:]) if lk else [], 'fills': fills})

    def _net(self, e):
        n = kid(e, 'net')
        if not n: return None
        return n[2] if len(n) > 2 else self.nets.get(n[1], n[1])

    def pad(self, ref, number):
        for p in self.pads:
            if p['ref'] == ref and p['num'] == str(number): return p
        raise KeyError(f'{ref}.{number}')

    def pads_of(self, ref):
        return [p for p in self.pads if p['ref'] == ref]

    def design(self, p):
        return (round(p[0] - self.origin[0], 3), round(self.origin[1] - p[1], 3))


def pad_polygon(p, grow=0.0):
    """The pad as a rotated rectangle (round/oval/roundrect pads are approximated by their bounding rectangle;
    circles are kept as circles by callers that need it)."""
    hw, hh = p['w'] / 2 + grow, p['h'] / 2 + grow
    out = []
    for cx, cy in ((-hw, -hh), (hw, -hh), (hw, hh), (-hw, hh)):
        dx, dy = rot(cx, cy, p['rot'])
        out.append((p['x'] + dx, p['y'] + dy))
    return out


def inside(pt, poly):
    x, y = pt; c = False
    for (x1, y1), (x2, y2) in zip(poly, poly[1:] + poly[:1]):
        if (y1 > y) != (y2 > y) and x < (x2 - x1) * (y - y1) / (y2 - y1) + x1:
            c = not c
    return c


def area(poly):
    return abs(sum(a[0] * b[1] - b[0] * a[1] for a, b in zip(poly, poly[1:] + poly[:1]))) / 2