"""Create R2 functional placement from schematic connectivity and mechanical anchors. Run with KiCad Python. The source snapshot is the unrouted native KiCad board; no coordinates, copper, or routes are read from the legacy layout. """ from collections import defaultdict import hashlib import json import math from pathlib import Path import shutil import pcbnew as pcb from bootstrap_pcb import ROOT, CONFIG, xy, keepout, label, pth_apertures from schematic_maintenance import parse, children, child OUT = ROOT / 'out/pcb-layout-r2' REVISION = 'R2-FUNCTIONAL-PLACEMENT' def digest(path): return hashlib.sha256(path.read_bytes()).hexdigest() def box_mm(item): b = item.GetBoundingBox(False, False) if isinstance(item, pcb.FOOTPRINT) else item.GetBoundingBox() return tuple(pcb.ToMM(v) for v in (b.GetLeft(), b.GetTop(), b.GetRight(), b.GetBottom())) def expand(box, d): a, b, c, e = box return a-d, b-d, c+d, e+d def intersects(a, b): return a[0] < b[2] and b[0] < a[2] and a[1] < b[3] and b[1] < a[3] def point(item): q = item.GetPosition() return pcb.ToMM(q.x), pcb.ToMM(q.y) class Layout: def __init__(self, number): self.number = number self.name = f'PhoneRail-Board{number}' self.path = ROOT / self.name / (self.name + '.kicad_pcb') text = (OUT / 'before' / self.path.name).read_text(encoding='utf-8') edits = [(node.start,node.end) for node in children(parse(text),'gr_text') if float(child(node,'at')[1]) > 270 or 'UNROUTED' in node[1]] for start,end in sorted(edits,reverse=True): text = text[:start]+text[end:] source = OUT / ('input-'+self.path.name) source.write_text(text,encoding='utf-8') self.board = pcb.LoadBoard(str(source)) assert not list(self.board.GetTracks()), 'This placement tool requires an unrouted source' self.parts = {f.GetReference(): f for f in self.board.GetFootprints()} self.done = {r for r, f in self.parts.items() if f.IsLocked()} self.obstacles = [] self.placements = {} for r in self.done: f = self.parts[r] both = any(p.GetAttribute() in (pcb.PAD_ATTRIB_PTH, pcb.PAD_ATTRIB_NPTH) for p in f.Pads()) self.obstacles.append((r, expand(box_mm(f), .35), -1 if both else f.GetLayer())) for z in self.board.Zones(): if z.GetIsRuleArea() and z.GetDoNotAllowFootprints(): self.obstacles.append((z.GetZoneName(), expand(box_mm(z), .25), -1 if z.GetLayerSet().Contains(pcb.F_Cu) else pcb.B_Cu)) def reserve(self, name, rect, layers, copper=False, footprints=True): keepout(self.board, name, rect, layers, copper) z = list(self.board.Zones())[-1] z.SetDoNotAllowFootprints(footprints) if footprints: self.obstacles.append((name, rect, -1 if len(layers) > 1 else layers[0])) def put(self, ref, target, rotation=0, radius=8, step=.5, bottom=None, reason='functional placement'): assert ref not in self.done, (ref, 'already placed') f = self.parts[ref] bottom = self.number == 2 if bottom is None else bottom if f.IsFlipped() != bottom: f.Flip(f.GetPosition(), pcb.FLIP_DIRECTION_TOP_BOTTOM) rotations = rotation if isinstance(rotation, tuple) else (rotation,) layer = pcb.B_Cu if bottom else pcb.F_Cu candidates = [(0, 0)] for k in range(1, int(radius / step)+1): candidates.extend((dx*step, dy*step) for dx in range(-k, k+1) for dy in (-k, k)) candidates.extend((dx*step, dy*step) for dy in range(-k+1, k) for dx in (-k, k)) candidates.sort(key=lambda q: q[0]**2+q[1]**2) winner = None for dx, dy in candidates: for rot in rotations: f.SetPosition(xy(target[0]+dx, target[1]+dy)) f.SetOrientationDegrees(rot) b = box_mm(f) if b[0] < 51 or b[1] < 51 or b[2] > (271 if ref == 'J22' else 269) or b[3] > 184: continue if any((ol == -1 or ol == layer) and intersects(b, ob) for _, ob, ol in self.obstacles): continue winner = (b, rot) break if winner: break if winner is None: raise RuntimeError(f'No legal location for {ref} near {target}') b, rot = winner f.SetLocked(False) f.SetField('Placement', REVISION) f.SetField('LayoutIntent', reason) for field in f.GetFields(): if field.GetName() not in {'Reference', 'Value'}: field.SetVisible(False) f.Value().SetVisible(False) f.Reference().SetLayer(pcb.B_SilkS if bottom else pcb.F_SilkS) f.Reference().SetMirrored(bottom) f.Reference().SetTextAngle(pcb.EDA_ANGLE(0, pcb.DEGREES_T)) f.Reference().SetTextSize(xy(.8, .8)) f.Reference().SetTextThickness(pcb.FromMM(.12)) f.Reference().SetPosition(xy((b[0]+b[2])/2, b[1]-.7)) self.done.add(ref) both = any(p.GetAttribute() == pcb.PAD_ATTRIB_PTH for p in f.Pads()) self.obstacles.append((ref, expand(b, .45), -1 if both else layer)) self.placements[ref] = {'xy_mm': point(f), 'rotation': rot, 'layer': self.board.GetLayerName(layer), 'target_displacement_mm': round(math.hypot(dx,dy),3), 'intent': reason} return f def near(self, ref, anchor, pin=None, offset=(0,0), **kwargs): f = self.parts[anchor] obj = next(p for p in f.Pads() if p.GetNumber() == str(pin)) if pin else f x, y = point(obj) return self.put(ref, (x+offset[0], y+offset[1]), rotation=(0,90,180,270), **kwargs) def finish(self): missing = set(self.parts) - self.done assert not missing, sorted(missing) self.board.GetTitleBlock().SetRevision(REVISION) self.board.GetTitleBlock().SetTitle(f'PhoneRail Board {self.number} - functional placement / unrouted') label(self.board, f'BOARD {self.number} R2 - PLACED / UNROUTED', 160, 46, 1.5) label(self.board, 'F.Cu: horizontal | B.Cu: vertical | local escapes require review', 160, 189, 1) place_silkscreen(self.board) candidate = OUT / self.path.name pcb.SaveBoard(str(candidate), self.board) candidate.write_text(pth_apertures(candidate.read_text(encoding='utf-8')), encoding='utf-8') return self.board def place_silkscreen(board, refs=None): # Fit references in actual free board space without suppressing DRC severities. occupied = defaultdict(list) for f in board.GetFootprints(): for p in f.Pads(): for copper, silk in ((pcb.F_Cu,pcb.F_SilkS),(pcb.B_Cu,pcb.B_SilkS)): if p.GetLayerSet().Contains(copper): occupied[silk].append(expand(box_mm(p), .2)) for shape in f.GraphicalItems(): if shape.GetLayer() in (pcb.F_SilkS, pcb.B_SilkS): occupied[shape.GetLayer()].append(expand(box_mm(shape), .12)) if refs is not None and f.GetReference() not in refs and f.Reference().IsVisible(): occupied[f.Reference().GetLayer()].append(expand(box_mm(f.Reference()), .12)) for f in sorted(board.GetFootprints(), key=lambda x:x.GetReference()): t = f.Reference() if not t.IsVisible() or (refs is not None and f.GetReference() not in refs): continue layer = t.GetLayer() b = box_mm(f) cx, cy = (b[0]+b[2])/2, (b[1]+b[3])/2 t.SetTextAngle(pcb.EDA_ANGLE(0,pcb.DEGREES_T)) found = False for d in (.8,1.3,1.8,2.5,3.5,5,7): positions = [(cx,b[1]-d),(cx,b[3]+d),(b[0]-d-1.5,cy),(b[2]+d+1.5,cy)] positions += [(cx+dx,b[1]-d) for dx in (-3,-2,-1,1,2,3)] positions += [(cx+dx,b[3]+d) for dx in (-3,-2,-1,1,2,3)] for x,y in positions: t.SetPosition(xy(x,y)) tb = box_mm(t) if tb[0]<51 or tb[2]>269 or tb[1]<51 or tb[3]>184: continue if not any(intersects(tb, q) for q in occupied[layer]): occupied[layer].append(expand(tb,.12)) found = True break if found: break if not found: t.SetLayer(pcb.B_Fab if f.IsFlipped() else pcb.F_Fab) t.SetPosition(xy(cx,cy)) f.SetField('ReferenceOnFabOnly','Dense area: refer to assembly drawing') for field in f.GetFields(): if field.GetName()=='ReferenceOnFabOnly': field.SetVisible(False) field.SetMirrored(f.IsFlipped()) def carrier(): s = Layout(2) # Bottom-side electronics stay clear of the lower board's outward-facing antenna. s.reserve('R2_ESP32_ANTENNA_PROJECTION', (232,175,258,184), [pcb.F_Cu,pcb.In1_Cu,pcb.In2_Cu,pcb.B_Cu], True) s.put('U32',(174,122),90,reason='Hub centered between five ports; upstream exit faces east') s.near('C749','U32',57,offset=(2,-2),radius=3,reason='Prioritize hub bypass beside upstream PHY supply pin') s.put('U31',(152,122),0,reason='Hub LDO between BTB supply and hub') s.put('U21',(203,130),0,reason='595 serial input adjacent to J32 control pins') s.put('U22',(184,135),0,reason='595 chain toward lower phone sites') s.put('U33',(164,137),0,reason='595 chain end and hub reset') s.put('U34',(239,154),0,reason='Carrier temperature sensor away from LDO and bulk capacitors') for ch in range(1,6): # Rotate this functional cell with the mechanical phone orientation. sign = 1 if ch <= 3 else -1 bat, tail = f'J8{ch}',f'J7{ch}' bx,by = point(s.parts[bat]) tx,ty = point(s.parts[tail]) base = 800+(ch-1)*10 s.put(f'C{base+1}',(bx+sign*12,by+sign*2),0, radius=12, reason='10.5mm bulk capacitor beside battery; lower-board projection reserved') for k, offset in [(2,(7,-5)),(3,(7,-2)),(4,(7,1)),(5,(7,4)),(6,(5,-6))]: s.put(f'C{base+k}',(bx+sign*offset[0],by+sign*offset[1]),(0,90,180,270),radius=10, reason='Battery ceramic bank outside ribbon lane') for ref,pin in [(f'R{780+ch}',3),(f'R{790+ch}',4)]: s.near(ref,bat,pin,reason='Battery identification resistor at connector') s.put(f'U{50+ch}',(tx+sign*3,ty-sign*5),0,radius=8, reason='USB ESD flow-through device adjacent to tail connector') s.put(f'U{23+ch}',(tx+sign*10,ty-sign*7),0,radius=10, reason='Per-port VBUS switch near tail; bulk current separated from USB pair') for prefix,pin in [('C70',5),('C71',1),('C72',4),('R70',6)]: ref = f'{prefix}{ch}' if ref in s.parts: s.near(ref,f'U{23+ch}',pin,reason='VBUS switch local decoupling/timing/default state') for ref,pin in [(f'R{760+ch}',22),(f'R{770+ch}',14)]: s.near(ref,tail,pin,reason='Tail accessory termination') # Keep EDL service connector reachable from the side of each site. s.put(f'J6{ch}',(bx-sign*10,by-sign*9),0,radius=10,reason='EDL service connector beside battery site') s.near(f'U4{ch}',f'J6{ch}',offset=(0,-sign*6),radius=10,reason='EDL analog switch at service connector') s.near(f'C{760+ch}',f'U4{ch}',5,reason='EDL switch supply bypass') s.near(f'R{740+ch}',f'U4{ch}',6,reason='EDL enable defaults low') for k in range(3): q = f'Q{ch}{k+3:02}' contact = f'J{41+(ch-1)*4+k}' s.near(q,contact,offset=(-sign*3,sign*7),radius=14,reason='Key MOS close to its phone contact') for suffix in (20+2*k,21+2*k): s.near(f'R{ch}{suffix}',q,1,reason='Key gate resistor/pulldown at MOS') for ref,anchor,pin in [('C736','U31',3),('C737','U31',2),('C760','U34',8), ('C604','U21',16),('C605','U22',16),('C739','U33',16),('R609','U21',13)]: s.near(ref,anchor,pin,reason='Supply bypass / control bias at IC pin') s.near('Y1','U32',61,offset=(0,-4),radius=8,reason='24MHz crystal immediately at hub oscillator pins') for ref,pin in [('C740',1),('C741',3)]: s.near(ref,'Y1',pin,reason='Crystal load capacitor; isolated ground return') hub_pins = {'C742':25,'C743':62,'C744':5,'C745':10,'C746':24,'C747':46,'C748':52, 'C750':64,'C751':64,'R708':63,'R709':44,'R710':44,'R711':41, 'R712':42,'R714':54,'R715':56,'R716':43,'R719':40,'R720':45} for ref,pin in hub_pins.items(): s.near(ref,'U32',pin,radius=12,reason='Hub pin-local bypass or configuration resistor') s.near('R717','J23','A5',offset=(-5,0),reason='USB-C CC termination') s.near('R718','J23','B5',offset=(-5,0),reason='USB-C CC termination') s.near('R721','J23','A4B9',offset=(-8,0),reason='Upstream VBUS detection divider') s.near('R722','R721',2,reason='Upstream VBUS detection divider return') return s def power_cell(s,ch,origin): x,y = origin buck, efuse, ina = f'U{ch}',f'U{ch+13}',f'U{ch+7}' s.put(buck,(x,y),0,radius=0,reason=f'CH{ch} buck; VIN west, SW east, feedback south') s.put(f'L{ch}01',(x+7,y),0,radius=1,reason='Inductor directly at SW; horizontal hot path') s.put(f'C{ch}06',(x+14,y-2),90,radius=2,reason='First buck output capacitor immediately after inductor') s.put(f'C{ch}07',(x+17,y-2),90,radius=2,reason='Parallel buck output capacitor') s.put(efuse,(x+20,y+5),90,radius=3,reason='Output protection between buck capacitor and shunt') s.put(f'Rs{ch}01',(x+26,y+9),180,radius=3,reason='4A output shunt; Kelvin pickup on inner pad edges') s.put(ina,(x+26,y+15),0,radius=3,reason='INA inputs face shunt; digital bus exits away from power path') for k,dy in [(1,-2),(2,1),(3,4),(4,-4)]: s.put(f'C{ch}{k:02}',(x-5,y+dy),(0,180,90,270),radius=5,reason='Local VIN ceramic hot-loop bank') s.near(f'C{ch}05',buck,13,offset=(2,-2),radius=5,reason='Bootstrap capacitor directly between BOOT and SW') s.near(f'C{ch}08',efuse,11,reason='eFuse input bypass') for ref,pin in [(f'R{ch}01',7),(f'R{ch}02',7),(f'R{ch}03',1),(f'R{ch}04',10), (f'R{ch}05',10),(f'R{ch}06',8),(f'C{ch}09',9),(f'C{ch}10',8), (f'C{ch}11',8),(f'R{ch}07',7)]: s.near(ref,buck,pin,radius=9,reason='Buck quiet feedback/compensation/soft-start component') s.near(f'C{ch}12',f'R{ch}07',2,reason='DAC injection low-pass at feedback branch') s.near(f'R{ch}08',f'C{ch}12',1,reason='DAC injection series resistor') for k,pin in [(9,15),(10,15),(11,17),(12,19),(13,20),(14,14),(15,2),(16,3),(17,3)]: s.near(f'R{ch}{k:02}',efuse,pin,radius=10,reason='eFuse OVP/ILIM/PG threshold or default bias') s.near(f'C{ch}14',efuse,18,reason='eFuse ramp timing capacitor') s.near(f'C{ch}13',ina,6,reason='INA226 local supply bypass') s.near(f'Q{ch}01',efuse,14,offset=(-4,4),reason='Discharge interlock local to eFuse enable') s.near(f'Q{ch}02',f'Q{ch}01',3,offset=(0,5),reason='Discharge MOS isolated from quiet feedback') s.near(f'R{ch}18',f'Q{ch}01',3,reason='Discharge gate bias') s.near(f'R{ch}19',f'Q{ch}02',3,offset=(5,4),radius=10,reason='Pulse discharge resistor with open thermal surroundings') def lower(upper): s = Layout(1) for ch in range(1,6): ref = f'C{801+(ch-1)*10}' s.reserve(f'R2_HEIGHT_{ref}_10.5mm',expand(box_mm(upper.parts[ref]),1),[pcb.F_Cu]) s.reserve('R2_RF_COPPER_VOID',(232,175,258,184),[pcb.F_Cu,pcb.In1_Cu,pcb.In2_Cu,pcb.B_Cu],True,False) s.obstacles.append(('RF_NEIGHBOUR_CLEARANCE',(232,175,258,184),pcb.F_Cu)) # The module itself extends into the copper-only antenna area. rf = s.obstacles.pop() s.put('U20',(247,165),180,radius=0,reason='MCU at board edge; antenna toward free south-east corner') s.obstacles.append(rf) s.put('J22',(264.866,151),90,radius=0,reason='USB debug connector opens at right edge') s.put('J1',(63,67),90,radius=0,reason='12V input at left edge; fuse and TVS directly inboard') s.put('J26',(59,148),270,radius=0,reason='Intake fan cable entry at left edge') s.put('J27',(260,99),90,radius=0,reason='Exhaust fan cable entry at right edge') # Three upper and two lower cells follow connector destinations, not old coordinates. for ch,origin in [(1,(81,91)),(2,(137,86)),(3,(213,89)),(4,(102,141)),(5,(179,141))]: power_cell(s,ch,origin) for ref,position,rot in [('F1',(74,65),0),('Q1',(82,65),90),('D1',(87,57),0), ('C2',(98,59),0),('C3',(108,59),0),('Rs6',(118,62),0), ('U13',(119,69),0),('U19',(142,62),0),('L2',(149,62),0), ('U30',(169,62),0),('L3',(177,62),0),('Q3',(189,58),90), ('U29',(190,67),0),('U23',(150,157),0),('U6',(135,170),0), ('U7',(209,169),0),('Q7',(74,153),0),('Q8',(79,160),0), ('Q6',(85,159),0),('Q9',(72,166),0),('Q10',(251,111),0)]: s.put(ref,position,rot,radius=6,reason='Functional input / auxiliary supply / control / fan zone') for i,ref in enumerate(('R9A','R9B','R9C','R9D','R9E')): s.put(ref,(204+i*10,58),0,radius=2,reason='Series bus discharge resistors spaced along ventilated north edge') assignments = { 'C1':('Q1',4),'DZ1':('Q1',4),'R1':('Q1',4),'C4':('Q1',5),'C5':('Rs6',1),'C6':('Rs6',1), 'C7':('U19',3),'C8':('U19',6),'C9':('L2',2),'C10':('L2',2),'C11':('U13',6), 'R2':('U19',4),'R3':('U19',4),'R4':('U29',1),'R5':('U29',1),'R6':('Q3',4), 'C731':('U30',3),'C732':('U30',3),'C733':('U30',6),'C734':('L3',2),'C735':('L3',2), 'R706':('U30',4),'R707':('U30',4), 'C601':('U20',2),'C602':('U20',2),'C603':('U20',3),'R601':('U20',3), 'R602':('J22','A5'),'R603':('J22','B5'),'R604':('U20',22),'R605':('U20',8), 'R606':('U23',9),'R607':('U23',10),'R608':('U23',1), 'C606':('U23',24),'C607':('U6',15),'C608':('U6',13),'C609':('U6',16), 'C610':('U7',15),'C611':('U7',13),'C612':('U7',16),'R614':('U6',16), 'R615':('U6',5),'R616':('U7',16),'R617':('U7',5), 'C738':('Q7',3),'D2':('Q7',3),'DZ3':('Q7',1),'R740':('Q7',1),'R741':('Q8',3), 'R742':('Q8',1),'R743':('Q8',1),'R733':('Q6',1),'R734':('Q6',1), 'R744':('J26',3),'R745':('Q9',1),'R746':('Q9',1),'R747':('Q9',3), 'R748':('J27',3),'R749':('Q10',1),'R750':('Q10',1),'R751':('Q10',3)} for ref,(anchor,pin) in assignments.items(): s.near(ref,anchor,pin,radius=12,reason='Pin-local support circuitry') s.put('RT601',(92,102),0,radius=8,reason='Thermistor near CH1 hot area; avoid direct copper heat spreading') s.put('RT602',(220,99),0,radius=8,reason='Thermistor near CH3 hot area; avoid direct copper heat spreading') for ref,pin in [('R618',39),('C613',39),('R619',38),('C614',38)]: s.near(ref,'U20',pin,radius=10,reason='ADC thermistor filter at MCU input') return s def main(): OUT.mkdir(parents=True,exist_ok=True) before = OUT / 'before' manifest = OUT / 'placement.json' previous = json.loads(manifest.read_text(encoding='utf-8')) if manifest.exists() else {} for n in (1,2): name = f'PhoneRail-Board{n}' for ext in ('.kicad_pcb','.kicad_pro','.kicad_dru'): path = ROOT/name/(name+ext) expected = previous.get('generated_files',{}).get(str(path.relative_to(ROOT))) if expected and digest(path) != expected: raise RuntimeError(f'Refusing to overwrite subsequent edits: {path}') before.mkdir(exist_ok=True) backup = before/path.name if not backup.exists(): shutil.copy2(path,backup) upper = carrier() lower_board = lower(upper) reports = {} for s in (lower_board,upper): s.finish() reports[s.name] = {'moved':len(s.placements),'placements':s.placements} print(s.name,'placed',len(s.placements),flush=True) for s in (lower_board,upper): shutil.copy2(OUT/s.path.name,s.path) reports['status'] = REVISION + '_UNROUTED_NOT_RELEASED' reports['generated_files'] = {str(s.path.relative_to(ROOT)):digest(s.path) for s in (lower_board,upper)} manifest.write_text(json.dumps(reports,indent=2)+'\n',encoding='utf-8') if __name__ == '__main__': main()