kicad:EasyEDA→KiCad 工程(Board1/Board2)、库、脚本与评审文档(IC 引脚、风扇、热、EDL 供电、布局规则)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
这个提交包含在:
@@ -0,0 +1,174 @@
|
||||
"""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()
|
||||
在新工单中引用
屏蔽一个用户