"""Correct the migration's missing Y reflection, preserving source spans. The EasyEDA footprint coordinates are Y-up; KiCad footprint coordinates are Y-down. Reflect geometry and negate angles, not pad numbers or copper sides. Verify every pad against archived efoo coordinates and IC pin chirality against standard KiCad footprints. The marker prevents a second reflection. """ import argparse import hashlib import json from pathlib import Path import re import shutil import pcbnew as pcb from schematic_maintenance import ROOT, Node, parse, child, children MARKER = 'phonerail-y-axis-corrected-r1' LIB = ROOT / 'lib/PhoneRail.pretty' OUT = ROOT / 'out/footprint-orientation' def source_footprints(): found = {} for path in sorted((ROOT.parent / '.claude/eda_src/epro/FOOTPRINT').glob('*.efoo')): rows = [json.loads(line) for line in path.read_text(encoding='utf-8').splitlines() if line.strip()] head = next(row[1] for row in rows if row[0] == 'HEAD') name = re.sub(r'[<>:"/\\|?*\r\n]', '_', head.get('title') or path.stem) found.setdefault(name, (path, [r for r in rows if r[0] == 'PAD'])) return found def reflect(source): root = parse(source) assert not children(root, 'model'), '3D model reflection needs a separate review' edits = [] def visit(node): if node[0] in {'at', 'start', 'mid', 'end', 'center', 'xy', 'offset', 'rect_delta'}: assert len(node) in (3,4) and all(isinstance(v,str) for v in node) values = list(node[1:]) values[1] = f'{-float(values[1]):g}' if len(values) == 3: values[2] = f'{-float(values[2]):g}' edits.append((node.start,node.end,'('+node[0]+' '+' '.join(values)+')')) else: for item in node: if isinstance(item, Node): visit(item) visit(root) edits.append((root.end-1,root.end-1,f'\t(tags "{MARKER}")\n')) for start,end,replacement in sorted(edits,reverse=True): source = source[:start]+replacement+source[end:] return source def verify_pads(path, rows, reflected): root = parse(path.read_text(encoding='utf-8')) pads = children(root,'pad') assert len(pads) == len(rows), (path.name,'pad count') for pad,row in zip(pads,rows): assert pad[1] == str(row[5]), (path.name,'pad number') at = child(pad,'at') x,y = float(row[6])*.0254,float(row[7])*.0254 if reflected: y = -y assert abs(float(at[1])-x) < 1e-4 and abs(float(at[2])-y) < 1e-4, (path.name,pad[1],'pad coordinate') angle = float(at[3]) if len(at) == 4 else 0 expected = (row[8] or 0)*(-1 if reflected else 1) assert abs((angle-expected+180) % 360-180) < .001, (path.name,pad[1],'pad rotation') return len(pads) def repair_slot_drills(source, rows): root = parse(source) edits = [] count = 0 for pad,row in zip(children(root,'pad'),rows): if not isinstance(row[9],list) or row[9][0] != 'SLOT': continue # efoo column 14 is drill rotation, independent of pad rotation. angle = ((row[14] or 0)-(row[8] or 0)) % 180 assert angle in (0,90), 'Nonorthogonal drill needs a dedicated pad transform' w,h = [round(float(v)*.0254,5) for v in row[9][1:3]] if angle == 90: w,h = h,w drill = child(pad,'drill') assert drill[1] == 'oval' edits.append((drill.start,drill.end,f'(drill oval {w:g} {h:g})')) count += 1 for start,end,replacement in sorted(edits,reverse=True): source = source[:start]+replacement+source[end:] return source,count def explicit_pth_masks(source): edits = [] for pad in children(parse(source),'pad'): if pad[2] in {'thru_hole','np_thru_hole'} and not child(pad,'tenting'): layers = child(pad,'layers') assert '*.Mask' in layers or ('F.Mask' in layers and 'B.Mask' in layers) edits.append((pad.end-1,' (tenting (front no) (back no))\n')) for at,text in sorted(edits,reverse=True): source = source[:at]+text+source[at:] return source def chirality(lib, name, pins): fp = pcb.FootprintLoad(str(lib), name) if fp is None: raise ValueError(name) pads = {p.GetNumber():p.GetPosition() for p in fp.Pads()} a,b,c = [pads[n] for n in pins] area = (b.x-a.x)*(c.y-a.y)-(b.y-a.y)*(c.x-a.x) assert area != 0 return 1 if area > 0 else -1 def main(): ap = argparse.ArgumentParser(description=__doc__) ap.add_argument('--apply', action='store_true') args = ap.parse_args() target = OUT / 'corrected.pretty' target.mkdir(parents=True,exist_ok=True) backup = OUT / 'before.pretty' backup.mkdir(exist_ok=True) originals = source_footprints() report = {'files':[], 'pad_coordinates_checked':0, 'standard_chirality_checks':[], 'slot_drill_rotations_checked':0} for path in sorted(LIB.glob('*.kicad_mod')): original, rows = originals[path.stem] source = path.read_text(encoding='utf-8') done = MARKER in source verify_pads(path, rows, done) if not done: if not (backup/path.name).exists(): shutil.copyfile(path, backup/path.name) source = reflect(source) source, slots = repair_slot_drills(source, rows) source = explicit_pth_masks(source) report['slot_drill_rotations_checked'] += slots candidate = target/path.name candidate.write_text(source,encoding='utf-8') report['pad_coordinates_checked'] += verify_pads(candidate,rows,True) report['files'].append({'file':path.name,'source_efoo':str(original), 'source_sha256':hashlib.sha256(original.read_bytes()).hexdigest(), 'corrected_sha256':hashlib.sha256(candidate.read_bytes()).hexdigest()}) for custom, standard, pins in [ ('SOIC-16_L9.9-W3.9-P1.27-LS6.0-BL', 'Package_SO/SOIC-16_3.9x9.9mm_P1.27mm', ('1','2','9')), ('SOIC-8_L5.0-W4.0-P1.27-LS6.0-BL', 'Package_SO/SOIC-8_3.9x4.9mm_P1.27mm', ('1','2','5')), ('SOT-23-3_L2.9-W1.3-P1.90-LS2.4-BR', 'Package_TO_SOT_SMD/SOT-23', ('1','2','3'))]: lib,name = standard.split('/') reference = chirality(Path('D:/KiCad/share/kicad/footprints')/(lib+'.pretty'),name,pins) assert chirality(target,custom,pins) == reference, (custom,'mirrored relative to KiCad standard') report['standard_chirality_checks'].append({'custom':custom,'standard':standard,'pass':True}) if args.apply: for path in target.glob('*.kicad_mod'): shutil.copyfile(path,LIB/path.name) # Preserve the standard fan connector geometry; only make mask intent explicit. for path in (ROOT/'lib/PhoneRail-Fan.pretty').glob('*.kicad_mod'): source = path.read_text(encoding='utf-8') revised = explicit_pth_masks(source) if revised != source: path.write_text(revised,encoding='utf-8') report['applied'] = args.apply (OUT/'audit.json').write_text(json.dumps(report,indent=2)+'\n',encoding='utf-8') print(f'{len(report["files"])} footprints; {report["pad_coordinates_checked"]} pad coordinates; ' f'{len(report["standard_chirality_checks"])} standard IC chirality checks; applied={args.apply}') if __name__ == '__main__': main()