app
AI Flow
Public Made by Adomby adom
Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board.
main
John Lauer
Publish 0.2.23
2ca63dc
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721
"""adom-aiflow altium: the Altium side of a project, done the same way every time (run after project-common).
library one project library in Altium (a .SchLib and a .PcbLib): every part from its wiki component
page (or, with no public page yet, from the board's project library: `parts --project-lib`), its
footprint and symbol converted from the KiCad files, its STEP model bound,
the Mouser, DigiKey and LCSC numbers looked up and checked; later parts join the SAME library
reference placement and spec coordinates from a reference KiCad board, in the Altium board's frame
(the board's lower-left corner at 0,0, y up)
schematic the design's schematic drawn in Altium from the netlist (one column per block, every pin
labelled with its net) inside a project, compiled and checked pin by pin against the
netlist, its symbols and pin types checked as drawn; then the board, as `board` builds it, in the same project
symbols the schematic's symbols checked again on the drawn sheet (pins on their bodies, pin types on
active parts, polarity drawings), as `schematic` does after it compiles; recorded in its proof
board the design's board built in Altium from the netlist: the outline, the fixed parts where the
spec puts them, every other part parked below the board for placement, every pad on its net;
saved, read back pin by pin, and the read written for `adom-aiflow start`
silkscreen the board's silkscreen planned (the planner the lanes share) and landed in Altium: part names
beside their parts, machine contacts framed with their nets, leaders, the identity on the
bottom; then checked on Altium's own measure (clear of copper, vias, the edge, each other)
export the manufacturing set from the project's OutJob (Gerbers, NC drill, pick and place, BOM), pulled
and checked against the board (layers, drill hits, every placed part), plus the JLCPCB BOM/CPL
reset clear the live board's copper (tracks, vias, pours) for a refine pass; the parts stay
view3d the board in Altium's 3D view and a slow orbit for the recording
Every step prints OK or ERROR with Hint lines and keeps its state in <run>/altium/.
Called by `adom-aiflow altium <step> ...`; AIFLOW_RUN, AIFLOW_TARGET and AIFLOW_THREAD come from it.
"""
init('altium')
def need(tool, pkg):
if not shutil.which(tool): err('%s is not installed' % tool, ['adom-wiki pkg install %s, then run this again.' % pkg])
def lib_paths(st):
d = box_dir() + '/library'
name = st.get('library') or 'Project'
return d, d + '/' + name + '.SchLib', d + '/' + name + '.PcbLib'
def fp_name(s):
return re.sub(r'[^A-Za-z0-9_.-]', '-', s).upper()[:60]
# ---------------------------------------------------------------- library
def run_data():
"""The run's state (run.json), or {}."""
try:
d = json.loads((RUN / 'run.json').read_text())
return d.get('data') or d
except Exception:
return {}
def run_spec():
"""The run's spec (its schematic overrides: allowPassive, polarityReviewed), or {}."""
try:
return json.loads(Path(run_data().get('spec') or '').read_text())
except Exception:
return {}
def part_source(key, w):
"""One part's KiCad files and facts, from its wiki component page or from the board's project library
(`parts --project-lib`): {sym, outline, fp, step, desc, mfr, package, links, origin, source}."""
pdir = STATE / 'pages' / key
if w['origin'] == 'project-lib':
pl = w['projectLib']
files, problems = stage_project_part(key, pl, pdir)
if problems: err('%s: the project library part (%s) is not complete: %s' % (w['mpn'], pl.get('dir'), '; '.join(problems)),
['Fix the project library (symbol, footprint, STEP model and its provenance.json entry), run `adom-aiflow parts --project-lib %s` again, then `altium library --only %s`.' % (pl.get('dir') or '<dir>', w['mpn'])])
(pdir / 'provenance.json').write_text(json.dumps(pl, indent=1))
return dict(files, origin='project-lib', **project_part_facts(pl, w.get('row')))
page = wiki_page(w['page'], pdir)
if not page: err('%s: no wiki component page at %s' % (w['mpn'], w['page']), ['Fetch the part first (chip-fetcher publishes a component page), or give its page slug in the netlist: "page": "<owner>/<slug>".',
'A part whose page cannot be public yet comes from the board\'s project library: `adom-aiflow parts --project-lib <dir>` (symbol, footprint, STEP model, provenance.json), then `altium library` again.'])
comp = page.get('component', {})
sym, outline, fp, step = pick_files(pdir, comp)
if not fp: err('%s: the page has no .kicad_mod footprint' % w['mpn'])
return {'sym': sym, 'outline': outline, 'fp': fp, 'step': step, 'desc': (page.get('brief') or comp.get('package') or '')[:120].replace('\n', ' '),
'mfr': comp.get('manufacturer') or '', 'package': comp.get('package') or '', 'links': comp.get('distributor_links'), 'origin': 'wiki', 'source': w['page']}
def convert_part(key, label, src, stage):
"""The import specs of one part from its KiCad files, by the same converters whatever their source:
altium-kicad2altium (footprint), altium-symbol-modes (symbol; a chip's outline symbol when the page has
one and the footprint more than two pads), the STEP bound as it is. Returns (files, symbol spec or None)."""
spec_fp = stage / (key + '.fp.spec'); spec_sym = stage / (key + '.sym.spec'); files = []
r = run(['altium-kicad2altium', str(src['fp']), str(spec_fp), key, src['desc']])
if r.returncode != 0 or not spec_fp.is_file(): err('%s: footprint conversion failed: %s' % (label, clip(r.stdout + r.stderr, 300)))
files.append(spec_fp)
outline, sym = src.get('outline'), src.get('sym')
use = outline if (outline and len(set(re.findall(r'\(pad "([^"]+)"', Path(src['fp']).read_text(errors='ignore')))) > 2) else sym
if use:
r = run(['altium-symbol-modes', str(use), str(spec_sym), key, src['desc']])
if r.returncode != 0 or not spec_sym.is_file(): err('%s: symbol conversion failed: %s' % (label, clip(r.stdout + r.stderr, 300)))
# PIN|mode|number|name|x|y|orient|len|hide|etype: a spec without the pin type comes from a tool older
# than altium-bridge 0.2.53, and every pin would land in Altium as Passive (a 2026-10-20 run did)
if any(len(l.split('|')) < 10 for l in spec_sym.read_text(errors='ignore').splitlines() if l.startswith('PIN|')):
err('%s: altium-symbol-modes is older than altium-bridge 0.2.53: its symbol spec carries no pin types, so every pin would go into Altium as Passive' % label,
['Update it: adom-wiki pkg install adom/altium-bridge, then run this again.'])
files.append(spec_sym)
if src.get('step'): files.append(src['step'])
return files, (spec_sym if spec_sym in files else None)
def cmd_library(a):
for tool, pkg in (('altium-kicad2altium', 'adom/altium-bridge'), ('altium-symbol-modes', 'adom/altium-bridge'), ('adom-wiki', 'adom/adom-wiki')):
need(tool, pkg)
st = load_state()
name = st.get('library') or a.name or 'Project'
if not re.fullmatch(r'[A-Za-z0-9_-]{1,40}', name): err('library name: letters, digits, _ and - only')
st['library'] = name
src = json.loads(Path(a.parts).read_text())
comps = src.get('components') if isinstance(src, dict) else src
if not comps: err('no parts: give --parts <netlist.json> (components[] with ref, mpn, fp) or parts.json [{mpn, page?, footprint?}]')
cache = json.loads(Path(a.numbers).read_text()) if a.numbers else {}
refs = st.setdefault('refs', {})
stage = STATE / 'stage'; shutil.rmtree(stage, ignore_errors=True); stage.mkdir(parents=True)
kept_specs = STATE / 'symbols'; kept_specs.mkdir(parents=True, exist_ok=True)
bdir, sch_lib, pcb_lib = lib_paths(st)
added, kept, unverified, from_lib = [], [], {}, []
# the `parts` step's choice per MPN: the wiki page it matched, or the board's project library
rows = parts_rows(run_data())
work = {}
for c in comps:
mpn = c.get('mpn')
if mpn:
key = fp_name(mpn)
row = rows.get(mpn_key(mpn)) or {}
origin, where = part_origin(c, row)
w = work.setdefault(key, {'mpn': mpn, 'origin': origin, 'page': where if origin == 'wiki' else None,
'projectLib': where if origin == 'project-lib' else None, 'row': row, 'refs': []})
w['refs'].append(c.get('ref'))
else:
fpn = (c.get('fp') or '').split(':')[-1]
hit = next(iter(sorted(Path(a.footprints).rglob(fpn + '.kicad_mod'))), None) if a.footprints and fpn else None
if not hit: err('%s has no MPN and its footprint %s was not found' % (c.get('ref'), fpn or '?'), ['Give --footprints <folder with %s.kicad_mod>, or an MPN in the netlist.' % fpn])
key = fp_name(fpn)
work.setdefault(key, {'footprint': str(hit), 'origin': 'board-only', 'refs': []})['refs'].append(c.get('ref'))
refs[c.get('ref')] = key
only = {fp_name(m) for m in (a.only or [])}
if only - set(work): err('--only names part(s) not in --parts: %s' % ', '.join(sorted(only - set(work))))
for key, w in work.items():
if w['origin'] == 'project-lib': from_lib.append(w['mpn'])
if only and key not in only:
kept.append(key); continue
if st['parts'].get(key, {}).get('imported') and not a.again and not only:
kept.append(key); continue
params = {}
if w['origin'] == 'board-only':
files, spec_sym = convert_part(key, key, {'fp': w['footprint'], 'desc': 'board-only pad'}, stage)
step = None
else:
src = part_source(key, w)
files, spec_sym = convert_part(key, w['mpn'], src, stage)
step = src.get('step')
numbers, notes = lookup_numbers(w['mpn'], src.get('links'), cache)
mfr = src.get('mfr') or ''
params = {'MPN': w['mpn'], 'Manufacturer': mfr, 'Package': src.get('package') or ''}
if numbers.get('mouser'): params['Mouser Part #'] = numbers['mouser']
if numbers.get('digikey'): params['DigiKey Part #'] = numbers['digikey']
if numbers.get('lcsc'): params['LCSC Part #'] = numbers['lcsc']
missing = [k for k in ('mouser', 'digikey', 'lcsc') if not numbers.get(k)]
if missing and mfr.strip().lower() != 'adom': unverified[w['mpn']] = missing + (['(' + '; '.join(sorted(set(notes))) + ')'] if notes else [])
spec_fp = files[0]
send(files, bdir + '/stage')
imp = {'pcbLib': win(pcb_lib), 'footprintSpec': win(bdir + '/stage/' + spec_fp.name)}
if spec_sym:
imp['schLib'] = win(sch_lib); imp['symbolSpec'] = win(bdir + '/stage/' + spec_sym.name)
if step: imp['step'] = win(bdir + '/stage/' + Path(step).name)
if params: imp['params'] = params
r = ab('altium_import_part', imp, timeout=900)
if not r.get('success'): err('%s: altium_import_part failed: %s' % (key, str(r.get('error') or r.get('errorCode'))[:300]), [r.get('_hint')])
# the symbol spec as imported, kept for the schematic's symbol check (every display mode's pins on its body)
if spec_sym: shutil.copy(spec_sym, kept_specs / spec_sym.name)
st['parts'][key] = {'imported': True, 'mpn': w.get('mpn'), 'refs': w['refs'], 'symbol': bool(spec_sym), 'step': bool(step), 'params': params, 'source': w['origin']}
if w['origin'] == 'project-lib': st['parts'][key]['projectLib'] = {'dir': w['projectLib'].get('dir'), 'source': w['projectLib'].get('source')}
added.append(key)
say('part %s: footprint%s%s, refs %s%s' % (key, ', symbol' if spec_sym else '', ', 3D' if step else '', ' '.join(w['refs']), ' (from the project library)' if w['origin'] == 'project-lib' else ''))
save_state(st)
st['schLib'], st['pcbLib'] = sch_lib, pcb_lib
save_state(st)
prove('library', {'name': name, 'parts': len(st['parts']), 'tool': 'altium', 'projectLibParts': from_lib})
ok('library %s: %d part(s) (%d added, %d kept%s): %s and %s' % (name, len(st['parts']), len(added), len(kept),
'; %d from the board\'s project library: %s' % (len(from_lib), ', '.join(from_lib)) if from_lib else '', pcb_lib, sch_lib),
['No distributor number yet: ' + '; '.join('%s %s' % (m, ' '.join(v)) for m, v in unverified.items()) + '. Run `altium library` again when the services are back, or add the numbers to the part\'s wiki page (a project-library part: "mouser"/"digikey"/"lcsc" in its provenance.json entry).' if unverified else '',
'Next: adom-aiflow altium reference --kicad <reference .kicad_pcb> --spec <spec.json> (if there is a reference board), then adom-aiflow altium schematic --netlist <netlist.json> --name <Design> (the schematic, then the board in the same project).'])
# ---------------------------------------------------------------- reference
def cmd_reference(a):
moves, fixed, size, moved = reference(a.kicad, a.spec, a.out_dir, 'Altium')
ok('reference %s: %d part placements, %d fixed parts, outline %.2f x %.2f mm, %d spec coordinate(s) moved into the board frame' % (Path(a.kicad).name, len(moves), len(fixed), size[0], size[1], moved),
['Wrote %s/moves.json (place check/land), %s/fixed.json (altium board) and %s/spec.json (use it for start).' % (a.out_dir, a.out_dir, a.out_dir)])
# ---------------------------------------------------------------- board
def fab_numbers(dru):
"""A KiCad rule pack's floors as altium_set_design_rules takes them (the Rust eda::fab_numbers)."""
floor = {}
for part in dru.split('(constraint ')[1:]:
kind = re.match(r'[^\s)]+', part).group(0)
m = re.search(r'\(min ([^)]+)\)', part)
if not m: continue
v = m.group(1).strip()
try: mm = float(v[:-3]) * 0.0254 if v.endswith('mil') else float(v.replace('mm', ''))
except ValueError: continue
kind = {'hole': 'hole_size'}.get(kind, kind) # a pack may write either name
floor[kind] = min(mm, floor.get(kind, mm))
o = {}
if 'clearance' in floor: o['clearance'] = floor['clearance']
if 'track_width' in floor: o['trackWidth'] = {'min': floor['track_width']}
if 'hole_size' in floor: o['hole'] = {'min': floor['hole_size']}
if 'via_diameter' in floor or 'hole_size' in floor: o['via'] = {'diameter': {'min': floor.get('via_diameter', 0.45)}, 'drill': {'min': floor.get('hole_size', 0.2)}}
if 'annular_width' in floor: o['annularRing'] = floor['annular_width']
# the floor AI Flow plans holes to when a pack names none (aiflow_pours::hole_to_hole): the tool's
# rule must agree, or Altium's own 0.254 mm default flags vias the plan placed 0.2 mm apart
o['holeToHole'] = floor.get('hole_to_hole', 0.2)
if 'edge_clearance' in floor: o['edgeClearance'] = floor['edge_clearance']
return o
def run_rules():
"""The fab rule pack `rules use` chose for this run (its text), or None."""
try:
d = json.loads((RUN / 'run.json').read_text()); fr = (d.get('data') or d).get('fabRules') or {}
f = RUN / 'rules' / fr.get('file', '')
return f.read_text() if fr.get('file') and f.is_file() else None
except Exception:
return None
def build_board(a):
st = load_state()
if not st.get('pcbLib'): err('no project library yet', ['Run `adom-aiflow altium library --parts <netlist.json>` first.'])
net = json.loads(Path(a.netlist).read_text())
fixed = json.loads(Path(a.fixed).read_text()) if a.fixed else (json.loads((STATE / 'fixed.json').read_text()) if (STATE / 'fixed.json').is_file() else {})
w, h = (a.outline or fixed.get('outlineMm') or [None, None])
if not w or not h: err('no board outline size', ['Pass --outline W H (mm), or run `altium reference` first (fixed.json carries it).'])
refs = st.get('refs', {})
parts, parked = [], 0
pos = fixed.get('fixed', {})
for c in net.get('components', []):
r = c['ref']
if r not in refs: err('%s is not in the project library' % r, ['Run `adom-aiflow altium library --parts %s` again: it adds the parts it is missing.' % a.netlist])
if r in pos:
p = pos[r]; parts.append({'ref': r, 'footprint': refs[r], 'x': p['x'], 'y': p['y'], 'rotation': p.get('rot', 0), 'comment': c.get('value') or ''})
else:
# parked in rows below the board, for the placement step to land
col, row = parked % 10, parked // 10
parts.append({'ref': r, 'footprint': refs[r], 'x': 2.0 + col * (w / 10.0), 'y': -6.0 - row * 6.0, 'rotation': 0, 'comment': c.get('value') or ''})
parked += 1
pcb = win(box_dir() + '/' + a.name + '.PcbDoc')
r = ab('altium_build_board', {'pcbPath': pcb, 'libraryPath': win(st['pcbLib']), 'outline': {'rect': [0, 0, w, h], 'cornerRadius': 0},
'parts': parts, 'nets': net.get('nets', {})}, timeout=1800)
if not r.get('success'): err('the board was not built cleanly: %s' % (r.get('error') or r.get('errorCode')), [r.get('_hint'), 'wrongPins: %s' % json.dumps(r.get('wrongPins'))[:400] if r.get('wrongPins') else '', 'missingParts: %s' % r.get('missingParts') if r.get('missingParts') else ''])
st['board'] = pcb; save_state(st)
# the fab's rules, chosen at the rules step (before there was a board), into this board
dru = run_rules()
if dru:
nums = fab_numbers(dru)
if nums:
rr = ab('altium_set_design_rules', dict(nums, expectPath=pcb), timeout=600)
if not rr.get('success'): err('the fab rules did not load into the new board: %s' % (rr.get('error') or rr.get('errorCode') or rr.get('failed')), [rr.get('_hint')])
say('fab rules loaded into the board: %s' % ', '.join('%s %s' % (k, v) for k, v in nums.items()))
# park by real size: the build parked on a coarse grid before any footprint's extent was known, and
# pads that overlap read as connected (the ratsnest undercounts). Left to right, 1.5 mm apart, rows
# below the board, each part's pads clear of the next.
rs = ab('altium_routing_state', {'expectPath': pcb}, timeout=300)
fixed_refs = set(pos)
boxes = {}
for p in rs.get('pads', []):
if p['ref'] in fixed_refs or not p['ref']: continue
hx, hy = max(p['sizeX'], p['sizeY']) / 2, max(p['sizeX'], p['sizeY']) / 2
b = boxes.setdefault(p['ref'], [1e9, 1e9, -1e9, -1e9])
b[0] = min(b[0], p['x'] - hx); b[1] = min(b[1], p['y'] - hy); b[2] = max(b[2], p['x'] + hx); b[3] = max(b[3], p['y'] + hy)
comps = {c['ref']: c for c in rs.get('components', [])}
moves, x, y, row_h, gap = [], 0.0, -3.0, 0.0, 1.5
for ref in [c['ref'] for c in net.get('components', []) if c['ref'] not in fixed_refs]:
b = boxes.get(ref); c = comps.get(ref)
if not b or not c: continue
bw, bh = b[2] - b[0], b[3] - b[1]
if x > 0 and x + bw > w * 1.6: x = 0.0; y -= row_h + gap; row_h = 0.0
# where the origin goes so the pads' box starts at (x, y - bh)
moves.append({'ref': ref, 'x': round(x + (c['x'] - b[0]), 4), 'y': round(y - bh + (c['y'] - b[1]), 4), 'rotation': c['rotation']})
x += bw + gap; row_h = max(row_h, bh)
if moves:
m = ab('altium_move_parts', {'expectPath': pcb, 'expectedRevision': rs.get('revision'), 'parts': moves}, timeout=900)
if not m.get('success'): err('re-parking the parts failed: %s' % (m.get('error') or m.get('errorCode')), [m.get('_hint')])
rs = ab('altium_routing_state', {'expectPath': pcb}, timeout=300)
# the board read, for `adom-aiflow start --board <this> --remote-board altium:<pcb>`
ps = ab('altium_pour_state', {}, timeout=300)
out = STATE / (a.name + '.altium.json')
# projectDir: the renderer takes each part's courtyard and fab outline from its source KiCad footprint (pages/)
out.write_text(json.dumps({'source': 'altium-bridge', 'state': rs, 'pours': ps if ps.get('success') else None, 'projectDir': str(STATE.resolve())}))
rules = Path(a.fixed).with_name('board.kicad_dru') if a.fixed else STATE / 'board.kicad_dru'
# the fab's rules beside the board read, where `start` looks for them (<board file>.kicad_dru)
if rules.is_file(): shutil.copy(rules, Path(str(out)[:-len('.json')] + '.kicad_dru'))
prove('board', {'pcb': pcb, 'parts': len(parts), 'parked': parked, 'unconnected': r.get('unconnected')})
return {'pcb': pcb, 'out': out, 'parts': len(parts), 'parked': parked, 'unconnected': rs.get('unconnected')}
def cmd_board(a):
b = build_board(a)
ok('board %s: %d part(s) (%d fixed, %d parked for placement), every pin on its net, %s ratsnest line(s) to route' % (b['pcb'], b['parts'], b['parts'] - b['parked'], b['parked'], b['unconnected']),
['Start the run on it: adom-aiflow start --board %s --spec <spec.json> --engine <you> --target %s --remote-board altium:%s' % (b['out'], TARGET, b['pcb']),
'Then rules use, place check / land (moves.json from `altium reference`), route, gate, land route, pour, land pours, measure, analyze.'])
# ---------------------------------------------------------------- schematic
def cmd_schematic(a):
st = load_state()
if not st.get('schLib'): err('no project library yet', ['Run `adom-aiflow altium library --parts <netlist.json>` first.'])
net = json.loads(Path(a.netlist).read_text())
refs = st.get('refs', {})
parts, board_only = [], []
for c in net.get('components', []):
key = refs.get(c['ref'])
if not key: err('%s is not in the project library' % c['ref'], ['Run `adom-aiflow altium library --parts %s` again: it adds the parts it is missing.' % a.netlist])
if not st['parts'].get(key, {}).get('symbol'):
board_only.append(c['ref']); continue
parts.append({'ref': c['ref'], 'symbol': key, 'footprint': key, 'block': c.get('block') or '', 'comment': c.get('value') or ''})
sch = win(box_dir() + '/' + a.name + '.SchDoc'); pcb = win(box_dir() + '/' + a.name + '.PcbDoc')
say('drawing %d part(s) in %d block(s) on %s' % (len(parts), len({p['block'] for p in parts}), sch))
r = ab('altium_build_schematic', {'schPath': sch, 'libraryPath': win(st['schLib']), 'pcbLibPath': win(st['pcbLib']), 'pcbPath': pcb,
'parts': parts, 'nets': net.get('nets', {}), 'title': net.get('title') or a.name,
'revision': a.revision, 'drawnBy': 'Adom AI Flow'}, timeout=1800)
if not r.get('success'):
err('the schematic does not compile to the netlist: %s' % (r.get('error') or r.get('errorCode') or json.dumps(r.get('mismatches'))[:400]),
[r.get('_hint'), 'compile errors: %s' % json.dumps(r.get('compileErrors'))[:400] if r.get('compileErrors') else ''])
st['schematic'] = sch; st['project'] = r.get('project'); save_state(st)
b = build_board(a)
# the project opened before its board file existed lists the board twice (one greyed): open it again
# from its file, now that every document is there
if r.get('project'):
for d in (b['pcb'], sch):
ab('altium_save', {'filePath': d})
# closing the project alone leaves a second view of the board open: close every document of it
for d in (b['pcb'], sch, win(st['schLib']), win(st['pcbLib']), r['project']):
ab('altium_close_document', {'path': d})
ab('altium_open_project', {'projectPath': r['project'], 'filePath': r['project']})
# symbols and pin types (what a clean compile does not prove): the sheet read back from Altium
say('checking the drawn symbols: pins on their bodies, pin types, polarity drawings')
sym = symbol_check(st, sch)
ab('altium_show_schematic', {'filePath': sch})
ab('altium_run_command', {'command': 'Sch:Zoom', 'params': {'Action': 'All'}})
# parity: altium_build_schematic succeeds only when the sheet compiles to the netlist pin for pin; ERC:
# the compiler's messages (Altium's connection matrix checks pin types); symbols: tools/altium-lane.py
prove('schematic', {'sch': sch, 'project': r.get('project'), 'parts': r.get('placed'), 'nets': r.get('compiledNets'), 'boardOnly': board_only, 'pcb': b['pcb'],
'parity': True, 'erc': {'pass': not r.get('compileErrors'), 'errors': r.get('compileErrors') or []},
'symbols': {'ok': sym['ok'], 'errors': sym['errors'], 'warnings': sym['warnings'], 'checked': sym.get('checked'), 'report': str((RUN / 'schematic-check.json').resolve())}})
warn = 'Symbol warnings (%d): %s%s' % (len(sym['warnings']), '; '.join(sym['warnings'][:6]), '; ... (all in %s)' % (RUN / 'schematic-check.json') if len(sym['warnings']) > 6 else '') if sym['warnings'] else ''
nxt = 'Then the board: adom-aiflow start --board %s --spec <spec.json> --engine <you> --target %s --remote-board altium:%s' % (b['out'], TARGET, b['pcb'])
if not sym['ok']:
err('schematic %s drawn and compiled to the netlist pin for pin, the board built (%s), but its symbols do not check out:\n %s' % (sch, b['pcb'], '\n '.join(sym['errors'])),
['Pins off the body: the symbol spec came from an older altium-symbol-modes; update adom/altium-bridge and import the part again (`altium library --only <MPN>`), then `altium schematic` again.',
'Pin types: Altium holds what the SchLib symbol declares (amplifier inputs Input, outputs Output, power pins Power). When the datasheet really says otherwise, add {"ref", "pin", "why"} to spec.schematic.allowPassive; a reviewed polarity drawing goes in spec.schematic.polarityReviewed.',
'Recorded in the schematic proof: the verified list says "schematic symbols and pin types NO" and finish refuses until a clean `altium schematic`. Report: %s' % (RUN / 'schematic-check.json'),
warn, nxt])
ok('schematic %s: %d part(s) drawn, %d labelled pin(s), compiled to the netlist pin for pin (%d nets), symbols and pin types checked (%d symbol(s), %d warning(s)); the board followed with %d part(s) (%d parked for placement) in project %s' % (
sch, r.get('placed') or 0, r.get('labels') or 0, r.get('compiledNets') or 0, sym.get('librarySymbols') or 0, len(sym['warnings']), b['parts'], b['parked'], r.get('project')),
['Board-only parts (no symbol, on the board only): %s.' % ', '.join(board_only) if board_only else '',
warn,
'Look at it: altium_show_schematic {"filePath": "%s"} and a screenshot; every part in its block, every pin labelled with its net.' % sch,
'Next: simulate it before the board (the Adom SPICE dashboard; record it with `evidence add --kind simulation`).',
nxt])
def symbol_check(st, sch):
"""The drawn sheet's symbols and pin types, read back from Altium: altium_schematic_data (every placed
part's pins with their electrical type and place), altium_symbol_prims per used symbol (its SchLib
record), the symbol specs the library step kept; judged by check_symbols (tools/altium-lane.py).
Writes <run>/schematic-check.json."""
sd = ab('altium_schematic_data', {'filePath': sch, 'pins': True}, timeout=300)
data = sd.get('data') if isinstance(sd.get('data'), dict) else sd
if not sd.get('success') or not isinstance(data.get('components'), list):
rep = {'ok': False, 'errors': ['could not read the drawn sheet back (altium_schematic_data): %s' % clip(str(sd.get('error') or data.get('error') or sd.get('errorCode') or 'no components'), 200)], 'warnings': [], 'checked': {}}
else:
comps = data['components']
specs, prims = {}, {}
kept = {f.name[:-len('.sym.spec')].upper(): f for f in (STATE / 'symbols').glob('*.sym.spec')} if (STATE / 'symbols').is_dir() else {}
for lr in sorted({c.get('libReference') for c in comps if c.get('libReference')}):
if lr.upper() in kept: specs[lr] = kept[lr.upper()].read_text(errors='ignore')
p = ab('altium_symbol_prims', {'libraryPath': win(st['schLib']), 'symbolName': lr}, timeout=120)
pd = p.get('data') if isinstance(p.get('data'), dict) else p
if p.get('success') and pd.get('ok', True) is not False: prims[lr] = pd
rep = check_symbols(comps, specs, prims, run_spec())
# a bridge before 0.2.53 makes every library pin Passive (0.2.52 shipped only the 3D-body fix) and answers symbol_prims' body from
# any display mode: its pin types and its sheet body say nothing, so those findings warn
ver = str(sd.get('bridgeVersion') or data.get('bridgeVersion') or '')
old = tuple(int(x) for x in re.findall(r'\d+', ver)[:3]) < (0, 2, 53) if ver else False
if old:
keep, moved = [], []
for e in rep['errors']:
(moved if e.startswith('pin type') or ('as drawn on the sheet' in e and not specs) else keep).append(e)
if moved:
rep['errors'] = keep
rep['warnings'] = ['Altium Bridge %s predates 0.2.53 (every library pin Passive, the body from any display mode); update the bridge, then `altium library --again` and `altium symbols`: %s' % (ver, m) for m in moved] + rep['warnings']
rep['ok'] = not keep
rep['bridgeVersion'] = ver or None
(RUN / 'schematic-check.json').write_text(json.dumps(rep, indent=1))
return rep
def cmd_symbols(a):
"""The symbol check alone, on the sheet `altium schematic` drew (after a library fix, or on a run from
before 0.2.10): the schematic proof's symbols result is replaced, the rest of the proof kept."""
st = load_state()
sch = a.sch or st.get('schematic')
if not sch or not st.get('schLib'): err('no Altium schematic yet', ['Run `adom-aiflow altium schematic --netlist <netlist.json> --name <Design>` first: it draws the sheet and runs this check.'])
pf = RUN / 'proofs' / 'schematic.json'
data = (json.loads(pf.read_text()).get('data') or {}) if pf.is_file() else {}
sym = symbol_check(st, sch)
ab('altium_show_schematic', {'filePath': sch})
data['symbols'] = {'ok': sym['ok'], 'errors': sym['errors'], 'warnings': sym['warnings'], 'checked': sym.get('checked'), 'report': str((RUN / 'schematic-check.json').resolve())}
data.setdefault('sch', sch)
prove('schematic', data)
warn = 'Symbol warnings (%d): %s' % (len(sym['warnings']), '; '.join(sym['warnings'][:8])) if sym['warnings'] else ''
if not sym['ok']:
err('schematic %s: %d symbol error(s):\n %s' % (sch, len(sym['errors']), '\n '.join(sym['errors'])),
[warn, 'Recorded in the schematic proof (verified list: schematic symbols and pin types NO; finish refuses). Report: %s' % (RUN / 'schematic-check.json')])
ok('schematic %s: %d placed part(s), %d symbol(s): pins on their bodies, pin types and polarity drawings check out (%d warning(s)); recorded in the schematic proof' % (
sch, sym.get('symbols') or 0, sym.get('librarySymbols') or 0, len(sym['warnings'])), [warn])
# ---------------------------------------------------------------- silkscreen
def altium_board_model(rs, ss):
"""The board as the shared planner sees it (silkscreen-plan.py), from routing_state and silk_state:
pads and bodies as boxes, each part's net, the outline, the vias, and the free overlay items."""
pads = {}
for q in rs.get('pads', []):
rot = int(round(q.get('rotation') or 0)) % 180
hx, hy = (q['sizeY'] / 2, q['sizeX'] / 2) if rot == 90 else (q['sizeX'] / 2, q['sizeY'] / 2)
if rot not in (0, 90): hx = hy = max(q['sizeX'], q['sizeY']) / 2
pads.setdefault(q['ref'], []).append(((q['x'] - hx, q['y'] - hy, q['x'] + hx, q['y'] + hy), q.get('net') or ''))
els = []
for c in ss.get('components', []):
ps = pads.get(c['ref'], [])
nets = sorted({n for _, n in ps if n})
body = c.get('body') or [c['x'], c['y'], c['x'], c['y']]
nm = c.get('name') or {}
els.append({'name': c['ref'], 'package': c.get('pattern') or '', 'value': (c.get('comment') or {}).get('text') or '',
'x': c['x'], 'y': c['y'], 'bottom': c.get('side') == 'bottom', 'pads': [b for b, _ in ps] or [tuple(body)],
'body': tuple(body), 'rot': c.get('rotation') or 0, 'homeName': (nm.get('x', c['x']), nm.get('y', c['y'])),
'nameAttr': True, 'nameAlign': 'bottom-left', 'nameBox': tuple(nm['box']) if nm.get('shown') and nm.get('box') else None,
'net': nets[0] if len(nets) == 1 else ('/'.join(nets) if nets else None)})
pts = rs.get('outline') or []
outline = (min(p[0] for p in pts), min(p[1] for p in pts), max(p[0] for p in pts), max(p[1] for p in pts)) if pts else None
vias = [(v['position']['x'], v['position']['y'], (v.get('size') or 0.6) / 2) for v in rs.get('vias', [])]
texts = [{'text': t['text'], 'layer': 21 if t['side'] == 'top' else 22, 'box': tuple(t['box']), 'at': (t['x'], t['y'])} for t in ss.get('texts', [])]
texts += [{'text': None, 'layer': 'wire21', 'box': (l['x1'], l['y1'], l['x2'], l['y2'])} for l in ss.get('lines', []) if l['side'] == 'top']
return els, outline, vias, texts
def cmd_silkscreen(a):
global TEXT_W, TEXT_PADK, TEXT_PAD, TEXT_H
# Altium's stroke font, measured on AD 26.11.1 (2026-10-15): a character advances up to 0.95 x the
# height, the stroke is a seventh of the height, and Altium's box adds one stroke on every side
TEXT_W, TEXT_PADK, TEXT_PAD, TEXT_H = 0.95, 2 / 7.0, 0.0, 1 + 2 / 7.0
st = load_state()
pcb = a.design or st.get('board')
if not pcb: err('which board? build it with `altium schematic` (or pass --design <.PcbDoc>)')
ab('altium_show_2d_board', {'filePath': pcb})
# an earlier pass's own items come off first, so the plan starts from the parts alone
prev = st.get('silk') or {}
if a.fresh:
# every free overlay text and line on the board goes (the lane's own from earlier passes)
cur = ab('altium_silk_state', {}, timeout=300)
prev = {'texts': [dict(t['raw'], text=t['text']) for t in cur.get('texts', [])], 'lines': [l['raw'] for l in cur.get('lines', [])]}
elif any('text' not in t for t in prev.get('texts', [])):
# handles stored without their text (before 2026-10-16): the text comes from the board
cur = ab('altium_silk_state', {}, timeout=300)
by = {(t['raw']['layer'], t['raw']['x'], t['raw']['y']): t['text'] for t in cur.get('texts', [])}
prev['texts'] = [dict(t, text=t.get('text') or by.get((t['layer'], t['x'], t['y']), '')) for t in prev.get('texts', [])]
if prev.get('texts') or prev.get('lines'):
ab('altium_apply_silkscreen', {'remove': prev}, timeout=600)
st['silk'] = {}; save_state(st)
rs = ab('altium_routing_state', {'expectPath': pcb}, timeout=300)
ss = ab('altium_silk_state', {}, timeout=300)
if not rs.get('success') or not ss.get('success'): err('could not read the board: %s' % (rs.get('error') or ss.get('error')), [rs.get('_hint')])
els, outline, vias, texts = altium_board_model(rs, ss)
say('planning the silkscreen of %d part(s) on %s' % (len(els), pcb))
groups, frames, labels, leaders, unresolved = plan_board(els, outline, vias, texts)
title = a.title or st.get('title') or Path(pcb.replace('\\', '/')).stem
lines_ = [title, 'adom-aiflow %s' % time.strftime('%Y-%m-%d')]
spot = identity_spot(els, outline, vias, lines_)
sw = lambda size: size / 7.0
names, ftexts, flines = [], [], []
for L in labels:
size, rot = L['size'], (90 if L.get('rot') == 'R90' else 0)
b = L['box']
x, y = (b[0] + sw(size), b[1] + sw(size)) if rot == 0 else (b[2] - sw(size), b[1] + sw(size))
if L['kind'] == 'name' and not L.get('leader') and L['text'] == L['ref']:
names.append({'ref': L['ref'], 'x': round(x, 4), 'y': round(y, 4), 'size': size, 'rotation': rot})
else:
ftexts.append({'text': L['text'], 'x': round(x, 4), 'y': round(y, 4), 'size': size, 'rotation': rot})
pieces_of = {g['ref']: g.get('pieces') for g in groups}
for ref, f in frames:
if pieces_of.get(ref):
for (p_, q_) in pieces_of[ref]: flines.append({'x1': p_[0], 'y1': p_[1], 'x2': q_[0], 'y2': q_[1], 'width': 0.15})
elif f:
for (p_, q_) in (((f[0], f[1]), (f[2], f[1])), ((f[2], f[1]), (f[2], f[3])), ((f[2], f[3]), (f[0], f[3])), ((f[0], f[3]), (f[0], f[1]))):
flines.append({'x1': p_[0], 'y1': p_[1], 'x2': q_[0], 'y2': q_[1], 'width': 0.15})
for l in leaders:
(x1, y1), (x2, y2) = l['leader']; flines.append({'x1': x1, 'y1': y1, 'x2': x2, 'y2': y2, 'width': 0.15})
if spot:
for k, l in enumerate(lines_):
cy = spot[1] + (len(lines_) / 2 - k - 0.5) * spot[2] * 1.5
w = TEXT_W * spot[2] * len(l)
# bottom text is mirrored: it runs to the left of its location
ftexts.append({'text': l, 'x': round(spot[0] + w / 2, 4), 'y': round(cy - spot[2] / 2, 4), 'size': spot[2], 'rotation': 0, 'side': 'bottom'})
# the names this pass does not place stay off the print (a hidden designator), never a stray default
placed = {n['ref'] for n in names}
hide = [{'ref': c['ref'], 'x': c['name']['x'], 'y': c['name']['y'], 'size': max(c['name']['size'], 0.5), 'show': False}
for c in ss.get('components', []) if c['ref'] not in placed and (c.get('name') or {}).get('shown')]
plan = {'names': names, 'texts': ftexts, 'lines': flines, 'hide': hide, 'unresolved': unresolved, 'identity': {'lines': lines_, 'at': spot}}
(STATE / 'silkscreen-plan.json').write_text(json.dumps(plan, indent=1))
if a.dry: ok('silkscreen plan (not landed): %d names, %d texts, %d lines, %d unresolved; %s' % (len(names), len(ftexts), len(flines), len(unresolved), STATE / 'silkscreen-plan.json'))
r = ab('altium_apply_silkscreen', {'names': names + hide, 'texts': ftexts, 'lines': flines}, timeout=900)
if not r.get('after'): err('landing the silkscreen failed: %s' % (r.get('error') or r.get('errorCode')), [r.get('_hint')])
after = r['after']
# remember this pass's own free items (their exact handles) for the next pass to take off
def near(t, u): return abs(t['x'] - u['x']) < 0.02 and abs(t['y'] - u['y']) < 0.02 and t['text'] == u['text']
mine_t = [dict(t['raw'], text=t['text']) for t in after.get('texts', []) if any(near(t, u) for u in ftexts)]
def same(l, u): return all(abs(l[k] - u[k]) < 0.02 for k in ('x1', 'y1', 'x2', 'y2'))
mine_l = [l['raw'] for l in after.get('lines', []) if any(same(l, u) for u in flines)]
st['silk'] = {'texts': mine_t, 'lines': mine_l}; save_state(st)
# the check, on what Altium measured: every printed label clear of copper, vias, the edge and the others
copper = [b for e in els for b in e['pads'] if not e['bottom']]
viab = [(x - r_, y - r_, x + r_, y + r_) for (x, y, r_) in vias]
shown = [(c['ref'], tuple(c['name']['box'])) for c in after.get('components', []) if c['name'].get('shown') and c['side'] == 'top']
shown += [(t['text'], tuple(t['box'])) for t in after.get('texts', []) if t['side'] == 'top']
problems = []
for i, (n, b) in enumerate(shown):
if outline and not (b[0] >= outline[0] and b[1] >= outline[1] and b[2] <= outline[2] and b[3] <= outline[3]): problems.append('%s past the board edge' % n)
# under 0.05 mm is the mask expansion: the fab clips silkscreen out of a pad's opening anyway
if any(_ov(b, q, -0.05) for q in copper): problems.append('%s on copper' % n)
if any(_ov(b, v, -0.02) for v in viab): problems.append('%s on a via' % n)
for n2, b2 in shown[i + 1:]:
if _ov(b, b2, -0.02): problems.append('%s over %s' % (n, n2))
failed = [x for x in (r.get('results') or []) if str(x).startswith('err')]
if problems or failed:
err('silkscreen landed but does not check out on the board: %s' % '; '.join((problems + failed)[:12]),
['The plan is in %s. Move the parts that crowd it (place), or run `altium silkscreen` again after the fix; the proof is recorded only from a clean check.' % (STATE / 'silkscreen-plan.json')])
prove('silkscreen', {'pcb': pcb, 'names': len(names), 'texts': len(ftexts), 'lines': len(flines), 'frames': len(frames), 'leaders': len(leaders), 'unresolved': unresolved, 'identity': bool(spot), 'checked': len(shown), 'tool': 'altium'})
ab('altium_run_command', {'command': 'PCB:Zoom', 'params': {'Action': 'All'}})
ok('silkscreen: %d part names, %d contact frame(s), %d value/net label(s), %d leader(s)%s, board identity on the bottom%s; every printed label checked clear of copper, vias, the edge and the others (%d)' % (
len(names), len(frames), len([t for t in ftexts if t.get('side') != 'bottom']), len(leaders), (', unresolved: ' + ', '.join(unresolved)) if unresolved else '', '' if spot else ' (no clear spot found)', len(shown)),
['Show both faces in Altium (top labels next to their parts; flip the board for the bottom identity), then the 3D view: `altium view3d`.'])
# ---------------------------------------------------------------- export
def roles_of(st, board_refs):
"""{ref: 'tp' | 'machine'} for the parts a fab must not place: board-only pads and test points
(bare copper) out of both files; machine contacts and pins (fitted after assembly) DNP."""
roles = {}
for ref, key in (st.get('refs') or {}).items():
if ref not in board_refs: continue
part = (st.get('parts') or {}).get(key, {})
if TP_NAME.search(key) or not part.get('symbol'): roles[ref] = 'tp'
elif MACHINE_NAME.search(key): roles[ref] = 'machine'
return roles
def jlc_files(out, name, bom, cpl):
"""JLCPCB's own column names from Altium's BOM and pick and place (already fab-ready)."""
h, rows = table(bom); hl = [c.strip().lower() for c in h]
col = lambda *n: next((hl.index(c) for c in n if c in hl), None)
ci, di, fi, li, dn = col('name', 'comment'), col('designator'), col('footprint'), col('lcsc part #', 'lcsc'), col('dnp')
with open(out / 'bom-jlcpcb.csv', 'w', newline='', encoding='utf-8') as f:
w = csv.writer(f); w.writerow(['Comment', 'Designator', 'Footprint', 'LCSC Part #'])
for r in rows:
if dn is not None and dn < len(r) and r[dn].strip(): continue
w.writerow([r[i] if i is not None and i < len(r) else '' for i in (ci, di, fi, li)])
h, rows = table(cpl); hl = [c.strip().lower() for c in h]
di, xi, yi, li, ri = col('designator'), col('center-x(mm)', 'center-x'), col('center-y(mm)', 'center-y'), col('layer'), col('rotation')
with open(out / 'cpl-jlcpcb.csv', 'w', newline='', encoding='utf-8') as f:
w = csv.writer(f); w.writerow(['Designator', 'Mid X', 'Mid Y', 'Layer', 'Rotation'])
for r in rows:
g = lambda i: r[i] if i is not None and i < len(r) else ''
w.writerow([g(di), g(xi) + 'mm', g(yi) + 'mm', 'Top' if 'top' in g(li).lower() else 'Bottom', g(ri)])
def cmd_export(a):
st = load_state()
prj, pcb = st.get('project'), a.design or st.get('board')
if not prj or not pcb: err('no Altium project yet', ['Run `adom-aiflow altium schematic` first: the project holds the schematic, the board and the manufacturing OutJob.'])
out = Path(a.out_dir) if a.out_dir else RUN / 'manufacturing'
shutil.rmtree(out, ignore_errors=True); out.mkdir(parents=True)
# the project handed over is what is on disk: save the open schematic and board first (a 2026-10-20 run
# had saved its board only when it was built, so its project opened as the empty board)
for d in [x for x in (st.get('schematic'), pcb) if x]:
s = ab('altium_save', {'filePath': d})
if not s.get('success'): err('could not save %s before the export: %s' % (d, s.get('error') or s.get('errorCode')), ['Save it in Altium (File > Save), then run `altium export` again.'])
say('writing the manufacturing set from %s' % prj)
r = ab('altium_export_manufacturing', {'projectPath': prj, 'pcbPath': pcb}, timeout=900)
if not r.get('success') or not r.get('files'): err('the export wrote nothing: %s' % (r.get('error') or r.get('errorCode') or 'no files'), [r.get('_hint'), 'dialogs: %s' % json.dumps(r.get('dialogs'))[:300] if r.get('dialogs') else ''])
files = [f for f in r['files'] if not f.lower().endswith(('.rep', '.extrep', '.rul', '.apr', '.apr_lib', '.drr', '.ldp')) and 'status report' not in f.lower()]
p = ab('pull_file', {'filePaths': [r['outputDir'] + '\\' + f for f in files], 'saveTo': str(out.resolve())}, timeout=900)
got = {Path(f.replace('\\', '/')).name for f in files}
have = {x.name for x in out.iterdir()}
problems = ['not pulled: %s' % ', '.join(sorted(got - have))] if got - have else []
ext = {x.suffix.lower() for x in out.iterdir()}
for what, keys in {'top copper': ('.gtl',), 'bottom copper': ('.gbl',), 'top mask': ('.gts',), 'bottom mask': ('.gbs',),
'top silkscreen': ('.gto',), 'outline': ('.gko', '.gm1'), 'drill': ('.txt', '.drl')}.items():
if not ext & set(keys): problems.append('no %s file' % what)
# the export leaves the OutJob current: put the board back before reading it
ab('altium_show_2d_board', {'filePath': pcb})
rs = ab('altium_routing_state', {'expectPath': pcb}, timeout=300)
if not rs.get('success') or not rs.get('components'): err('could not read the board to check the files against it: %s' % (rs.get('error') or rs.get('errorCode')), [rs.get('_hint')])
board_refs = {c['ref'] for c in rs.get('components', [])}
# drill hits: every via and every drilled pad is one hit in the NC drill file
want_hits = len(rs.get('vias', [])) + sum(1 for q in rs.get('pads', []) if (q.get('drill') or 0) > 0)
hits = 0
for d in out.glob('*.TXT'):
for line in d.read_text(errors='ignore').splitlines():
if re.match(r'^[XY][-\d]', line): hits += 1
if want_hits and hits != want_hits: problems.append('the drill file has %d hit(s), the board %d via(s) and drilled pad(s)' % (hits, want_hits))
roles = roles_of(st, board_refs)
cpl = next(iter(out.glob('Pick Place*.csv')), None); bom = next(iter(out.glob('Bill of Materials*.csv')), None)
if not cpl: problems.append('no pick and place file')
if not bom: problems.append('no BOM file')
if cpl and bom:
fab_rewrite(cpl, roles, True); fab_rewrite(bom, roles, False)
placed = board_refs - set(roles)
cpl_refs, bom_refs, bom_all = refs_in(cpl), refs_in(bom, dnp_too=False), refs_in(bom)
if placed - cpl_refs: problems.append('pick and place is missing %s' % ', '.join(sorted(placed - cpl_refs)))
if placed - bom_refs: problems.append('BOM is missing %s' % ', '.join(sorted(placed - bom_refs)))
if set(roles) & cpl_refs: problems.append('test points or machine parts still in the pick and place: %s' % ', '.join(sorted(set(roles) & cpl_refs)))
dnp = sorted(r_ for r_, k in roles.items() if k == 'machine')
if set(dnp) - bom_all: problems.append('machine parts missing from the BOM as DNP: %s' % ', '.join(sorted(set(dnp) - bom_all)))
jlc_files(out, Path(pcb).stem, bom, cpl)
# fills in the Gerbers: every copper layer Altium has poured carries filled regions (G36) in its Gerber
ps = ab('altium_pour_state', {}, timeout=300)
poured = sorted(l for l, mm2 in ((ps.get('filledAreaByLayerMm2') or {}) if ps.get('success') else {}).items() if (mm2 or 0) > 0)
gext = lambda l: '.gtl' if l == 'F.Cu' else '.gbl' if l == 'B.Cu' else ('.g%s' % l[2:-3] if l.startswith('In') and l.endswith('.Cu') else None)
regions = {}
for l in poured:
f = next((x for x in out.iterdir() if gext(l) and x.suffix.lower() == gext(l)), None)
regions[l] = f.read_text(errors='ignore').count('G36') if f else 0
fills_gerbers = {'pass': bool(poured) and all(n > 0 for n in regions.values()), 'regions': regions} if ps.get('success') else {'pass': None, 'why': 'altium_pour_state failed'}
for l, n in regions.items():
if n == 0: problems.append('the %s Gerber has no filled regions (G36) though Altium poured that layer' % l)
if problems: err('manufacturing files written to %s, but they do not check out: %s' % (out, '; '.join(problems)), ['Fix the board or the parts\' parameters and run `altium export` again; never hand the human files that do not match the board.'])
import zipfile
zp = out / (Path(pcb.replace('\\', '/')).stem + '-gerbers.zip')
with zipfile.ZipFile(zp, 'w', zipfile.ZIP_DEFLATED) as z:
for x in sorted(out.iterdir()):
if x.suffix.lower() in ('.gtl', '.gbl', '.gts', '.gbs', '.gto', '.gbo', '.gtp', '.gbp', '.gko', '.gm1', '.txt') or re.fullmatch(r'\.g\d+|\.gm\d+', x.suffix.lower()):
z.write(x, x.name)
missing_numbers = sorted({r_[0] for r_ in [(row[0],) for row in table(out / 'bom-jlcpcb.csv')[1] if len(row) > 3 and not row[3].strip()]}) if (out / 'bom-jlcpcb.csv').is_file() else []
prove('export', {'dir': str(out.resolve()), 'gerberZip': zp.name, 'drillHits': hits, 'cplRefs': len(refs_in(cpl)), 'bomLines': len(table(bom)[1]), 'tool': 'altium', 'fillsGerbers': fills_gerbers})
if os.environ.get('AIFLOW_EXE'): run([os.environ['AIFLOW_EXE'], '--run', str(RUN), 'timeline', 'write'], timeout=60)
tps = sorted(r_ for r_, k in roles.items() if k == 'tp'); dnp = sorted(r_ for r_, k in roles.items() if k == 'machine')
ok('manufacturing files in %s, checked against the board: Gerbers (%s), NC drill (%d hits = vias + drilled pads), pick and place (%d parts), BOM (%d lines), the JLCPCB BOM and CPL' % (
out, zp.name, hits, len(refs_in(cpl)), len(table(bom)[1])),
['No LCSC number on: ' + ', '.join(missing_numbers) + '. That is fine: for a JLCPCB build, buy these anywhere (DigiKey, Mouser, ...) and send them as consigned parts, or add an LCSC number to the part to have JLCPCB source it (usually cheaper and faster).' if missing_numbers else '',
'Test points and board-only pads are bare copper, out of the BOM and the pick and place: ' + ', '.join(tps) if tps else '',
'Machine contacts and pins are fitted after assembly: DNP in the BOM, out of the pick and place: ' + ', '.join(dnp) if dnp else ''])
# ---------------------------------------------------------------- reset / view3d
def cmd_reset(a):
st = load_state()
pcb = a.design or st.get('board')
if not pcb: err('which board? pass --design <.PcbDoc path> or build one with `altium board`')
ab('altium_show_2d_board', {'filePath': pcb})
s0 = ab('altium_routing_state', {'expectPath': pcb})
if not s0.get('success'): err('could not read the board: %s' % s0.get('error'))
nets = [n for n in s0.get('nets', []) if n]
r = ab('altium_ripup_nets', {'expectPath': pcb, 'expectedRevision': s0['revision'], 'nets': nets}, timeout=900)
if not r.get('success'): err('rip-up: %s' % r.get('error'), [r.get('_hint')])
r2 = ab('altium_remove_pours', {'expectPath': pcb, 'pours': 'all'})
if not r2.get('success'): err('pours: %s' % r2.get('error'), [r2.get('_hint')])
ok('copper cleared on %s: %d net(s) ripped up, %d pour(s) removed; the parts did not move' % (pcb, len(nets), r2.get('removed') or 0),
['Next: adom-aiflow route (it plans on the live board), gate, land route, pour, land vias, land pours, measure, then analyze again.'])
def cmd_view3d(a):
st = load_state()
pcb = a.design or st.get('board')
if not pcb: err('which board? pass --design <.PcbDoc path>')
r = ab('altium_show_3d_board', {'filePath': pcb})
if not r.get('success'): err('3D view: %s' % r.get('error'), [r.get('_hint')])
img = (r.get('shot') or {}).get('image')
if img:
import base64
d = RUN / 'altium'; d.mkdir(parents=True, exist_ok=True)
(d / 'view3d.png').write_bytes(base64.b64decode(img.split(',')[-1]))
t = ab('altium_animate_view', {'preset': 'showcase3d', 'pauseMs': int(a.pause * 1000)}, timeout=900)
prove('tour-3d', {'pcb': pcb, 'tour': bool(t.get('success'))})
ok('the board in Altium 3D%s; a still is at %s' % (' and a showcase orbit' if t.get('success') else '', RUN / 'altium' / 'view3d.png'),
['Look at it: every part must sit flat on its pads as its datasheet drawing shows. A part that does not: fix its model on the wiki page and run `altium library --again`.'])
def main():
ap = argparse.ArgumentParser(prog='adom-aiflow altium')
sub = ap.add_subparsers(dest='cmd', required=True)
p = sub.add_parser('library'); p.add_argument('--parts', required=True, help='netlist.json (components with ref, mpn, fp) or parts.json [{mpn, page?, footprint?}]'); p.add_argument('--footprints', help='folder of .kicad_mod for board-only parts (no MPN)'); p.add_argument('--name'); p.add_argument('--numbers', help='JSON {mpn: {mouser, digikey, lcsc}} already checked'); p.add_argument('--again', action='store_true', help='import every part again (after a fix on its page)'); p.add_argument('--only', nargs='+', metavar='MPN', help='import just these parts again')
p = sub.add_parser('reference'); p.add_argument('--kicad', required=True); p.add_argument('--spec', required=True); p.add_argument('--out-dir', default=str(RUN / 'altium'))
p = sub.add_parser('board'); p.add_argument('--netlist', required=True); p.add_argument('--name', required=True); p.add_argument('--fixed', help='fixed.json from `altium reference` (default: <run>/altium/fixed.json)'); p.add_argument('--outline', nargs=2, type=float, help='W H in mm when there is no fixed.json')
p = sub.add_parser('schematic'); p.add_argument('--netlist', required=True); p.add_argument('--name', required=True); p.add_argument('--fixed', help='fixed.json from `altium reference` (default: <run>/altium/fixed.json)'); p.add_argument('--outline', nargs=2, type=float, help='W H in mm when there is no fixed.json'); p.add_argument('--revision', default='A')
p = sub.add_parser('symbols'); p.add_argument('--sch', help='the .SchDoc (default: the project schematic)')
p = sub.add_parser('silkscreen'); p.add_argument('--design', help='the .PcbDoc (default: the project board)'); p.add_argument('--title', help='the board identity on the bottom (default: the design name)'); p.add_argument('--dry', action='store_true', help='plan only'); p.add_argument('--fresh', action='store_true', help='clear every free overlay text and line first (earlier passes left on the board)')
p = sub.add_parser('export'); p.add_argument('--design', help='the .PcbDoc (default: the project board)'); p.add_argument('--out-dir', help='default <run>/manufacturing')
p = sub.add_parser('reset'); p.add_argument('--design')
p = sub.add_parser('view3d'); p.add_argument('--design'); p.add_argument('--pause', type=float, default=0.7)
a = ap.parse_args()
{'library': cmd_library, 'reference': cmd_reference, 'schematic': cmd_schematic, 'symbols': cmd_symbols, 'board': cmd_board, 'export': cmd_export, 'silkscreen': cmd_silkscreen, 'reset': cmd_reset, 'view3d': cmd_view3d}[a.cmd](a)
main()