"""Create unrouted KiCad boards with verified net import and mechanical anchors. Run with the Python shipped with KiCad. Refuse to overwrite edited boards. Electrical parts awaiting layout are grouped outside the board, not silently presented as a completed placement. No EasyEDA tracks or copper are imported. """ import hashlib import json import math from pathlib import Path import shutil import subprocess import xml.etree.ElementTree as ET import pcbnew as pcb from check_hardware import Board from schematic_maintenance import ROOT, parse, child, children CONFIG = json.loads((ROOT / 'pcb-mechanical.json').read_text(encoding='utf-8')) OUT = ROOT / 'out/pcb-start' MECH_LIB = ROOT / 'lib/PhoneRail-Mechanical.pretty' def digest(path): return hashlib.sha256(path.read_bytes()).hexdigest() def xy(x, y): return pcb.VECTOR2I(pcb.FromMM(x), pcb.FromMM(y)) def top(x, y): ox, oy = CONFIG['origin_kicad_mm'] return ox + x, oy + CONFIG['size_mm'][1] - y def site(n, u, v): x, y, angle = CONFIG['phone_sites'][str(n)] if angle == 180: u, v = CONFIG['phone_size_mm'][0] - u, CONFIG['phone_size_mm'][1] - v return top(x + u, y + v) def layer_set(*layers): result = pcb.LSET() for layer in layers: result.AddLayer(layer) return result def draw_line(board, a, b, layer=pcb.Edge_Cuts, width=.05): item = pcb.PCB_SHAPE() item.SetShape(pcb.SHAPE_T_SEGMENT) item.SetStart(xy(*a)) item.SetEnd(xy(*b)) item.SetLayer(layer) item.SetWidth(pcb.FromMM(width)) board.Add(item) def rounded_rect(board, x, y, w, h, r, layer=pcb.Edge_Cuts): points = [(x+r,y), (x+w-r,y), (x+w,y+r), (x+w,y+h-r), (x+w-r,y+h), (x+r,y+h), (x,y+h-r), (x,y+r)] for i in (0, 2, 4, 6): draw_line(board, points[i], points[i+1], layer) for i, center, mid_angle in [(1,(x+w-r,y+r),-45), (3,(x+w-r,y+h-r),45), (5,(x+r,y+h-r),135), (7,(x+r,y+r),225)]: mid = (center[0]+r*math.cos(math.radians(mid_angle)), center[1]+r*math.sin(math.radians(mid_angle))) arc = pcb.PCB_SHAPE() arc.SetShape(pcb.SHAPE_T_ARC) arc.SetArcGeometry(xy(*points[i]), xy(*mid), xy(*points[(i+1) % 8])) arc.SetLayer(layer) arc.SetWidth(pcb.FromMM(.05)) board.Add(arc) def label(board, text, x, y, size=1, layer=pcb.Dwgs_User): item = pcb.PCB_TEXT(board) item.SetText(text) item.SetPosition(xy(x, y)) item.SetTextSize(xy(size, size)) item.SetTextThickness(pcb.FromMM(size / 7)) item.SetLayer(layer) board.Add(item) def keepout(board, name, rect, layers, copper=False): zone = pcb.ZONE(board) zone.SetIsRuleArea(True) zone.SetZoneName(name) zone.SetLayerSet(layer_set(*layers)) zone.SetDoNotAllowFootprints(True) zone.SetDoNotAllowTracks(copper) zone.SetDoNotAllowVias(copper) zone.SetDoNotAllowPads(copper) zone.SetDoNotAllowZoneFills(copper) x0, y0, x1, y1 = rect outline = zone.Outline() outline.NewOutline() for x, y in [(x0,y0), (x1,y0), (x1,y1), (x0,y1)]: outline.Append(pcb.FromMM(x), pcb.FromMM(y)) board.Add(zone) def mechanical_library(): MECH_LIB.mkdir(exist_ok=True) for name, drill, pad, radius, plated in [ ('M3_GND_D3.2', 3.2, 6, 3.5, True), ('M1.6_NPTH_D1.8', 1.8, 1.8, CONFIG['phone_hole_courtyard_radius_mm'], False)]: fp = pcb.FOOTPRINT(None) fp.SetFPIDAsString('PhoneRail-Mechanical:' + name) fp.SetReference('H**') fp.SetValue(name) fp.SetAttributes(pcb.FP_BOARD_ONLY | pcb.FP_EXCLUDE_FROM_BOM | pcb.FP_EXCLUDE_FROM_POS_FILES) hole = pcb.PAD(fp) hole.SetNumber('1' if plated else '') hole.SetAttribute(pcb.PAD_ATTRIB_PTH if plated else pcb.PAD_ATTRIB_NPTH) hole.SetShape(pcb.PAD_SHAPE_CIRCLE) hole.SetSize(xy(pad, pad)) hole.SetDrillSize(xy(drill, drill)) layers = pcb.LSET.AllCuMask() layers.AddLayer(pcb.F_Mask) layers.AddLayer(pcb.B_Mask) hole.SetLayerSet(layers) fp.Add(hole) for layer in (pcb.F_CrtYd, pcb.B_CrtYd): shape = pcb.PCB_SHAPE(fp) shape.SetShape(pcb.SHAPE_T_CIRCLE) shape.SetCenter(xy(0, 0)) shape.SetEnd(xy(radius, 0)) shape.SetWidth(pcb.FromMM(.05)) shape.SetLayer(layer) fp.Add(shape) fp.Reference().SetVisible(False) fp.Value().SetVisible(False) pcb.PCB_IO_MGR.FindPlugin(pcb.PCB_IO_MGR.KICAD_SEXP).FootprintSave(str(MECH_LIB), fp) path = MECH_LIB/(name+'.kicad_mod') path.write_text(pth_apertures(path.read_text(encoding='utf-8')),encoding='utf-8') def mount(board, name, ref, position, gnd=None): fp = pcb.FootprintLoad(str(MECH_LIB), name) fp.SetReference(ref) fp.SetPosition(xy(*position)) fp.SetLocked(True) if gnd: for pad in fp.Pads(): pad.SetNet(gnd) board.Add(fp) def place(fp, position, rotation=0, bottom=False, locked=True): fp.SetPosition(xy(*position)) if bottom: fp.Flip(fp.GetPosition(), pcb.FLIP_DIRECTION_TOP_BOTTOM) fp.SetOrientationDegrees(rotation) fp.SetLocked(locked) fp.SetField('Placement', 'MECHANICAL_ANCHOR' if locked else 'STAGED_NOT_PLACED') for field in fp.GetFields(): if field.GetName() not in {'Reference', 'Value'}: field.SetVisible(False) field.SetLayer(pcb.B_Fab if bottom else pcb.F_Fab) field.SetMirrored(bottom) fp.Value().SetVisible(False) fp.Reference().SetTextSize(xy(.8, .8)) fp.Reference().SetTextThickness(pcb.FromMM(.12)) box = fp.GetBoundingBox(False, False) fp.Reference().SetPosition(pcb.VECTOR2I(fp.GetPosition().x, box.GetTop()-pcb.FromMM(1))) def stage(board, parts, source, number): groups = {} for ref, fp in parts.items(): if fp.IsLocked(): continue groups.setdefault(source.parts[ref]['page'], []).append(fp) xstart = CONFIG['origin_kicad_mm'][0] + CONFIG['size_mm'][0] + 15 ystart = CONFIG['origin_kicad_mm'][1] column_y = [ystart] * 3 for index, (page, fps) in enumerate(sorted(groups.items())): col = index % 3 left, y = xstart + col*77, column_y[col] + 6 label(board, page.removesuffix('.kicad_sch'), left+32, y-4, 1.2) cursor, row_height = left, 0 for fp in sorted(fps, key=lambda f: f.GetReference()): place(fp, (0, 0), bottom=number == 2, locked=False) box = fp.GetBoundingBox(False, False) w, h = pcb.ToMM(box.GetWidth()), pcb.ToMM(box.GetHeight()) if cursor+w > left+70 and cursor > left: cursor, y, row_height = left, y+row_height+4, 0 offset = (cursor-pcb.ToMM(box.GetLeft()), y-pcb.ToMM(box.GetTop())) fp.Move(xy(*offset)) cursor += w+3 row_height = max(row_height, h) column_y[col] = y+row_height+7 def add_stackup(path): source = path.read_text(encoding='utf-8') setup = child(parse(source), 'setup') layers = ['F.Cu','In1.Cu','In2.Cu','B.Cu'] items = ['(stackup'] for i, layer in enumerate(layers): items.append(f'(layer "{layer}" (type "copper") (thickness {CONFIG["copper_mm"][i]}))') if i < 3: kind = 'core' if i == 1 else 'prepreg' items.append(f'(layer "dielectric {i+1}" (type "{kind}") ' f'(thickness {CONFIG["dielectric_mm"][i]}) (material "FR4") ' '(epsilon_r 4.3) (loss_tangent 0.02))') items.append('(copper_finish "ENIG") (dielectric_constraints no))') old = child(setup, 'stackup') start, end = (old.start, old.end) if old else (setup.end-1, setup.end-1) source = source[:start]+'\n'.join(items)+'\n'+source[end:] path.write_text(pth_apertures(source), encoding='utf-8') def pth_apertures(source): # Explicit no-tenting survives a native load/save cycle in KiCad 10. edits = [] root = parse(source) footprints = [root] if root[0] == 'footprint' else children(root,'footprint') for footprint in footprints: for pad in children(footprint,'pad'): if pad[2] in {'thru_hole','np_thru_hole'}: layers = child(pad,'layers') edits.append((layers.start,layers.end,'(layers "*.Cu" "*.Mask")')) if not child(pad,'tenting'): edits.append((pad.end-1,pad.end-1,' (tenting (front no) (back no))\n')) for start,end,replacement in sorted(edits,reverse=True): source = source[:start]+replacement+source[end:] return source def native_net_names(source): if hasattr(source, 'pcb_net_names'): return source.pcb_net_names path = OUT/f'Board{source.number}.net' result = subprocess.run(['D:/KiCad/bin/kicad-cli.exe','sch','export','netlist', '--format','kicadsexpr','-o',str(path),str(source.path)], capture_output=True,encoding='utf-8',errors='replace',timeout=60) path.with_suffix('.net.log').write_text(result.stdout+result.stderr,encoding='utf-8') if result.returncode: raise RuntimeError(f'Native netlist failed: {path}') nets = child(parse(path.read_text(encoding='utf-8')),'nets') by_members = {frozenset((child(node,'ref')[1],child(node,'pin')[1]) for node in children(net,'node')): child(net,'name')[1] for net in children(nets,'net')} # XML is human-readable; the native netlist retains KiCad's escaped names. source.pcb_net_names = {name:by_members[frozenset(members)] for name,members in source.net_members.items()} return source.pcb_net_names def project_settings(number): path = ROOT / f'PhoneRail-Board{number}/PhoneRail-Board{number}.kicad_pro' project = json.loads(path.read_text(encoding='utf-8')) net = project.setdefault('net_settings', {}) base = dict(net['classes'][0]) base.update(clearance=.15, track_width=.25, via_diameter=.6, via_drill=.3, diff_pair_width=.28, diff_pair_gap=.18, diff_pair_via_gap=.25) classes = [dict(base, name='Default')] for name, width, clearance in [('USB90',.28,.15), ('PWR4A',1.2,.2), ('PWR5V',.8,.2), ('PWR12V',2,.2)]: classes.append(dict(base, name=name, track_width=width, clearance=clearance)) net['classes'] = classes patterns = [(n, 'PWR4A') for n in ['VO*_P', *[f'VO{i}' for i in range(1,6)], *[f'Net-(U{i}-VIN+)' for i in range(8,13)]]] patterns += [(n, 'PWR12V') for n in ['+12V', '*/FAN_12V_SW', 'Net-(J1-+)', 'Net-(D1-C)']] patterns += [(n, 'PWR5V') for n in ['+5V', *[f'VBUS{i}' for i in range(1,6)]]] patterns += [(n, 'USB90') for n in ['USB*_DP','USB*_DM','USB*_DPJ','USB*_DMJ','UP_DP','UP_DM']] net['netclass_patterns'] = [{'pattern': p, 'netclass': c} for p, c in patterns] settings = project['board'].setdefault('design_settings', {}) settings.setdefault('rules', {}).update(min_clearance=.127, min_track_width=.127, min_via_diameter=.6, min_through_hole_diameter=.3, min_hole_to_hole=.25, min_copper_edge_clearance=.3, solder_mask_clearance=.05, solder_mask_min_width=.1) return path, json.dumps(project, indent=2, ensure_ascii=False)+'\n' def build(number, source): board = pcb.BOARD() board.SetCopperLayerCount(4) board.GetDesignSettings().SetBoardThickness(pcb.FromMM(CONFIG['thickness_mm'])) board.GetTitleBlock().SetTitle(f'PhoneRail Board{number} R1 - placement start') board.GetTitleBlock().SetRevision('R1-PLACEMENT-START') origin = CONFIG['origin_kicad_mm'] rounded_rect(board, *origin, *CONFIG['size_mm'], CONFIG['corner_radius_mm']) nets = {} names = native_net_names(source) for name in source.net_members: net = pcb.NETINFO_ITEM(board, names[name]) board.Add(net) nets[name] = net tree = ET.parse(OUT / f'Board{number}.xml') root_uuid = child(parse(source.path.read_text(encoding='utf-8')), 'uuid')[1] parts = {} for c in tree.findall('components/comp'): ref, fpname = c.get('ref'), c.findtext('footprint') if not fpname: raise ValueError(f'{ref}: no footprint') lib, name = fpname.split(':') if lib == 'PhoneRail': assert 'phonerail-y-axis-corrected-r1' in (ROOT/f'lib/{lib}.pretty/{name}.kicad_mod').read_text(encoding='utf-8'), 'Run repair_footprint_handedness.py before importing the migrated library' fp = pcb.FootprintLoad(str(ROOT / f'lib/{lib}.pretty'), name) if fp is None: raise ValueError(f'{ref}: missing {fpname}') fp.SetFPIDAsString(fpname) fp.SetReference(ref) fp.SetValue(c.findtext('value')) fp.SetSheetname(source.parts[ref]['page'].removesuffix('.kicad_sch')) fp.SetSheetfile(source.parts[ref]['page']) fp.SetExcludedFromBOM(source.parts[ref]['in_bom'] != 'yes') fp.SetExcludedFromPosFiles(source.parts[ref]['in_bom'] != 'yes') fp.SetDNP(source.parts[ref]['dnp'] == 'yes') for field in c.findall('fields/field'): if field.get('name') not in {'Reference','Value','Footprint'}: fp.SetField(field.get('name'), field.text or '') path = pcb.KIID_PATH() ids = [root_uuid, *c.find('sheetpath').get('tstamps').strip('/').split('/'), c.findtext('tstamps')] for uid in filter(None, ids): path.push_back(pcb.KIID(uid)) fp.SetPath(path) board.Add(fp) for pad in fp.Pads(): net_name = source.pins.get((ref, pad.GetNumber())) if net_name: pad.SetNet(nets[net_name]) parts[ref] = fp for side, position in CONFIG['headers_source_mm'].items(): ref = {1: {'left':'J24','right':'J25'}, 2: {'left':'J31','right':'J32'}}[number][side] place(parts[ref], top(*position), bottom=number == 2) holes = list(CONFIG['mounting_holes_common_mm']) if number == 2: holes += CONFIG['mounting_holes_carrier_only_mm'] for i, point in enumerate(holes, 1): mount(board, 'M3_GND_D3.2', f'MH{i}', top(*point), nets['GND']) slot_rectangles = [] if number == 2: place(parts['J23'], top(*CONFIG['usb_upstream_source_mm']), rotation=270, bottom=True) for n in range(1, 6): x, y, angle = CONFIG['phone_sites'][str(n)] w, h = CONFIG['phone_size_mm'] lx, ly = top(x, y+h) rounded_rect(board, lx, ly, w, h, .5, pcb.Dwgs_User) label(board, f'CH{n} PHONE - 1mm GAP', lx+w/2, ly+h/2, 1.2) for name, pos in CONFIG['phone_holes_mm'].items(): if name not in CONFIG['phone_holes_skipped']: mount(board, 'M1.6_NPTH_D1.8', f'P{n}{name}', site(n, *pos)) for index, pos in enumerate(CONFIG['phone_key_contacts_mm']): ref = f'J{41+(n-1)*4+index}' place(parts[ref], site(n, *pos), rotation=angle) # A separate label outside the contact cluster prevents ink on contacts. parts[ref].Reference().SetVisible(False) for prefix, key in [('J8','battery_connector_local_mm'), ('J7','tail_connector_local_mm')]: place(parts[f'{prefix}{n}'], site(n, *CONFIG[key]), rotation=angle, bottom=True) for name, rect in CONFIG['slots_local_mm'].items(): a, b = site(n, *rect[:2]), site(n, *rect[2:]) x0,y0,x1,y1 = min(a[0],b[0]),min(a[1],b[1]),max(a[0],b[0]),max(a[1],b[1]) rounded_rect(board, x0,y0,x1-x0,y1-y0,CONFIG['slot_corner_radius_mm']) margin = CONFIG['slot_clearance_mm'] keepout(board, f'CH{n}_{name}_CUTOUT', (x0-margin,y0-margin,x1+margin,y1+margin), [pcb.F_Cu,pcb.In1_Cu,pcb.In2_Cu,pcb.B_Cu], copper=True) slot_rectangles.append([x0,y0,x1,y1]) for name, rect in CONFIG['ribbon_lanes_local_mm'].items(): a,b = site(n,*rect[:2]),site(n,*rect[2:]) keepout(board, f'CH{n}_{name}_RIBBON', (min(a[0],b[0]),min(a[1],b[1]),max(a[0],b[0]),max(a[1],b[1])), [pcb.B_Cu]) label(board, f'BOARD {number} R1 - UNROUTED', origin[0]+110, origin[1]-5, 1.8) stage(board, parts, source, number) destination = OUT / f'PhoneRail-Board{number}.kicad_pcb' pcb.SaveBoard(str(destination), board) add_stackup(destination) reloaded = pcb.LoadBoard(str(destination)) report = verify_import(reloaded, source, number) report['internal_slots'] = len(slot_rectangles) return destination, reloaded, report def verify_import(board, source, number, allow_routes=False): parts = {f.GetReference(): f for f in board.GetFootprints()} electrical = {r: f for r, f in parts.items() if r in source.parts} assert set(electrical) == set(source.parts), 'Component import mismatch' checked = 0 names = native_net_names(source) for ref, fp in electrical.items(): expected = {pin: net for (r, pin), net in source.pins.items() if r == ref} pads = {} for pad in fp.Pads(): pads.setdefault(pad.GetNumber(), []).append(pad) assert set(expected) <= set(pads), (ref, 'Missing pads', set(expected)-set(pads)) for pin, net in expected.items(): assert all(p.GetNetname() == names[net] for p in pads[pin]), (ref, pin, net) checked += len(pads[pin]) for pad in fp.Pads(): if pad.GetAttribute() in {pcb.PAD_ATTRIB_PTH,pcb.PAD_ATTRIB_NPTH}: assert pad.GetLayerSet().Contains(pcb.F_Mask) and pad.GetLayerSet().Contains(pcb.B_Mask), (ref,'missing PTH mask openings') assert fp.GetPath().size() >= 2, (ref, 'Missing schematic UUID path') assert fp.GetFPIDAsString() == source.parts[ref]['Footprint'] if number == 2 and ref.startswith('J') and ref[1:].isdigit() and 41 <= int(ref[1:]) <= 60: assert all(not p.GetLayerSet().Contains(pcb.F_Paste) for p in fp.Pads()), ref assert fp.IsExcludedFromBOM() and fp.IsExcludedFromPosFiles(), ref assert board.GetCopperLayerCount() == 4 assert abs(pcb.ToMM(board.GetDesignSettings().GetBoardThickness())-1.6) < .001 if not allow_routes: assert len(list(board.GetTracks())) == 0 if number == 2: usb = electrical['J23'] assert all(p.GetPosition().x < usb.GetPosition().x for p in usb.Pads() if p.GetNumber() in {'A5','B5','A6','B6','A7','B7'}), 'USB mouth faces inward' for n in range(1,6): fp = electrical[f'J7{n}'] pads = {p.GetNumber():p for p in fp.Pads()} # In local phone coordinates, VBUS anchor is -u, even row is +v. sign = -1 if CONFIG['phone_sites'][str(n)][2] == 180 else 1 assert (pads['51'].GetPosition().x-fp.GetPosition().x)*sign < 0 assert (pads['2'].GetPosition().y-fp.GetPosition().y)*sign < 0 return {'electrical_footprints':len(electrical), 'mechanical_footprints':len(parts)-len(electrical), 'pad_net_assignments_checked':checked, 'net_count':len(source.net_members), 'anchored_electrical_footprints':sum(f.IsLocked() for f in electrical.values()), 'staged_electrical_footprints':sum(any(field.GetName() == 'Placement' and field.GetText() == 'STAGED_NOT_PLACED' for field in f.GetFields()) for f in electrical.values()), 'tracks':len(list(board.GetTracks())), 'copper_layers':4, 'thickness_mm':1.6} def mating_check(lower, upper): boards = [{f.GetReference():f for f in b.GetFootprints()} for b in (lower, upper)] count = 0 for lref, uref in [('J24','J31'),('J25','J32')]: lp = {p.GetNumber():p for p in boards[0][lref].Pads()} up = {p.GetNumber():p for p in boards[1][uref].Pads()} for pin in range(1,41): a, b = lp[str(pin)], up[str(pin+1 if pin % 2 else pin-1)] assert (a.GetPosition()-b.GetPosition()).EuclideanNorm() < pcb.FromMM(.001), (lref,pin,'misaligned') assert a.GetNetname() == b.GetNetname(), (lref,pin,'wrong net') count += 1 return count def main(): OUT.mkdir(exist_ok=True) manifest_path = OUT / 'pcb-start.json' old = json.loads(manifest_path.read_text(encoding='utf-8')) if manifest_path.exists() else {} for n in (1,2): name = f'PhoneRail-Board{n}' for suffix in ('.kicad_pcb', '.kicad_dru'): target = ROOT / name / (name+suffix) if target.exists() and digest(target) != old.get('generated_files', {}).get(str(target.relative_to(ROOT))): raise RuntimeError(f'Refusing to overwrite an edited or untracked file: {target}') mechanical_library() results, boards, artifacts = {}, [], [] for n in (1,2): source = Board(n) source.compile(r'D:\KiCad\bin\kicad-cli.exe', OUT) temporary, board, report = build(n, source) results[f'Board{n}'] = report boards.append(board) artifacts.append((temporary, ROOT/source.name/(source.name+'.kicad_pcb'))) results['mated_pins_checked'] = mating_check(*boards) results['generated_files'] = {} rules = '''(version 1) (rule "USB90 geometry target" (condition "A.NetClass == 'USB90'") (constraint diff_pair_gap (min 0.15) (opt 0.18) (max 0.21)) (constraint diff_pair_uncoupled (max 5))) ''' for temporary, target in artifacts: shutil.copyfile(temporary, target) n = 1 if 'Board1' in target.stem else 2 project_path, content = project_settings(n) project_path.write_text(content, encoding='utf-8') rule_path = target.with_suffix('.kicad_dru') rule_path.write_text(rules, encoding='utf-8') table_path = target.parent / 'fp-lib-table' table = table_path.read_text(encoding='utf-8') if 'PhoneRail-Mechanical' not in table: node = parse(table) entry = '(lib (name "PhoneRail-Mechanical") (type "KiCad") (uri "${KIPRJMOD}/../lib/PhoneRail-Mechanical.pretty") (options "") (descr "R1 mechanical anchors"))\n' table_path.write_text(table[:node.end-1]+entry+table[node.end-1:], encoding='utf-8') for path in (target, rule_path): results['generated_files'][str(path.relative_to(ROOT))] = digest(path) results['status'] = 'PLACEMENT_START_NOT_ROUTED_NOT_RELEASED' results['mechanical_config_sha256'] = digest(ROOT/'pcb-mechanical.json') manifest_path.write_text(json.dumps(results,indent=2)+'\n',encoding='utf-8') print(json.dumps(results, indent=2)) if __name__ == '__main__': main()