175 行
7.3 KiB
Python
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()
|