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
"""Read a .kicad_mod footprint file and emit placed (footprint ...) instances
for a .kicad_pcb, the same way symlib.py handles schematic symbols."""
import re, os, uuid as U

def uid(): return str(U.uuid4())

class FpLib:
    def __init__(self, mod_paths):
        """mod_paths: {"Package_SO:SOIC-14_3.9x8.7mm_P1.27mm": "/path/to/file.kicad_mod", ...}"""
        self.src = {}
        for lib_id, path in mod_paths.items():
            self.src[lib_id] = open(path, errors='replace').read()
        self.net_num = {"": 0}   # net name -> net number, assigned in first-seen order

    def net_id(self, name):
        """Look up (assigning if new) the net number for a net name. Used so pads,
        zones, tracks and vias all reference the SAME numbered net table -- tracks
        (unlike pads/zones) only accept a bare (net N), no name, so a real top-level
        (net N "name") table is required once any copper routing is added."""
        name = name or ""
        if name not in self.net_num:
            self.net_num[name] = len(self.net_num)
        return self.net_num[name]

    def net_table(self):
        """(net N "name") lines for every net seen so far, in assigned-number order,
        for the board header."""
        items = sorted(self.net_num.items(), key=lambda kv: kv[1])
        return '\n'.join(f'\t(net {n} "{name}")' for name, n in items)

    def pads(self, lib_id):
        """[(number, x, y, angle_or_None)] in the footprint's own frame (0-rotation)."""
        t = self.src[lib_id]
        out = []
        for m in re.finditer(r'\(pad "(\w+)" \w+ \w+\s*\n\s*\(at ([-\d.]+) ([-\d.]+)(?: ([\d.-]+))?\)', t):
            num, x, y, ang = m.groups()
            out.append((num, float(x), float(y), float(ang) if ang else 0.0))
        return out

    def pads_full(self, lib_id):
        """[(number, x, y, angle_or_None, layers_str)] -- like pads() but also returns
        the pad's (layers ...) string, so a router can tell SMD (F.Cu-only) pads from
        THT (*.Cu, any layer) pads."""
        t = self.src[lib_id]
        out = []
        for m in re.finditer(
                r'\(pad "(\w+)" \w+ \w+\s*\n\s*\(at ([-\d.]+) ([-\d.]+)(?: ([\d.-]+))?\)\n'
                r'\s*\(size [^\n]*\)\n\s*(?:\(drill[^\n]*\)\n\s*)?\(layers ([^)]*)\)',
                t):
            num, x, y, ang, layers = m.groups()
            out.append((num, float(x), float(y), float(ang) if ang else 0.0, layers))
        return out

    @staticmethod
    def _absolutize_pad_angle(pad_block, frot):
        """Bake the footprint's rotation into the pad's own angle.

        KiCad stores a pad's `(at x y angle)` angle ABSOLUTELY, in board
        coordinates -- the footprint's rotation moves a pad's POSITION but does
        not orient its SHAPE. Verified directly against KiCad 10 rather than
        assumed: a `(size 3 0.6)` SMD rect inside a footprint placed at 90
        degrees still plots 3.000mm wide by 0.600mm tall, and DRC measures it
        that way too, while the same footprint's fp_line and fp_rect graphics
        DO rotate. Copying library pad blocks verbatim therefore left 21 pads
        across Q1-Q5 and D2-D4 -- the power FETs and switching diodes -- rotated
        90 degrees away from where the layout intends them, on the real
        fabricated copper, with DRC perfectly happy about it.

        Writing the angle absolutely is what KiCad's own editor does when you
        rotate a footprint, and it makes the file, the plot, KiCad's DRC and
        this project's geometry checker all agree.
        """
        if not frot:
            return pad_block
        m = re.search(r'\(at ([-\d.]+) ([-\d.]+)(?: ([-\d.]+))?\)', pad_block)
        if not m:
            return pad_block
        ang = (float(m.group(3) or 0) + frot) % 360
        new_at = (f'(at {m.group(1)} {m.group(2)}'
                  + (f' {ang:g}' if ang else '') + ')')
        return pad_block[:m.start()] + new_at + pad_block[m.end():]

    def instance(self, lib_id, ref, value, x, y, rot, net_by_pad, footprint_field=None,
                 renumber=None):
        """net_by_pad: {pad_number: net_name or None}. Returns the (footprint ...) text.

        renumber: {physical_pad_number: schematic_pin_number}, for footprints whose
        left-to-right/geometric pad order doesn't match the schematic symbol's own
        pin numbering (e.g. a generic TO-92 NPN symbol numbered B-C-E vs a real
        part's physical E-B-C pinout). KiCad's own schematic<->PCB parity check
        assumes pad number == schematic pin number 1:1; the fix is to relabel
        which NUMBER a given physical pad carries (keeping its real x,y position),
        not to quietly swap nets onto the wrong-numbered pad - that satisfies our
        own net wiring but not KiCad's parity check, and more importantly would
        leave the pad NUMBER lying about which real pin it is if anyone reads the
        footprint directly.
        """
        renumber = renumber or {}
        t = self.src[lib_id]
        body = t.split('\n', 1)[1]                       # drop the opening (footprint "name"
        body = body.rsplit('\n)', 1)[0]                   # drop the final closing paren
        body = re.sub(r'^\t\(version [^\n]*\n', '', body)
        body = re.sub(r'^\t\(generator [^\n]*\n', '', body)
        body = re.sub(r'^\t\(generator_version [^\n]*\n', '', body)
        body = re.sub(r'^\t\(layer "F\.Cu"\)\n', '', body, count=1)

        from symlib import SymLib
        forms = SymLib._forms(body)
        out_forms = []

        def close_with_uuid(block, extra=''):
            idx = block.rstrip().rfind('\n')
            head, tail = block[:idx+1], block[idx+1:]
            indent = re.match(r'\t*', tail).group(0)
            return head + (extra) + f'{indent}\t(uuid "{uid()}")\n' + tail

        for f in forms:
            kind = re.match(r'\((\w+)', f).group(1)
            if kind == 'property':
                name = re.match(r'\(property "([^"]+)"', f).group(1)
                if name == 'Reference':
                    f = re.sub(r'\(property "Reference" "[^"]*"', f'(property "Reference" "{ref}"', f)
                elif name == 'Value':
                    f = re.sub(r'\(property "Value" "[^"]*"', f'(property "Value" "{value}"', f)
                if not re.search(r'\(uuid ', f):
                    f = close_with_uuid(f)
                out_forms.append(f)
            elif kind == 'pad':
                phys_num = re.match(r'\(pad "(\w*)"', f).group(1)   # some footprints have a
                # nameless mechanical/keepout pad ("") - never carries a net
                num = renumber.get(phys_num, phys_num)
                if num != phys_num:
                    f = re.sub(r'^\(pad "\w*"', f'(pad "{num}"', f, count=1)
                net = net_by_pad.get(num) if num else None
                extra = f'\t\t(net {self.net_id(net)} "{net}")\n' if net else ''
                f = self._absolutize_pad_angle(f, rot)
                f = close_with_uuid(f, extra)
                out_forms.append(f)
            elif kind in ('fp_line', 'fp_rect', 'fp_circle', 'fp_poly', 'fp_arc', 'fp_text'):
                if not re.search(r'\(uuid ', f):
                    f = close_with_uuid(f)
                out_forms.append(f)
            else:
                out_forms.append(f)
        new_body = '\n'.join('\t' + x for x in out_forms)

        rot_txt = f' {rot:g}' if rot else ''
        header = (f'\t(footprint "{lib_id}"\n'
                  f'\t\t(layer "F.Cu")\n'
                  f'\t\t(uuid "{uid()}")\n'
                  f'\t\t(at {x:.3f} {y:.3f}{rot_txt})\n')
        return header + new_body + '\n\t)'