488 行
22 KiB
Python
488 行
22 KiB
Python
"""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()
|