文件
phonerail/kicad/scripts/repair_footprint_handedness.py
T

175 行
7.3 KiB
Python

"""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()