app
Adom Library
Public Made by Adomby adom
adom-lbr — the EDA library translator. Bring a component in from any supported EDA tool and convert it to any other (KiCad ⇄ Altium ⇄ EAGLE/Fusion) through one canonical adom-lbr JSON — symbol + footprint + 3D + pin↔pad map. Altium is written natively (no Altium needed). Includes the adom-lbr Manager: browse wiki components and view symbol/footprint/3D + the per-EDA layer stackup.
← Commit history
Laptop full-toolbar hero, lint-pass/lint-fail/CLI feature images, open-source section + Rust source
10 files changed
+1661−1
Cargo.tomladded+24@@ -0,0 +1,24 @@+[package]+name = "adom-lbr"+version = "0.1.4"+edition = "2021"+description = "EAGLE .lbr generator, linter, and validator for Fusion 360 Electronics. Converts KiCad symbols + footprints to complete EAGLE libraries."++[[bin]]+name = "adom-lbr"+path = "src/main.rs"++[dependencies]+clap = { version = "4", features = ["derive"] }+clap_complete = "4"+regex-lite = "0.1"+serde = { version = "1", features = ["derive"] }+serde_json = "1"+quick-xml = { version = "0.36", features = ["serialize"] }+tiny_http = "0.12"+anyhow = "1"++[profile.release]+opt-level = "z"+lto = true+strip = true
README.md+28−1@@ -1,9 +1,27 @@ # adom-lbr -**An EAGLE `.lbr` generator, linter, and validator for Fusion 360 Electronics — built from KiCad sources.**+**An EAGLE `.lbr` generator, linter, and validator for Fusion 360 Electronics — built from KiCad sources, standalone Rust, no local EAGLE.** adom-lbr turns a KiCad `.kicad_sym` (and optional `.kicad_mod`) into a true EAGLE `.lbr` with a complete deviceset — symbol + package + pin→pad connects — that Fusion 360 Electronics imports cleanly. It can also synthesize a placeable symbol from a footprint's pads when there's no schematic symbol, convert an `.lbr` back to KiCad, and verify a library through `service-kicad`. Standalone Rust, no Node, no gallia. +## The library at a glance++++Load an `.lbr` and the viewer breaks it down at a glance — **symbols, packages, devicesets, pins, pads** — and re-lints it server-side. A green **Lint passed** badge means the library is ready to import into Fusion 360 Electronics with no surprises. The top bar carries the **Send to** targets (KiCad / Fusion / Altium / OrCAD).++## The linter catches what Fusion rejects++++This is the whole point. Fusion will silently show "No Components" or refuse to place a part when a library is subtly broken — adom-lbr catches it first. Here the deviceset has **no pin-to-pad connect mappings**, so the linter flags `empty-connects` with a plain-English reason: *"Fusion cannot use this component."* It also catches missing packages, devices with no package attribute, malformed XML, and invisible symbol bodies — each with a code and a fix.++## Generate from the command line++++`generate` builds the whole library in one shot from a `.kicad_sym` (+ optional `.kicad_mod`), lint-checks it, and writes a native `.lbr`. The same engine powers the **Send to → Fusion/Altium/OrCAD** flow in adom-symbol and adom-footprint, so whatever you see in those apps is exactly what lands in your library.+ ## What it does - **`generate`** — `.kicad_sym` (+ `.kicad_mod`) → native `.lbr`, lint-checked for Fusion compatibility.@@ -15,6 +33,15 @@ adom-lbr turns a KiCad `.kicad_sym` (and optional `.kicad_mod`) into a true EAGL Fusion 360 (open the `.lbr` directly), Altium / OrCAD (download the `.lbr` + import steps; native exporters on the roadmap), KiCad (converted back to `.kicad_sym`). +## Open source — check it out, vibe-code it, send it back++adom-lbr is MIT and **fully open**. The complete Rust source is in this page's **Files** tab — browse it, or grab `src/` + `Cargo.toml`.++1. **Build it.** `cargo build --release` drops a standalone binary (no Node, no gallia).+2. **Point your install at your checkout.** `adompkg link john/adom-lbr ./adom-lbr` so the `adom-lbr` you run is your local copy.+3. **Vibe-code a change.** Open the folder in Claude Code and describe what you want — a new lint rule, another export target, a smarter symbol synthesizer. The lint rules live in one readable list, easy to extend.+4. **Send it back.** Open a **pull request** on this page (the wiki reviews and merges per-repo PRs), or start a discussion thread. Good additions get folded into the shipped app.+ ## Part of the Adom EDA family Used by **chip-fetcher** and the Send-to flow across **adom-symbol** and **adom-footprint**; pairs with **ds2sf**.
docs/generate.pngadded⋯ 1 unchanged line ⋯
docs/hero.pngadded⋯ 1 unchanged line ⋯
docs/lintfail.pngadded⋯ 1 unchanged line ⋯
docs/lintpass.pngadded⋯ 1 unchanged line ⋯
src/app.htmladded+256@@ -0,0 +1,256 @@+<!doctype html>+<html lang="en">+<head>+ <meta charset="utf-8">+ <meta name="viewport" content="width=device-width, initial-scale=1">+ <title>adom-lbr</title>+ <link rel="icon" type="image/svg+xml" href="favicon.svg">+ <style>+ :root {+ --bg:#0d1117; --panel:#11161f; --panel2:#0f141c; --border:#222b38; --border2:#2b3645;+ --text:#e6edf3; --muted:#8b949e; --text3:#5b6675; --accent:#00b8b1; --accent2:#00e6dc;+ --ok:#2ea043; --ok2:#3fb950; --err:#f85149; --warn:#d29922;+ }+ * { box-sizing:border-box; }+ html,body { margin:0; height:100%; }+ body { background:var(--bg); color:var(--text); font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; display:flex; flex-direction:column; }++ header.app {+ height:44px; flex:0 0 44px; display:grid; grid-template-columns:auto 1fr auto;+ align-items:center; gap:14px; padding:0 14px; border-bottom:1px solid var(--border);+ background:linear-gradient(180deg,#11161f,#0d1117);+ }+ header.app .ident { display:flex; align-items:center; gap:8px; min-width:0; }+ header.app .ident svg { width:16px; height:16px; color:var(--accent2); flex:0 0 16px; }+ header.app .app-name { font-weight:700; font-size:13px; letter-spacing:.2px; white-space:nowrap; }+ header.app .app-sub { color:var(--muted); font-size:12px; white-space:nowrap; }+ header.app .app-sub::before { content:"·"; margin:0 6px; color:var(--text3); }+ header.app .subject { justify-self:center; display:flex; align-items:baseline; gap:10px; min-width:0; }+ header.app .subject-name { font-weight:600; font-size:13px; max-width:42vw; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }+ header.app .subject-meta { font-family:'JetBrains Mono',ui-monospace,monospace; font-size:11px; color:var(--muted); white-space:nowrap; }+ header.app .actions { display:flex; align-items:center; gap:8px; }+ header.app .conn { display:inline-flex; align-items:center; gap:6px; font-size:11px; color:var(--muted); }+ header.app .conn .dot { width:8px; height:8px; border-radius:50%; background:var(--text3); transition:background .2s, box-shadow .2s; }+ header.app .conn.live .dot { background:var(--accent2); box-shadow:0 0 8px var(--accent2); }+ header.app .sendto { display:inline-flex; align-items:center; gap:4px; }+ header.app .sendto .sendlabel { font-size:11px; color:var(--muted); margin-right:2px; }+ header.app .sendto .sbtn { font:inherit; font-size:11px; color:var(--text2,#cfd6e4); background:var(--surface2,#1c2230); border:1px solid var(--border,#2a3140); border-radius:6px; padding:3px 8px; cursor:pointer; transition:background .15s,border-color .15s,color .15s; }+ header.app .sendto .sbtn:hover { background:var(--accent2,#37c8a6); border-color:var(--accent2,#37c8a6); color:#0b0f17; }+ header.app .sendto .sbtn:disabled { opacity:.5; cursor:progress; }+ #toast { position:fixed; left:50%; bottom:18px; transform:translateX(-50%) translateY(20px); background:#1c2230; color:#e8ecf4; border:1px solid #2a3140; border-radius:10px; padding:10px 16px; font-size:13px; max-width:84vw; box-shadow:0 8px 28px rgba(0,0,0,.45); opacity:0; pointer-events:none; transition:opacity .25s, transform .25s; z-index:9999; }+ #toast.show { opacity:1; transform:translateX(-50%) translateY(0); }+ #toast.ok { border-color:#37c8a6; } #toast.err { border-color:#e0698a; }+ #bridgeModal{position:fixed;inset:0;z-index:100001;display:none;align-items:center;justify-content:center;background:rgba(2,5,10,.62);backdrop-filter:blur(4px);padding:20px}+ #bridgeModal.show{display:flex}+ #bridgeModal .bm-card{max-width:min(94vw,440px);background:rgba(17,22,31,.99);border:1px solid var(--border2,#2b3645);border-radius:16px;padding:22px 22px 18px;box-shadow:0 30px 90px rgba(0,0,0,.7);text-align:center}+ #bridgeModal .bm-icon{font-size:34px;margin-bottom:6px}+ #bridgeModal .bm-title{font-size:16px;font-weight:700;margin-bottom:8px;color:var(--text,#e6edf3)}+ #bridgeModal .bm-body{font-size:13px;line-height:1.55;color:var(--muted,#8b949e);margin-bottom:16px}+ #bridgeModal .bm-actions{display:flex;flex-direction:column;gap:9px}+ #bridgeModal .bm-go{display:block;text-decoration:none;background:linear-gradient(180deg,#00b8b1,#009a94);color:#04201f;font-weight:700;font-size:13px;padding:10px;border-radius:10px}+ #bridgeModal .bm-dismiss{background:transparent;border:1px solid var(--border2,#2b3645);color:var(--muted,#8b949e);font-size:12.5px;padding:8px;border-radius:10px;cursor:pointer}+ #bridgeModal .bm-dismiss:hover{color:var(--text,#e6edf3)}++ main { flex:1; display:flex; align-items:flex-start; justify-content:center; padding:28px; min-height:0; overflow:auto; }+ .card { background:var(--panel); border:1px solid var(--border); border-radius:14px; padding:22px 24px; box-shadow:0 24px 80px rgba(0,0,0,.5); max-width:min(94vw,720px); width:100%; }+ .topline { display:flex; align-items:center; justify-content:space-between; gap:14px; }+ .libname { font-size:21px; font-weight:700; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }+ .badge { flex:0 0 auto; display:inline-flex; align-items:center; gap:7px; font-size:13px; font-weight:700; padding:6px 13px; border-radius:999px; }+ .badge .pip { width:9px; height:9px; border-radius:50%; }+ .badge.pass { background:rgba(46,160,67,.14); color:var(--ok2); border:1px solid rgba(46,160,67,.4); }+ .badge.pass .pip { background:var(--ok2); box-shadow:0 0 8px var(--ok2); }+ .badge.fail { background:rgba(248,81,73,.13); color:var(--err); border:1px solid rgba(248,81,73,.4); }+ .badge.fail .pip { background:var(--err); box-shadow:0 0 8px var(--err); }++ .counts { display:flex; flex-wrap:wrap; gap:10px; margin-top:18px; }+ .stat { flex:1 1 0; min-width:84px; background:var(--panel2); border:1px solid var(--border); border-radius:10px; padding:11px 13px; }+ .stat .n { font-size:20px; font-weight:700; font-family:'JetBrains Mono',ui-monospace,monospace; }+ .stat .k { color:var(--muted); font-size:11px; margin-top:1px; text-transform:uppercase; letter-spacing:.4px; }++ .section { margin-top:18px; }+ .section h3 { font-size:11px; letter-spacing:.5px; text-transform:uppercase; color:var(--muted); margin:0 0 8px; }+ .issue { display:flex; gap:9px; align-items:flex-start; padding:9px 11px; border-radius:9px; font-size:13px; line-height:1.45; margin-bottom:7px; }+ .issue.e { background:rgba(248,81,73,.08); border:1px solid rgba(248,81,73,.28); }+ .issue.w { background:rgba(210,153,34,.08); border:1px solid rgba(210,153,34,.28); }+ .issue .code { flex:0 0 auto; font-family:'JetBrains Mono',ui-monospace,monospace; font-size:11px; padding:1px 7px; border-radius:6px; margin-top:1px; }+ .issue.e .code { background:rgba(248,81,73,.18); color:var(--err); }+ .issue.w .code { background:rgba(210,153,34,.18); color:var(--warn); }+ .clean { color:var(--ok2); font-size:13px; display:flex; align-items:center; gap:8px; }+ .empty { color:var(--text3); font-size:13px; padding:48px 20px; text-align:center; }+ code { font-family:'JetBrains Mono',ui-monospace,monospace; background:var(--panel2); border:1px solid var(--border); border-radius:5px; padding:1px 6px; font-size:.9em; }+ footer { flex:0 0 auto; text-align:center; color:var(--text3); font-size:11px; padding:8px; border-top:1px solid var(--border); }++ #tt { position:fixed; z-index:99999; max-width:340px; background:rgba(22,27,34,0.97); backdrop-filter:blur(16px);+ border:1px solid rgba(0,184,177,0.25); border-radius:10px; padding:10px 14px; color:#e6edf3;+ font-family:-apple-system,sans-serif; font-size:12px; font-weight:400; letter-spacing:0; text-transform:none;+ line-height:1.55; white-space:pre-wrap; pointer-events:none; opacity:0; transition:opacity .18s ease;+ box-shadow:0 12px 40px rgba(0,0,0,0.55); }+ #tt.show { opacity:1; }+ [data-tooltip] { cursor:help; }+ </style>+</head>+<body>+ <header class="app">+ <div class="ident" data-tooltip="adom-lbr — a standalone Rust tool that converts KiCad symbols/footprints into EAGLE .lbr libraries for Fusion 360 Electronics, then lints them so Fusion won't silently show 'No Components'. No local EAGLE/Fusion install needed.">+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 5.5A1.5 1.5 0 0 1 5.5 4H9a1.5 1.5 0 0 1 1.5 1.5V20a1.5 1.5 0 0 0-1.5-1.5H5.5A1.5 1.5 0 0 1 4 17z"/><path d="M20 5.5A1.5 1.5 0 0 0 18.5 4H15a1.5 1.5 0 0 0-1.5 1.5V20a1.5 1.5 0 0 1 1.5-1.5h3.5a1.5 1.5 0 0 0 1.5-1.5z"/></svg>+ <span class="app-name">adom-lbr</span>+ <span class="app-sub">EAGLE library linter</span>+ </div>+ <div class="subject" id="subject">+ <span class="subject-name" id="subjectName">—</span>+ <span class="subject-meta" id="subjectMeta"></span>+ </div>+ <div class="actions">+ <span class="sendto" id="sendto" style="display:none" data-tooltip="Send this library to a desktop EDA tool via Adom Desktop. Fusion 360 / Altium / OrCAD import the .lbr directly; KiCad receives a native .kicad_sym converted from it.">+ <span class="sendlabel">Send to</span>+ <button class="sbtn" data-target="kicad">KiCad</button>+ <button class="sbtn" data-target="fusion360">Fusion</button>+ <button class="sbtn" data-target="altium">Altium</button>+ <button class="sbtn" data-target="orcad">OrCAD</button>+ </span>+ <span class="conn" id="conn" data-tooltip="Live connection to the adom-lbr Rust server. Green = the UI is polling /state and the AI can drive this view in real time (push an .lbr via /load, run /eval, read the console).">+ <span class="dot"></span><span id="connText">connecting…</span>+ </span>+ </div>+ </header>++ <main>+ <div class="card" id="card">+ <div class="empty" id="empty">Waiting for a library… run <code>adom-lbr generate</code> then push the <code>.lbr</code>, or the AI will load one here.</div>+ <div id="content" style="display:none">+ <div class="topline">+ <div class="libname" id="libName"></div>+ <div class="badge" id="badge"><span class="pip"></span><span id="badgeText"></span></div>+ </div>+ <div class="counts" id="counts"></div>+ <div class="section" id="errSection" style="display:none">+ <h3 data-tooltip="Hard errors block the component in Fusion 360 — an empty packages/connects section or a malformed deviceset makes the part import as 'No Components'.">Errors</h3>+ <div id="errList"></div>+ </div>+ <div class="section" id="warnSection" style="display:none">+ <h3 data-tooltip="Warnings won't block import but usually mean the symbol/pads won't look or connect the way you expect in Fusion.">Warnings</h3>+ <div id="warnList"></div>+ </div>+ <div class="section" id="cleanSection" style="display:none">+ <div class="clean">✓ Lint passed — this library is ready to import into Fusion 360 Electronics.</div>+ </div>+ </div>+ </div>+ </main>+ <footer>standalone Rust — KiCad → EAGLE .lbr, no gallia, no Node, no local EAGLE</footer>++ <div id="tt"></div>+ <div id="toast"></div>++ <script>+ (function(){+ const post = (level,args)=>{ try{ navigator.sendBeacon('console', JSON.stringify({level,msg:args.map(a=>{try{return typeof a==='string'?a:JSON.stringify(a)}catch{return String(a)}}).join(' ')})); }catch(e){} };+ ['log','warn','error','info'].forEach(l=>{ const o=console[l].bind(console); console[l]=(...a)=>{ post(l,a); o(...a); }; });+ window.addEventListener('error', e=> post('error',[e.message+' @'+(e.filename||'')+':'+e.lineno]));+ })();++ (function(){+ const tt=document.getElementById('tt'); let timer=null, cur=null;+ function place(el){ const r=el.getBoundingClientRect(); tt.style.left='0px'; tt.style.top='0px'; tt.classList.add('show');+ const tr=tt.getBoundingClientRect(); let x=r.left+r.width/2-tr.width/2; let y=r.top-tr.height-8;+ x=Math.max(8,Math.min(x,innerWidth-tr.width-8)); if(y<8) y=r.bottom+8;+ tt.style.left=x+'px'; tt.style.top=y+'px'; }+ document.addEventListener('mouseover',e=>{ const el=e.target.closest('[data-tooltip]'); if(!el||el===cur) return; cur=el;+ clearTimeout(timer); timer=setTimeout(()=>{ tt.textContent=el.getAttribute('data-tooltip'); place(el); },500); });+ document.addEventListener('mouseout',e=>{ if(e.target.closest('[data-tooltip]')===cur){ clearTimeout(timer); tt.classList.remove('show'); cur=null; } });+ })();++ function esc(s){ return String(s==null?'':s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }+ function statHtml(n,k,tip){ return '<div class="stat" data-tooltip="'+esc(tip)+'"><div class="n">'+n+'</div><div class="k">'+k+'</div></div>'; }+ function issueHtml(cls,o){ return '<div class="issue '+cls+'"><span class="code">'+esc(o.code)+'</span><span>'+esc(o.message)+'</span></div>'; }++ let lastHash='';+ async function pollState(){+ try{+ const r=await fetch('state',{cache:'no-store'}); const s=await r.json();+ setConn(true);+ const hash=JSON.stringify(s);+ if(hash!==lastHash){ lastHash=hash; render(s); }+ }catch(e){ setConn(false); }+ }+ function setConn(live){ const c=document.getElementById('conn'); c.classList.toggle('live',live); document.getElementById('connText').textContent=live?'live':'offline'; }+ function render(s){+ const has=!!s.hasContent;+ document.getElementById('empty').style.display=has?'none':'';+ document.getElementById('content').style.display=has?'':'none';+ document.getElementById('subjectName').textContent=has?s.libraryName:'—';+ document.getElementById('subjectMeta').textContent=has?(s.lintPassed?'lint pass':((s.errors||[]).length+' err')):'';+ document.getElementById('sendto').style.display=has?'inline-flex':'none';+ if(!has) return;++ document.getElementById('libName').textContent=s.libraryName;+ const badge=document.getElementById('badge'), bt=document.getElementById('badgeText');+ badge.className='badge '+(s.lintPassed?'pass':'fail');+ bt.textContent=s.lintPassed?'Lint passed':(((s.errors||[]).length)+' error'+(((s.errors||[]).length)===1?'':'s'));+ badge.setAttribute('data-tooltip', s.lintPassed+ ? 'All Fusion-360 lint checks passed — symbols, packages and a complete deviceset with pin→pad connects are present.'+ : 'Lint failed — Fusion 360 would reject or mis-import this library. Fix the errors below and re-generate.');++ document.getElementById('counts').innerHTML =+ statHtml(s.symbolCount,'symbols','Schematic symbol definitions in the library (EAGLE layer 94).')++ statHtml(s.packageCount,'packages','PCB footprint/package definitions (pads + silkscreen).')++ statHtml(s.devicesetCount,'devicesets','Devicesets bind a symbol to a package with pin→pad connects — what Fusion actually places.')++ statHtml(s.pinCount,'pins','Total schematic pins across all symbols.')++ statHtml(s.padCount,'pads','Total SMD/through-hole pads across all packages.');++ const errs=s.errors||[], warns=s.warnings||[];+ const eS=document.getElementById('errSection'), wS=document.getElementById('warnSection'), cS=document.getElementById('cleanSection');+ eS.style.display=errs.length?'':'none';+ document.getElementById('errList').innerHTML=errs.map(o=>issueHtml('e',o)).join('');+ wS.style.display=warns.length?'':'none';+ document.getElementById('warnList').innerHTML=warns.map(o=>issueHtml('w',o)).join('');+ cS.style.display=(!errs.length&&!warns.length)?'':'none';+ }+ setInterval(pollState,1000); pollState();++ // ── Send to desktop EDA tools ──+ let toastTimer=null;+ function showToast(msg,kind){ const t=document.getElementById('toast'); t.textContent=msg; t.className='show '+(kind||''); clearTimeout(toastTimer); toastTimer=setTimeout(()=>t.className=t.className.replace('show','').trim(),4200); }+ document.getElementById('sendto').addEventListener('click',async e=>{+ const b=e.target.closest('.sbtn'); if(!b) return;+ const target=b.dataset.target, label=b.textContent;+ const all=[...document.querySelectorAll('.sbtn')]; all.forEach(x=>x.disabled=true);+ showToast('Sending to '+label+'…');+ try{+ const r=await fetch('deliver',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({target})});+ const j=await r.json();+ if(j.needsBridge){ showBridgeModal(j); }+ else if(j.ok){ showToast('✓ '+(j.message||('Delivered to '+label)),'ok'); }+ else { showToast('✕ '+(j.message||j.error||('Could not deliver to '+label)),'err'); }+ }catch(err){ showToast('✕ Delivery failed: '+err,'err'); }+ finally{ all.forEach(x=>x.disabled=false); }+ });+ function showBridgeModal(j){+ let m=document.getElementById('bridgeModal');+ if(!m){ m=document.createElement('div'); m.id='bridgeModal'; document.body.appendChild(m); }+ m.innerHTML='<div class="bm-card"><div class="bm-icon">🌉</div>'+ +'<div class="bm-title">Build the '+(j.target||'')+' bridge</div>'+ +'<div class="bm-body">'+(j.message||'')+'</div>'+ +'<div class="bm-actions"><a class="bm-go" href="'+(j.contribute||'https://wiki.adom.inc')+'" target="_blank" rel="noopener">Contribute on the wiki →</a>'+ +'<button class="bm-dismiss">Got it</button></div></div>';+ m.classList.add('show');+ m.querySelector('.bm-dismiss').onclick=()=>m.classList.remove('show');+ m.onclick=e=>{if(e.target===m)m.classList.remove('show')};+ }++ async function evalLoop(){+ try{+ const r=await fetch('eval/pending',{cache:'no-store'}); const j=await r.json();+ if(j&&j.id){+ let result, err=null;+ try{ result=await (async()=>{ return eval(j.code); })(); }catch(e){ err=String(e&&e.stack||e); }+ await fetch('eval/'+j.id+'/result',{method:'POST',headers:{'Content-Type':'application/json'},+ body:JSON.stringify({result: err?undefined:safe(result), error:err})});+ }+ }catch(e){}+ }+ function safe(v){ try{ JSON.stringify(v); return v; }catch{ return String(v); } }+ setInterval(evalLoop,400); evalLoop();+ </script>+</body>+</html>
src/deliver.rsadded+103@@ -0,0 +1,103 @@+//! "Send to" delivery — push the loaded `.lbr` to the user's desktop EDA tools+//! via the adom-desktop bridge. The EAGLE-family tools (Fusion 360 / Altium /+//! OrCAD) import the `.lbr` directly. KiCad doesn't read EAGLE libraries, so we+//! convert it to a native `.kicad_sym` first (the same import path as the CLI).+//! Best-effort: a clear message when the bridge is offline.++use serde_json::{json, Value};+use std::process::Command;++fn ad(verb: &str, payload: Value) -> Result<Value, String> {+ let out = Command::new("adom-desktop")+ .args([verb, &payload.to_string()])+ .output()+ .map_err(|e| format!("adom-desktop unreachable: {e}"))?;+ let txt = String::from_utf8_lossy(&out.stdout).to_string();+ Ok(serde_json::from_str::<Value>(&txt).unwrap_or_else(|_| json!({ "ok": out.status.success(), "raw": txt })))+}++fn looks_offline(v: &Value) -> bool {+ let s = v.to_string().to_lowercase();+ v.get("bridgeConnected").and_then(|b| b.as_bool()) == Some(false)+ || s.contains("not connected")+ || s.contains("no client")+ || s.contains("offline")+ || s.contains("timeout")+}++/// Deliver the `.lbr` to `target` ("kicad" | "fusion360" | "altium" | "orcad").+pub fn deliver(library_name: &str, lbr: &str, target: &str) -> Value {+ if lbr.is_empty() {+ return json!({ "ok": false, "error": "no library loaded" });+ }+ let dir = std::env::temp_dir().join(format!("adom-lbr-deliver-{}", std::process::id()));+ let _ = std::fs::create_dir_all(&dir);+ let safe = library_name.replace(['/', ' '], "_");++ // KiCad: convert the EAGLE library to a native .kicad_sym.+ if target == "kicad" {+ let parsed = match crate::parse_eagle_lbr(lbr) {+ Ok(p) => p,+ Err(e) => return json!({ "ok": false, "error": format!("could not convert .lbr for KiCad: {e}") }),+ };+ let kicad_sym = crate::generate_kicad_sym(&parsed);+ let sym_path = dir.join(format!("{safe}.kicad_sym"));+ if std::fs::write(&sym_path, &kicad_sym).is_err() {+ return json!({ "ok": false, "error": "could not write temp symbol" });+ }+ let send = ad("send_files", json!({ "filePaths": [sym_path.to_string_lossy()], "targetApp": "kicad", "destinationFolder": "symbols" }));+ let inst = ad("kicad_install_library", json!({ "libraryPath": sym_path.to_string_lossy(), "libraryName": library_name, "libraryType": "symbol" }));+ return summarize("KiCad", "symbol", &[send, inst]);+ }++ let (tool, app, folder) = match target {+ "fusion360" | "fusion" => ("Fusion 360", "fusion360", "fusion"),+ "altium" => ("Altium", "altium", "altium"),+ "orcad" => ("OrCAD", "orcad", "orcad"),+ _ => return json!({ "ok": false, "error": format!("unknown target {target}") }),+ };++ let lbr_path = dir.join(format!("{safe}.lbr"));+ if std::fs::write(&lbr_path, lbr).is_err() {+ return json!({ "ok": false, "error": "could not write temp .lbr" });+ }+ if app == "altium" || app == "orcad" {+ return bridge_not_built(tool, &lbr_path.to_string_lossy());+ }+ let send = ad("send_files", json!({ "filePaths": [lbr_path.to_string_lossy()], "targetApp": app, "destinationFolder": folder }));+ let win = send.as_ref().ok()+ .and_then(|s| s.get("destinationPaths").and_then(|p| p.as_array()).and_then(|a| a.first()).and_then(|x| x.as_str()).map(String::from))+ .unwrap_or_else(|| lbr_path.to_string_lossy().to_string());+ let mut steps = vec![send];+ if app == "fusion360" {+ steps.push(ad("fusion_open_lbr", json!({ "filePath": win })));+ }+ summarize(tool, "library (.lbr)", &steps)+}++/// Altium / OrCAD have no Adom Desktop bridge yet — the .lbr is ready, but invite+/// the user to build the bridge and contribute it on the wiki.+fn bridge_not_built(tool: &str, file: &str) -> Value {+ json!({+ "ok": false,+ "needsBridge": true,+ "target": tool,+ "file": file,+ "contribute": "https://wiki.adom.inc/adom/adom-desktop",+ "message": format!("Your EAGLE .lbr for {tool} is built and saved — but the {tool} ↔ Adom Desktop bridge isn't built yet. Want {tool} connectivity in the Adom ecosystem? Be the one to build the bridge and contribute it on the wiki.")+ })+}++fn summarize(tool: &str, what: &str, results: &[Result<Value, String>]) -> Value {+ for r in results {+ match r {+ Err(e) => return json!({ "ok": false, "target": tool, "error": e, "offline": e.contains("unreachable") }),+ Ok(v) if looks_offline(v) => {+ return json!({ "ok": false, "target": tool, "offline": true,+ "message": format!("File generated, but Adom Desktop isn't connected to {tool}. Open Adom Desktop on your laptop and try again.") });+ }+ _ => {}+ }+ }+ json!({ "ok": true, "target": tool, "message": format!("Delivered {what} to {tool}.") })+}
src/main.rsadded+999@@ -0,0 +1,999 @@+mod deliver;+mod server;++use clap::{Parser, Subcommand};+use serde::{Deserialize, Serialize};+use std::fs;+use std::path::PathBuf;+use std::process;++const VERSION: &str = include_str!("../VERSION");+// SAFETY NET — install subcommand fetches from wiki first; this is the offline fallback+const SKILL: &str = include_str!("../SKILL.md");++#[derive(Parser)]+#[command(name = "adom-lbr", version = VERSION.trim(), about = "EAGLE .lbr generator, linter, and validator for Fusion 360 Electronics")]+struct Cli {+ #[command(subcommand)]+ command: Commands,+}++#[derive(Subcommand)]+enum Commands {+ /// Generate a .lbr from a KiCad .kicad_sym (and optional .kicad_mod).+ /// If --sym is omitted but --fp is given, a symbol is synthesized from the+ /// footprint pads so footprint-only parts still form a placeable deviceset.+ Generate {+ /// Path to the .kicad_sym file (optional when --fp is given)+ #[arg(long)]+ sym: Option<PathBuf>,+ /// Path to the .kicad_mod footprint file (optional — generates a placeholder if omitted)+ #[arg(long)]+ fp: Option<PathBuf>,+ /// Output .lbr file path (default: <name>.lbr in the same directory)+ #[arg(short, long)]+ output: Option<PathBuf>,+ /// Symbol name override+ #[arg(long)]+ name: Option<String>,+ },+ /// Lint an existing .lbr file for Fusion 360 compatibility+ Lint {+ /// Path to the .lbr file+ path: PathBuf,+ },+ /// Validate a .lbr file has complete deviceset (symbol + package + connects)+ Validate {+ /// Path to the .lbr file+ path: PathBuf,+ },+ /// Import an EAGLE .lbr and convert to KiCad .kicad_sym+ Import {+ /// Path to the .lbr file+ path: PathBuf,+ /// Output .kicad_sym file path (default: <name>.kicad_sym)+ #[arg(short, long)]+ output: Option<PathBuf>,+ },+ /// Verify a .kicad_sym via service-kicad (headless KiCad validation)+ Check {+ /// Path to the .kicad_sym file+ path: PathBuf,+ },+ /// Run the live app server (AI-drivable: /state, /load, /eval, /console)+ Serve {+ /// Port to listen on+ #[arg(long, default_value_t = 8784)]+ port: u16,+ /// Optional .lbr to load into the view on startup+ #[arg(long)]+ file: Option<PathBuf>,+ },+ /// Install skill + bash completions+ Install,+ /// Check health+ Health,+ /// Generate shell completions+ Completions {+ #[arg(value_enum)]+ shell: clap_complete::Shell,+ },+}++// ── KiCad .kicad_sym parser ──++#[derive(Debug, Serialize, Deserialize)]+struct KicadPin {+ name: String,+ number: String,+ pin_type: String,+ x: f64,+ y: f64,+ angle: u32,+ length: f64,+}++#[derive(Debug, Serialize, Deserialize)]+struct KicadBody {+ left: f64,+ top: f64,+ right: f64,+ bottom: f64,+}++#[derive(Debug, Serialize, Deserialize)]+struct GroupLabel {+ text: String,+ x: f64,+ y: f64,+}++#[derive(Debug, Serialize, Deserialize)]+struct ParsedSymbol {+ name: String,+ reference: String,+ value: String,+ footprint: String,+ description: String,+ pins: Vec<KicadPin>,+ body: Option<KicadBody>,+ group_labels: Vec<GroupLabel>,+}++fn parse_kicad_sym(content: &str, name_override: Option<&str>) -> Result<ParsedSymbol, String> {+ let name = if let Some(n) = name_override {+ n.to_string()+ } else {+ let re = regex_lite::Regex::new(r#"\(symbol "([^"]+)""#).unwrap();+ re.captures(content)+ .and_then(|c| c.get(1))+ .map(|m| m.as_str().to_string())+ .ok_or("Cannot find symbol name in .kicad_sym")?+ };++ let mut pins = Vec::new();+ // The pin angle may be an int (`180`) or a float (`180.0`) depending on the+ // KiCad version that wrote the symbol; accept both. The shape/electrical+ // tokens may also be hyphenated (e.g. `power_in`, `no_connect`).+ let pin_re = regex_lite::Regex::new(+ r#"(?s)\(pin\s+([\w-]+)\s+[\w-]+\s+\(at\s+([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)\)\s+\(length\s+([-\d.]+)\).*?\(name\s+"([^"]*)".*?\(number\s+"([^"]*)""#+ ).unwrap();++ for cap in pin_re.captures_iter(content) {+ pins.push(KicadPin {+ pin_type: cap[1].to_string(),+ x: cap[2].parse().unwrap_or(0.0),+ y: cap[3].parse().unwrap_or(0.0),+ angle: cap[4].parse::<f64>().unwrap_or(0.0).round() as u32,+ length: cap[5].parse().unwrap_or(2.54),+ name: cap[6].to_string(),+ number: cap[7].to_string(),+ });+ }++ let body = {+ let rect_re = regex_lite::Regex::new(+ r#"\(rectangle\s+\(start\s+([-\d.]+)\s+([-\d.]+)\)\s+\(end\s+([-\d.]+)\s+([-\d.]+)\)"#+ ).unwrap();+ rect_re.captures(content).map(|c| KicadBody {+ left: c[1].parse().unwrap_or(0.0),+ top: c[2].parse().unwrap_or(0.0),+ right: c[3].parse().unwrap_or(0.0),+ bottom: c[4].parse().unwrap_or(0.0),+ })+ };++ let mut group_labels = Vec::new();+ let text_re = regex_lite::Regex::new(+ r#"\(text "([^"]+)"\s+\(at\s+([-\d.]+)\s+([-\d.]+)"#+ ).unwrap();+ for cap in text_re.captures_iter(content) {+ group_labels.push(GroupLabel {+ text: cap[1].to_string(),+ x: cap[2].parse().unwrap_or(0.0),+ y: cap[3].parse().unwrap_or(0.0),+ });+ }++ let prop = |key: &str| -> String {+ let re = regex_lite::Regex::new(&format!(r#"\(property\s+"{}"\s+"([^"]*)""#, key)).unwrap();+ re.captures(content)+ .and_then(|c| c.get(1))+ .map(|m| m.as_str().to_string())+ .unwrap_or_default()+ };++ Ok(ParsedSymbol {+ name,+ reference: prop("Reference"),+ value: prop("Value"),+ footprint: prop("Footprint"),+ description: prop("Description"),+ pins,+ body,+ group_labels,+ })+}++// ── EAGLE .lbr parser (import) ──++fn parse_eagle_lbr(content: &str) -> Result<ParsedSymbol, String> {+ let pin_re = regex_lite::Regex::new(+ r#"<pin name="([^"]+)" x="([^"]+)" y="([^"]+)" length="([^"]+)" direction="([^"]+)"([^/]*)/>"#+ ).unwrap();++ let sym_name_re = regex_lite::Regex::new(r#"<symbol name="([^"]+)">"#).unwrap();+ let name = sym_name_re.captures(content)+ .and_then(|c| c.get(1))+ .map(|m| m.as_str().to_string())+ .ok_or("Cannot find <symbol name=\"...\"> in .lbr")?;++ let desc_re = regex_lite::Regex::new(r#"<description>([^<]*)</description>"#).unwrap();+ let description = desc_re.captures(content)+ .and_then(|c| c.get(1))+ .map(|m| m.as_str().to_string())+ .unwrap_or_default()+ .replace("&", "&").replace("<", "<").replace(">", ">")+ .replace(""", "\"").replace("'", "'");++ let prefix_re = regex_lite::Regex::new(r#"<deviceset[^>]*prefix="([^"]+)""#).unwrap();+ let reference = prefix_re.captures(content)+ .and_then(|c| c.get(1))+ .map(|m| m.as_str().to_string())+ .unwrap_or_else(|| "U".to_string());++ let rect_re = regex_lite::Regex::new(+ r#"<wire x1="([^"]+)" y1="([^"]+)" x2="([^"]+)" y2="([^"]+)" width="[^"]*" layer="94"/>"#+ ).unwrap();++ let mut min_x = f64::MAX;+ let mut min_y = f64::MAX;+ let mut max_x = f64::MIN;+ let mut max_y = f64::MIN;+ let mut has_body = false;+ for cap in rect_re.captures_iter(content) {+ has_body = true;+ let x1: f64 = cap[1].parse().unwrap_or(0.0);+ let y1: f64 = cap[2].parse().unwrap_or(0.0);+ let x2: f64 = cap[3].parse().unwrap_or(0.0);+ let y2: f64 = cap[4].parse().unwrap_or(0.0);+ min_x = min_x.min(x1).min(x2);+ min_y = min_y.min(y1).min(y2);+ max_x = max_x.max(x1).max(x2);+ max_y = max_y.max(y1).max(y2);+ }++ let body = if has_body {+ Some(KicadBody { left: min_x, top: max_y, right: max_x, bottom: min_y })+ } else {+ None+ };++ let connect_re = regex_lite::Regex::new(+ r#"<connect gate="[^"]*" pin="([^"]+)" pad="([^"]+)"/>"#+ ).unwrap();+ let mut pin_to_pad: std::collections::HashMap<String, String> = std::collections::HashMap::new();+ for cap in connect_re.captures_iter(content) {+ pin_to_pad.insert(cap[1].to_string(), cap[2].to_string());+ }++ let length_val = |kw: &str| -> f64 {+ match kw {+ "point" => 0.0,+ "short" => 2.54,+ "middle" => 5.08,+ "long" => 7.62,+ _ => 2.54,+ }+ };++ let eagle_to_kicad_dir = |d: &str| -> &'static str {+ match d {+ "pwr" => "power_in",+ "in" => "input",+ "out" => "output",+ "io" => "bidirectional",+ "pas" => "passive",+ "nc" => "unconnected",+ "hiz" => "tri_state",+ "oc" => "open_collector",+ _ => "bidirectional",+ }+ };++ let rot_re = regex_lite::Regex::new(r#"rot="R(\d+)""#).unwrap();++ let mut pins = Vec::new();+ for cap in pin_re.captures_iter(content) {+ let pin_name_raw = cap[1].to_string();+ let pin_name = pin_name_raw.replace("@2", "").replace("@3", "")+ .replace("@4", "").replace("@5", "").replace("@6", "")+ .replace("@7", "").replace("@8", "").replace("@9", "");++ let pad_number = pin_to_pad.get(&pin_name_raw)+ .cloned()+ .unwrap_or_else(|| "0".to_string());++ let angle = rot_re.captures(&cap[6])+ .and_then(|c| c.get(1))+ .and_then(|m| m.as_str().parse::<u32>().ok())+ .unwrap_or(0);++ pins.push(KicadPin {+ name: pin_name,+ number: pad_number,+ pin_type: eagle_to_kicad_dir(&cap[5]).to_string(),+ x: cap[2].parse().unwrap_or(0.0),+ y: cap[3].parse().unwrap_or(0.0),+ angle,+ length: length_val(&cap[4]),+ });+ }++ Ok(ParsedSymbol {+ value: name.clone(),+ name,+ reference,+ footprint: String::new(),+ description,+ pins,+ body,+ group_labels: Vec::new(),+ })+}++// ── KiCad .kicad_sym generator ──++fn generate_kicad_sym(sym: &ParsedSymbol) -> String {+ let mut s = String::with_capacity(4096);+ s.push_str("(kicad_symbol_lib\n (version 20231120)\n (generator \"adom-lbr\")\n");+ s.push_str(&format!(" (symbol \"{}\"\n", sym.name));+ s.push_str(" (pin_names (offset 1.016))\n");+ s.push_str(" (exclude_from_sim no)\n");+ s.push_str(" (in_bom yes)\n (on_board yes)\n");++ s.push_str(&format!(" (property \"Reference\" \"{}\"\n (at 0 2.54 0)\n (effects (font (size 1.27 1.27)))\n )\n", sym.reference));+ s.push_str(&format!(" (property \"Value\" \"{}\"\n (at 0 -2.54 0)\n (effects (font (size 1.27 1.27)))\n )\n", sym.name));+ s.push_str(&format!(" (property \"Footprint\" \"{}\"\n (at 0 0 0)\n (effects (font (size 1.27 1.27)) (hide yes))\n )\n",+ sym.footprint.replace('"', "\\\"")+ ));+ s.push_str(" (property \"Datasheet\" \"\"\n (at 0 0 0)\n (effects (font (size 1.27 1.27)) (hide yes))\n )\n");+ if !sym.description.is_empty() {+ s.push_str(&format!(" (property \"Description\" \"{}\"\n (at 0 0 0)\n (effects (font (size 1.27 1.27)) (hide yes))\n )\n",+ sym.description.replace('"', "\\\"")+ ));+ }++ s.push_str(&format!(" (symbol \"{}_0_{}\"\n", sym.name, 1));++ if let Some(body) = &sym.body {+ s.push_str(&format!(" (rectangle (start {} {}) (end {} {})\n (stroke (width 0.254) (type default))\n (fill (type background))\n )\n",+ body.left, body.top, body.right, body.bottom+ ));+ }++ for pin in &sym.pins {+ let kicad_angle = match pin.angle {+ 0 => 0,+ 90 => 90,+ 180 => 180,+ 270 => 270,+ _ => 0,+ };+ s.push_str(&format!(+ " (pin {} line (at {} {} {}) (length {}) (name \"{}\") (number \"{}\"))\n",+ pin.pin_type, pin.x, pin.y, kicad_angle, pin.length,+ pin.name.replace('"', "\\\""),+ pin.number.replace('"', "\\\"")+ ));+ }++ s.push_str(" )\n");+ s.push_str(" )\n)\n");+ s+}++// ── EAGLE .lbr generator ──++fn esc_xml(s: &str) -> String {+ s.replace('&', "&")+ .replace('<', "<")+ .replace('>', ">")+ .replace('"', """)+ .replace('\'', "'")+}++fn pin_direction(kicad_type: &str) -> &'static str {+ match kicad_type {+ "power_in" => "pwr",+ "power_out" => "out",+ "input" => "in",+ "output" => "out",+ "bidirectional" => "io",+ "passive" => "pas",+ "unconnected" => "nc",+ "tri_state" => "hiz",+ "open_collector" | "open_emitter" => "oc",+ _ => "io",+ }+}++fn pin_rotation(angle: u32) -> &'static str {+ match angle {+ 180 => " rot=\"R180\"",+ 90 => " rot=\"R90\"",+ 270 => " rot=\"R270\"",+ _ => "",+ }+}++fn pin_length_keyword(len: f64) -> &'static str {+ if len <= 0.0 { "point" }+ else if len <= 2.54 { "short" }+ else if len <= 5.08 { "middle" }+ else { "long" }+}++fn dedup_pin_names(pins: &[KicadPin]) -> Vec<String> {+ let mut counts: std::collections::HashMap<&str, u32> = std::collections::HashMap::new();+ for pin in pins {+ *counts.entry(&pin.name).or_insert(0) += 1;+ }+ let mut seen: std::collections::HashMap<&str, u32> = std::collections::HashMap::new();+ pins.iter().map(|pin| {+ let total = counts[pin.name.as_str()];+ if total == 1 {+ pin.name.clone()+ } else {+ let n = seen.entry(&pin.name).or_insert(0);+ *n += 1;+ if *n == 1 {+ pin.name.clone()+ } else {+ format!("{}@{}", pin.name, n)+ }+ }+ }).collect()+}++/// The first `(footprint "NAME" …)` name in a `.kicad_mod`, if any.+fn first_footprint_name(km: &str) -> Option<String> {+ let re = regex_lite::Regex::new(r#"\(footprint\s+"([^"]+)""#).unwrap();+ re.captures(km).and_then(|c| c.get(1)).map(|m| m.as_str().to_string())+}++/// Synthesize a placeable symbol from a `.kicad_mod`'s pads when there is no+/// `.kicad_sym`. One symbol pin per unique pad number, balanced left/right, so+/// the resulting EAGLE deviceset has the correct pin/pad names and connects.+fn synth_symbol_from_mod(km: &str, name: &str) -> ParsedSymbol {+ // Pad numbers in document order, de-duplicated (thru-hole pairs share a no.).+ // Pad ids may be quoted ("A1") or bare (1); skip mechanical pads ("").+ let pad_re = regex_lite::Regex::new(r#"\(pad\s+(?:"([^"]*)"|(\S+))"#).unwrap();+ let mut nums: Vec<String> = Vec::new();+ for c in pad_re.captures_iter(km) {+ let n = c.get(1).or_else(|| c.get(2)).map(|m| m.as_str().to_string()).unwrap_or_default();+ if !n.is_empty() && !nums.contains(&n) {+ nums.push(n);+ }+ }+ let count = nums.len().max(1);+ let half = (count + 1) / 2;+ let body_h = (half as f64) * 2.54 + 2.54;+ let pins: Vec<KicadPin> = nums.iter().enumerate().map(|(i, num)| {+ let (x, y, angle) = if i < half {+ (-12.7, (half as f64 / 2.0 - i as f64) * 2.54, 0)+ } else {+ (12.7, (half as f64 / 2.0 - (i - half) as f64) * 2.54, 180)+ };+ KicadPin {+ name: num.clone(),+ number: num.clone(),+ pin_type: "passive".into(),+ x, y, angle, length: 2.54,+ }+ }).collect();+ ParsedSymbol {+ name: name.to_string(),+ reference: "U".into(),+ value: name.to_string(),+ footprint: name.to_string(),+ description: format!("{name} (symbol synthesized from footprint pads)"),+ pins,+ body: Some(KicadBody { left: -7.62, top: body_h / 2.0, right: 7.62, bottom: -body_h / 2.0 }),+ group_labels: Vec::new(),+ }+}++fn generate_lbr(sym: &ParsedSymbol, _fp_content: Option<&str>) -> String {+ let mut xml = String::with_capacity(8192);++ xml.push_str("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n");+ xml.push_str("<!DOCTYPE eagle SYSTEM \"eagle.dtd\">\n");+ xml.push_str("<eagle version=\"9.6.2\">\n<drawing>\n");+ xml.push_str("<settings><setting alwaysvectorfont=\"no\"/></settings>\n");+ xml.push_str("<grid distance=\"0.1\" unitdist=\"inch\" unit=\"inch\" style=\"lines\" multiple=\"1\" display=\"no\" altdistance=\"0.01\" altunitdist=\"inch\" altunit=\"inch\"/>\n");++ // Layers+ xml.push_str(EAGLE_LAYERS);++ xml.push_str("<library>\n");+ xml.push_str(&format!("<description>{}</description>\n", esc_xml(&sym.description)));++ // Packages section — generate a placeholder SMD package from pin positions+ xml.push_str("<packages>\n");+ xml.push_str(&format!("<package name=\"{}\">\n", esc_xml(&sym.name)));+ xml.push_str(&format!(" <description>{} placeholder footprint</description>\n", esc_xml(&sym.name)));++ // Generate SMD pads from pin numbers+ let pad_size = 0.5;+ let pitch = 0.5;+ let pin_count = sym.pins.len();+ let half = (pin_count + 1) / 2;++ for (i, pin) in sym.pins.iter().enumerate() {+ let (px, py) = if i < half {+ // Left column+ (-(pin_count as f64 * pitch / 4.0), (half as f64 / 2.0 - i as f64) * pitch)+ } else {+ // Right column+ (pin_count as f64 * pitch / 4.0, (half as f64 / 2.0 - (i - half) as f64) * pitch)+ };+ xml.push_str(&format!(+ " <smd name=\"{}\" x=\"{:.3}\" y=\"{:.3}\" dx=\"{:.2}\" dy=\"{:.2}\" layer=\"1\"/>\n",+ esc_xml(&pin.number), px, py, pad_size, pad_size+ ));+ }++ // Package outline+ let body_w = pin_count as f64 * pitch / 2.0 + 1.0;+ let body_h = half as f64 * pitch + 1.0;+ xml.push_str(&format!(+ " <wire x1=\"{:.3}\" y1=\"{:.3}\" x2=\"{:.3}\" y2=\"{:.3}\" width=\"0.127\" layer=\"21\"/>\n",+ -body_w / 2.0, body_h / 2.0, body_w / 2.0, body_h / 2.0+ ));+ xml.push_str(&format!(+ " <wire x1=\"{:.3}\" y1=\"{:.3}\" x2=\"{:.3}\" y2=\"{:.3}\" width=\"0.127\" layer=\"21\"/>\n",+ body_w / 2.0, body_h / 2.0, body_w / 2.0, -body_h / 2.0+ ));+ xml.push_str(&format!(+ " <wire x1=\"{:.3}\" y1=\"{:.3}\" x2=\"{:.3}\" y2=\"{:.3}\" width=\"0.127\" layer=\"21\"/>\n",+ body_w / 2.0, -body_h / 2.0, -body_w / 2.0, -body_h / 2.0+ ));+ xml.push_str(&format!(+ " <wire x1=\"{:.3}\" y1=\"{:.3}\" x2=\"{:.3}\" y2=\"{:.3}\" width=\"0.127\" layer=\"21\"/>\n",+ -body_w / 2.0, -body_h / 2.0, -body_w / 2.0, body_h / 2.0+ ));++ xml.push_str(&format!(" <text x=\"0\" y=\"{:.3}\" size=\"1.27\" layer=\"25\" align=\"bottom-center\">>NAME</text>\n", body_h / 2.0 + 0.5));+ xml.push_str(&format!(" <text x=\"0\" y=\"{:.3}\" size=\"1.27\" layer=\"27\" align=\"top-center\">>VALUE</text>\n", -body_h / 2.0 - 0.5));++ xml.push_str("</package>\n");+ xml.push_str("</packages>\n");++ // Symbols section+ xml.push_str("<symbols>\n");+ xml.push_str(&format!("<symbol name=\"{}\">\n", esc_xml(&sym.name)));++ if let Some(body) = &sym.body {+ xml.push_str(&format!(" <wire x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\" width=\"0.254\" layer=\"94\"/>\n", body.left, body.top, body.right, body.top));+ xml.push_str(&format!(" <wire x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\" width=\"0.254\" layer=\"94\"/>\n", body.right, body.top, body.right, body.bottom));+ xml.push_str(&format!(" <wire x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\" width=\"0.254\" layer=\"94\"/>\n", body.right, body.bottom, body.left, body.bottom));+ xml.push_str(&format!(" <wire x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\" width=\"0.254\" layer=\"94\"/>\n", body.left, body.bottom, body.left, body.top));+ }++ let name_y = sym.body.as_ref().map(|b| b.top + 2.54).unwrap_or(10.0);+ let value_y = sym.body.as_ref().map(|b| b.bottom - 2.54).unwrap_or(-10.0);+ xml.push_str(&format!(" <text x=\"0\" y=\"{}\" size=\"1.27\" layer=\"95\" align=\"bottom-center\">>NAME</text>\n", name_y));+ xml.push_str(&format!(" <text x=\"0\" y=\"{}\" size=\"1.27\" layer=\"96\" align=\"top-center\">>VALUE</text>\n", value_y));++ let deduped_names = dedup_pin_names(&sym.pins);+ for (i, pin) in sym.pins.iter().enumerate() {+ xml.push_str(&format!(+ " <pin name=\"{}\" x=\"{}\" y=\"{}\" length=\"{}\" direction=\"{}\"{}/>\n",+ esc_xml(&deduped_names[i]), pin.x, pin.y,+ pin_length_keyword(pin.length),+ pin_direction(&pin.pin_type),+ pin_rotation(pin.angle)+ ));+ }++ let body_center_x = sym.body.as_ref().map(|b| (b.left + b.right) / 2.0).unwrap_or(0.0);+ let body_left = sym.body.as_ref().map(|b| b.left).unwrap_or(-10.0);+ let body_right = sym.body.as_ref().map(|b| b.right).unwrap_or(10.0);+ for label in &sym.group_labels {+ let (lx, align) = if label.x > body_center_x {+ (body_right - 1.0, " align=\"right\"")+ } else {+ (body_left + 1.0, "")+ };+ xml.push_str(&format!(+ " <text x=\"{}\" y=\"{}\" size=\"0.762\" layer=\"94\" font=\"vector\" ratio=\"15\"{}>{}</text>\n",+ lx, label.y, align, esc_xml(&label.text)+ ));+ }++ xml.push_str("</symbol>\n");+ xml.push_str("</symbols>\n");++ // Deviceset with COMPLETE device (package + connects)+ let ref_prefix = if sym.reference.is_empty() { "U" } else { &sym.reference };+ xml.push_str("<devicesets>\n");+ xml.push_str(&format!("<deviceset name=\"{}\" prefix=\"{}\">\n", esc_xml(&sym.name), esc_xml(ref_prefix)));+ xml.push_str(&format!(" <description>{}</description>\n", esc_xml(&sym.description)));+ xml.push_str(" <gates>\n");+ xml.push_str(&format!(" <gate name=\"G$1\" symbol=\"{}\" x=\"0\" y=\"0\"/>\n", esc_xml(&sym.name)));+ xml.push_str(" </gates>\n");+ xml.push_str(" <devices>\n");+ xml.push_str(&format!(" <device name=\"\" package=\"{}\">\n", esc_xml(&sym.name)));+ xml.push_str(" <connects>\n");++ // Generate connect mappings: gate pin name → pad number+ for (i, pin) in sym.pins.iter().enumerate() {+ xml.push_str(&format!(+ " <connect gate=\"G$1\" pin=\"{}\" pad=\"{}\"/>\n",+ esc_xml(&deduped_names[i]), esc_xml(&pin.number)+ ));+ }++ xml.push_str(" </connects>\n");+ xml.push_str(" <technologies><technology name=\"\"/></technologies>\n");+ xml.push_str(" </device>\n");+ xml.push_str(" </devices>\n");+ xml.push_str("</deviceset>\n");+ xml.push_str("</devicesets>\n");++ xml.push_str("</library>\n</drawing>\n</eagle>");+ xml+}++// ── Linter ──++#[derive(Debug, Serialize)]+pub(crate) struct LintResult {+ pub passed: bool,+ pub errors: Vec<LintError>,+ pub warnings: Vec<LintError>,+}++#[derive(Debug, Serialize)]+pub(crate) struct LintError {+ pub code: String,+ pub message: String,+}++pub(crate) fn lint_lbr(content: &str) -> LintResult {+ let mut errors = Vec::new();+ let mut warnings = Vec::new();++ if !content.contains("<packages>") || !content.contains("</packages>") {+ errors.push(LintError { code: "missing-packages".into(), message: "No <packages> section found".into() });+ }+ if !content.contains("<symbols>") || !content.contains("</symbols>") {+ errors.push(LintError { code: "missing-symbols".into(), message: "No <symbols> section found".into() });+ }+ if !content.contains("<devicesets>") || !content.contains("</devicesets>") {+ errors.push(LintError { code: "missing-devicesets".into(), message: "No <devicesets> section found".into() });+ }++ // Check for empty packages+ if content.contains("<packages></packages>") || content.contains("<packages/>") {+ errors.push(LintError { code: "empty-packages".into(), message: "Packages section is empty — Fusion will show 'No Components'".into() });+ }++ // Check for empty connects+ if content.contains("<connects/>") || content.contains("<connects></connects>") {+ errors.push(LintError { code: "empty-connects".into(), message: "Device has no pin-to-pad connect mappings — Fusion cannot use this component".into() });+ }++ // Check device has a package attribute+ if content.contains("<device name=\"\">") && !content.contains("package=") {+ errors.push(LintError { code: "device-no-package".into(), message: "Device has no package attribute — Fusion needs a package to show the component".into() });+ }++ // Check for duplicate pin names (EAGLE requires unique)+ let pin_re = regex_lite::Regex::new(r#"<pin name="([^"]+)""#).unwrap();+ let mut pin_names = std::collections::HashMap::new();+ for cap in pin_re.captures_iter(content) {+ *pin_names.entry(cap[1].to_string()).or_insert(0u32) += 1;+ }+ for (name, count) in &pin_names {+ if *count > 1 {+ errors.push(LintError {+ code: "duplicate-pin-name".into(),+ message: format!("Pin name '{}' appears {} times — EAGLE requires unique pin names (use @suffix for duplicates)", name, count),+ });+ }+ }++ // Check pad names match pin numbers in connects+ let connect_re = regex_lite::Regex::new(r#"<connect[^>]*pad="([^"]+)""#).unwrap();+ let smd_re = regex_lite::Regex::new(r#"<smd name="([^"]+)""#).unwrap();+ let pad_names: std::collections::HashSet<_> = smd_re.captures_iter(content).map(|c| c[1].to_string()).collect();+ for cap in connect_re.captures_iter(content) {+ let pad = &cap[1];+ if !pad_names.contains(pad) && !pad_names.is_empty() {+ warnings.push(LintError {+ code: "connect-pad-missing".into(),+ message: format!("Connect references pad '{}' which doesn't exist in the package", pad),+ });+ }+ }++ // Check XML is well-formed+ if content.matches('<').count() != content.matches('>').count() {+ errors.push(LintError { code: "malformed-xml".into(), message: "Unbalanced XML angle brackets".into() });+ }++ // Fusion-specific checks+ if !content.contains("layer=\"94\"") {+ warnings.push(LintError { code: "no-symbol-layer".into(), message: "No elements on layer 94 (Symbols) — the symbol body may be invisible".into() });+ }++ LintResult {+ passed: errors.is_empty(),+ errors,+ warnings,+ }+}++// ── Layers constant ──++const EAGLE_LAYERS: &str = r#"<layers>+<layer number="1" name="Top" color="4" fill="1" visible="yes" active="yes"/>+<layer number="16" name="Bottom" color="1" fill="1" visible="yes" active="yes"/>+<layer number="17" name="Pads" color="2" fill="1" visible="yes" active="yes"/>+<layer number="18" name="Vias" color="2" fill="1" visible="yes" active="yes"/>+<layer number="19" name="Unrouted" color="6" fill="1" visible="yes" active="yes"/>+<layer number="20" name="Dimension" color="15" fill="1" visible="yes" active="yes"/>+<layer number="21" name="tPlace" color="7" fill="1" visible="yes" active="yes"/>+<layer number="22" name="bPlace" color="7" fill="1" visible="yes" active="yes"/>+<layer number="23" name="tOrigins" color="15" fill="1" visible="yes" active="yes"/>+<layer number="24" name="bOrigins" color="15" fill="1" visible="yes" active="yes"/>+<layer number="25" name="tNames" color="7" fill="1" visible="yes" active="yes"/>+<layer number="26" name="bNames" color="7" fill="1" visible="yes" active="yes"/>+<layer number="27" name="tValues" color="7" fill="1" visible="yes" active="yes"/>+<layer number="28" name="bValues" color="7" fill="1" visible="yes" active="yes"/>+<layer number="29" name="tStop" color="7" fill="3" visible="no" active="yes"/>+<layer number="30" name="bStop" color="7" fill="6" visible="no" active="yes"/>+<layer number="31" name="tCream" color="7" fill="4" visible="no" active="yes"/>+<layer number="32" name="bCream" color="7" fill="5" visible="no" active="yes"/>+<layer number="33" name="tFinish" color="6" fill="3" visible="no" active="yes"/>+<layer number="34" name="bFinish" color="6" fill="6" visible="no" active="yes"/>+<layer number="35" name="tGlue" color="7" fill="4" visible="no" active="yes"/>+<layer number="36" name="bGlue" color="7" fill="5" visible="no" active="yes"/>+<layer number="37" name="tTest" color="7" fill="1" visible="no" active="yes"/>+<layer number="38" name="bTest" color="7" fill="1" visible="no" active="yes"/>+<layer number="39" name="tKeepout" color="4" fill="11" visible="no" active="yes"/>+<layer number="40" name="bKeepout" color="1" fill="11" visible="no" active="yes"/>+<layer number="41" name="tRestrict" color="4" fill="10" visible="no" active="yes"/>+<layer number="42" name="bRestrict" color="1" fill="10" visible="no" active="yes"/>+<layer number="43" name="vRestrict" color="2" fill="10" visible="no" active="yes"/>+<layer number="44" name="Drills" color="7" fill="1" visible="no" active="yes"/>+<layer number="45" name="Holes" color="7" fill="1" visible="no" active="yes"/>+<layer number="46" name="Milling" color="3" fill="1" visible="no" active="yes"/>+<layer number="47" name="Measures" color="7" fill="1" visible="no" active="yes"/>+<layer number="48" name="Document" color="7" fill="1" visible="yes" active="yes"/>+<layer number="49" name="Reference" color="7" fill="1" visible="yes" active="yes"/>+<layer number="91" name="Nets" color="2" fill="1" visible="yes" active="yes"/>+<layer number="92" name="Busses" color="1" fill="1" visible="yes" active="yes"/>+<layer number="93" name="Pins" color="2" fill="1" visible="no" active="yes"/>+<layer number="94" name="Symbols" color="4" fill="1" visible="yes" active="yes"/>+<layer number="95" name="Names" color="7" fill="1" visible="yes" active="yes"/>+<layer number="96" name="Values" color="7" fill="1" visible="yes" active="yes"/>+<layer number="97" name="Info" color="7" fill="1" visible="yes" active="yes"/>+<layer number="98" name="Guide" color="6" fill="1" visible="yes" active="yes"/>+</layers>+"#;++// ── Main ──++fn main() {+ let cli = Cli::parse();++ match cli.command {+ Commands::Generate { sym, fp, output, name } => {+ let fp_content = fp.as_ref().map(|p| {+ fs::read_to_string(p).unwrap_or_else(|e| {+ eprintln!("ERROR: Cannot read footprint {}: {}", p.display(), e);+ process::exit(1);+ })+ });++ let parsed = match (&sym, &fp_content) {+ (Some(sym_path), _) => {+ let sym_content = fs::read_to_string(sym_path).unwrap_or_else(|e| {+ eprintln!("ERROR: Cannot read {}: {}", sym_path.display(), e);+ eprintln!("Hint: Check the .kicad_sym path exists.");+ process::exit(1);+ });+ parse_kicad_sym(&sym_content, name.as_deref()).unwrap_or_else(|e| {+ eprintln!("ERROR: {}", e);+ process::exit(1);+ })+ }+ (None, Some(fp)) => {+ // Footprint-only: synthesize a placeable symbol from the pads.+ let syn_name = name.clone()+ .or_else(|| first_footprint_name(fp))+ .unwrap_or_else(|| "FOOTPRINT".to_string());+ synth_symbol_from_mod(fp, &syn_name)+ }+ (None, None) => {+ eprintln!("ERROR: Generate needs at least --sym or --fp.");+ eprintln!("Hint: `adom-lbr generate --fp part.kicad_mod` synthesizes a symbol from the pads.");+ process::exit(1);+ }+ };++ let out_default = sym.as_ref().or(fp.as_ref()).cloned().unwrap_or_else(|| PathBuf::from(&parsed.name));+ let lbr = generate_lbr(&parsed, fp_content.as_deref());++ // Lint before writing+ let lint = lint_lbr(&lbr);+ for err in &lint.errors {+ eprintln!("LINT ERROR: [{}] {}", err.code, err.message);+ }+ for warn in &lint.warnings {+ eprintln!("LINT WARN: [{}] {}", warn.code, warn.message);+ }++ let out_path = output.unwrap_or_else(|| {+ out_default.with_extension("lbr")+ });++ fs::write(&out_path, &lbr).unwrap_or_else(|e| {+ eprintln!("ERROR: Cannot write {}: {}", out_path.display(), e);+ process::exit(1);+ });++ println!("OK: Generated {} ({} pins, {} bytes)", out_path.display(), parsed.pins.len(), lbr.len());+ println!("HINT: Lint {} with {} error(s), {} warning(s)", if lint.passed { "PASSED" } else { "FAILED" }, lint.errors.len(), lint.warnings.len());++ if !lint.passed {+ process::exit(2);+ }+ }++ Commands::Lint { path } => {+ let content = fs::read_to_string(&path).unwrap_or_else(|e| {+ eprintln!("ERROR: Cannot read {}: {}", path.display(), e);+ eprintln!("Hint: Check the path exists. Use `ls` to verify.");+ process::exit(1);+ });++ let result = lint_lbr(&content);+ if result.passed {+ println!("OK: Lint passed with 0 error(s), {} warning(s)", result.warnings.len());+ } else {+ eprintln!("ERROR: Lint failed with {} error(s)", result.errors.len());+ for err in &result.errors {+ eprintln!(" [{}] {}", err.code, err.message);+ }+ eprintln!("Hint: Fix the errors above, then re-run `adom-lbr lint {}`", path.display());+ }+ for warn in &result.warnings {+ eprintln!("WARN: [{}] {}", warn.code, warn.message);+ }+ // Also output JSON for programmatic consumers+ let json = serde_json::to_string_pretty(&result).unwrap();+ println!("{}", json);++ if !result.passed {+ process::exit(2);+ }+ }++ Commands::Validate { path } => {+ let content = fs::read_to_string(&path).unwrap_or_else(|e| {+ eprintln!("ERROR: Cannot read {}: {}", path.display(), e);+ process::exit(1);+ });++ let result = lint_lbr(&content);+ if result.passed {+ println!("OK: {} is a valid EAGLE .lbr with complete deviceset", path.display());+ } else {+ eprintln!("FAIL: {} has {} error(s)", path.display(), result.errors.len());+ for err in &result.errors {+ eprintln!(" [{}] {}", err.code, err.message);+ }+ process::exit(2);+ }+ }++ Commands::Import { path, output } => {+ let content = fs::read_to_string(&path).unwrap_or_else(|e| {+ eprintln!("ERROR: Cannot read {}: {}", path.display(), e);+ process::exit(1);+ });++ let parsed = parse_eagle_lbr(&content).unwrap_or_else(|e| {+ eprintln!("ERROR: {}", e);+ process::exit(1);+ });++ let kicad_sym = generate_kicad_sym(&parsed);++ let out_path = output.unwrap_or_else(|| {+ path.with_extension("kicad_sym")+ });++ fs::write(&out_path, &kicad_sym).unwrap_or_else(|e| {+ eprintln!("ERROR: Cannot write {}: {}", out_path.display(), e);+ process::exit(1);+ });++ println!("OK: Imported {} → {} ({} pins)", path.display(), out_path.display(), parsed.pins.len());+ println!("HINT: Run 'adom-lbr check {}' to validate via service-kicad", out_path.display());+ }++ Commands::Check { path } => {+ let content = fs::read_to_string(&path).unwrap_or_else(|e| {+ eprintln!("ERROR: Cannot read {}: {}", path.display(), e);+ process::exit(1);+ });++ let mut errors: Vec<String> = Vec::new();++ if !content.contains("(kicad_symbol_lib") {+ errors.push("Missing (kicad_symbol_lib) header — not a valid .kicad_sym file".into());+ }+ if !content.contains("(symbol \"") {+ errors.push("No (symbol) definitions found".into());+ }++ let pin_count = content.matches("(pin ").count();+ if pin_count == 0 {+ errors.push("No pins found — symbol has no electrical connections".into());+ }++ if !content.contains("(property \"Reference\"") {+ errors.push("Missing Reference property".into());+ }+ if !content.contains("(property \"Value\"") {+ errors.push("Missing Value property".into());+ }++ // Check for duplicate pin names within each symbol+ let sym_re = regex_lite::Regex::new(r#"\(pin \w+ \w+ \(at [^)]+\) \(length [^)]+\) \(name "([^"]+)""#).unwrap();+ let mut pin_names: std::collections::HashMap<String, usize> = std::collections::HashMap::new();+ for cap in sym_re.captures_iter(&content) {+ *pin_names.entry(cap[1].to_string()).or_insert(0) += 1;+ }+ let dups: Vec<_> = pin_names.iter().filter(|(_, &c)| c > 1).collect();+ if !dups.is_empty() && !dups.iter().all(|(n, _)| n.contains('@') || n.as_str() == "~") {+ let dup_list: Vec<_> = dups.iter().filter(|(n, _)| !n.contains('@') && n.as_str() != "~").map(|(n, c)| format!("{} (x{})", n, c)).collect();+ if !dup_list.is_empty() {+ // Duplicate pin names are valid in KiCad for power pins (DVDD, IOVDD, GND).+ // Warn but don't error — EAGLE import will need @N suffixes.+ eprintln!("WARN: Duplicate pin names (will need @N suffix for EAGLE): {}", dup_list.join(", "));+ }+ }++ if errors.is_empty() {+ println!("OK: {} validated — {} pins, {} unique pin names", path.display(), pin_count, pin_names.len());+ } else {+ eprintln!("ERROR: {} failed validation with {} error(s)", path.display(), errors.len());+ for e in &errors {+ eprintln!(" {}", e);+ }+ eprintln!("Hint: Fix the errors above, then re-run `adom-lbr check {}`", path.display());+ process::exit(2);+ }+ }++ Commands::Serve { port, file } => {+ if let Some(f) = file {+ match fs::read_to_string(&f) {+ Ok(content) => server::set_state(server::analyze_lbr(&content)),+ Err(e) => {+ eprintln!("ERROR: Cannot read {}: {}", f.display(), e);+ process::exit(1);+ }+ }+ }+ if let Err(e) = server::run(port) {+ eprintln!("ERROR: {e:#}");+ process::exit(1);+ }+ }++ Commands::Install => {+ let home = std::env::var("HOME").unwrap_or_else(|_| "/home/adom".to_string());+ let skill_dir = PathBuf::from(&home).join(".claude/skills/adom-lbr");+ fs::create_dir_all(&skill_dir).ok();+ fs::write(skill_dir.join("SKILL.md"), SKILL).ok();+ println!("Installed skill: adom-lbr -> {}", skill_dir.display());+ println!("Installed adom-lbr v{}", VERSION.trim());+ }++ Commands::Health => {+ println!("OK: adom-lbr v{}", VERSION.trim());+ }++ Commands::Completions { shell } => {+ use clap::CommandFactory;+ clap_complete::generate(shell, &mut Cli::command(), "adom-lbr", &mut std::io::stdout());+ }+ }+}
src/server.rsadded+251@@ -0,0 +1,251 @@+//! adom-lbr live app server — the AI-drivable surface (app-creator §7/§7b/§8).+//! Same shell as adom-symbol/adom-footprint: GET / (UI), GET /state (poller),+//! POST /load (push an .lbr — raw XML, re-linted server-side), POST /eval ++//! GET /eval/pending + POST /eval/:id/result + GET /eval/:id, POST/GET /console,+//! POST /shutdown, GET /favicon.svg. No gallia, no Node.++use anyhow::Result;+use serde_json::{json, Value};+use std::collections::{HashMap, VecDeque};+use std::sync::atomic::{AtomicU64, Ordering};+use std::sync::Mutex;+use tiny_http::{Header, Method, Response, Server};++#[derive(Default, Clone)]+pub struct AppState {+ pub library_name: String,+ pub has_content: bool,+ pub lint_passed: bool,+ pub errors: Vec<(String, String)>, // (code, message)+ pub warnings: Vec<(String, String)>, // (code, message)+ pub symbol_count: usize,+ pub package_count: usize,+ pub deviceset_count: usize,+ pub pin_count: usize,+ pub pad_count: usize,+ /// Raw `.lbr` XML retained for "Send to" delivery.+ pub lbr: String,+}++static STATE: Mutex<Option<AppState>> = Mutex::new(None);+static EVAL_PENDING: Mutex<VecDeque<(u64, String)>> = Mutex::new(VecDeque::new());+static EVAL_RESULTS: Mutex<Option<HashMap<u64, Value>>> = Mutex::new(None);+static CONSOLE: Mutex<Option<Vec<String>>> = Mutex::new(None);+static EVAL_ID: AtomicU64 = AtomicU64::new(1);++const APP_HTML: &str = include_str!("app.html");+const FAVICON: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 5.5A1.5 1.5 0 0 1 5.5 4H9a1.5 1.5 0 0 1 1.5 1.5V20a1.5 1.5 0 0 0-1.5-1.5H5.5A1.5 1.5 0 0 1 4 17z"/><path d="M20 5.5A1.5 1.5 0 0 0 18.5 4H15a1.5 1.5 0 0 0-1.5 1.5V20a1.5 1.5 0 0 1 1.5-1.5h3.5a1.5 1.5 0 0 0 1.5-1.5z"/></svg>"##;++pub fn set_state(s: AppState) {+ *STATE.lock().unwrap() = Some(s);+}++/// Parse + lint an `.lbr` (EAGLE XML) into the displayable app state.+pub fn analyze_lbr(content: &str) -> AppState {+ let lint = crate::lint_lbr(content);+ let lib = extract_attr(content, "deviceset")+ .or_else(|| extract_attr(content, "symbol"))+ .or_else(|| extract_attr(content, "package"))+ .unwrap_or_else(|| "library".to_string());+ AppState {+ library_name: lib,+ has_content: true,+ lint_passed: lint.passed,+ errors: lint.errors.iter().map(|e| (e.code.clone(), e.message.clone())).collect(),+ warnings: lint.warnings.iter().map(|w| (w.code.clone(), w.message.clone())).collect(),+ symbol_count: content.matches("<symbol ").count(),+ package_count: content.matches("<package ").count(),+ deviceset_count: content.matches("<deviceset ").count(),+ pin_count: content.matches("<pin ").count(),+ pad_count: content.matches("<smd ").count() + content.matches("<pad ").count(),+ lbr: content.to_string(),+ }+}++/// First `name="…"` attribute of the first `<tag …>` element.+fn extract_attr(content: &str, tag: &str) -> Option<String> {+ let needle = format!("<{tag} ");+ let i = content.find(&needle)? + needle.len();+ let rest = &content[i..];+ let k = rest.find("name=\"")? + "name=\"".len();+ let j = rest[k..].find('"')? + k;+ Some(rest[k..j].to_string())+}++fn pairs_json(v: &[(String, String)]) -> Value {+ Value::Array(v.iter().map(|(c, m)| json!({ "code": c, "message": m })).collect())+}++fn state_json() -> String {+ match &*STATE.lock().unwrap() {+ Some(s) => json!({+ "libraryName": s.library_name,+ "hasContent": s.has_content,+ "lintPassed": s.lint_passed,+ "errors": pairs_json(&s.errors),+ "warnings": pairs_json(&s.warnings),+ "symbolCount": s.symbol_count,+ "packageCount": s.package_count,+ "devicesetCount": s.deviceset_count,+ "pinCount": s.pin_count,+ "padCount": s.pad_count,+ })+ .to_string(),+ None => "{}".to_string(),+ }+}++pub fn run(port: u16) -> Result<()> {+ *EVAL_RESULTS.lock().unwrap() = Some(HashMap::new());+ *CONSOLE.lock().unwrap() = Some(Vec::new());+ let server = Server::http(format!("0.0.0.0:{port}")).map_err(|e| anyhow::anyhow!("bind {port}: {e}"))?;+ eprintln!("[adom-lbr] serving on http://127.0.0.1:{port}");+ // app-creator §6a — best-effort HD port registration (internal/proxy-only).+ register_port(port, "adom-lbr", "EAGLE .lbr linter live app (AI-drivable)");++ for mut req in server.incoming_requests() {+ let url = req.url().split('?').next().unwrap_or("/").to_string();+ let method = req.method().clone();+ let mut body = String::new();+ if matches!(method, Method::Post) {+ let _ = req.as_reader().read_to_string(&mut body);+ }+ let resp = route(&method, &url, &body);+ let _ = req.respond(resp);+ if url == "/shutdown" && matches!(method, Method::Post) {+ break;+ }+ }+ unregister_port(port);+ Ok(())+}++/// Best-effort HD port registration (app-creator §6a / adom-port-hints).+/// Raw TCP so we add no HTTP-client dependency; never blocks serving, never+/// fails the app if HD is absent (outside Hydrogen Desktop).+fn register_port(port: u16, name: &str, reason: &str) {+ let body = format!(+ "{{\"port\":{port},\"action\":\"register\",\"name\":\"{name}\",\"reason\":\"{reason}\",\"expose\":\"internal\"}}"+ );+ std::thread::spawn(move || hd_post(&body));+}+fn unregister_port(port: u16) {+ hd_post(&format!("{{\"port\":{port},\"action\":\"unregister\"}}"));+}+fn hd_post(body: &str) {+ use std::io::Write;+ use std::net::{TcpStream, ToSocketAddrs};+ use std::time::Duration;+ let addr = match ("host.docker.internal", 47084u16).to_socket_addrs().ok().and_then(|mut a| a.next()) {+ Some(a) => a,+ None => return,+ };+ if let Ok(mut s) = TcpStream::connect_timeout(&addr, Duration::from_millis(800)) {+ let _ = s.set_write_timeout(Some(Duration::from_millis(800)));+ let req = format!(+ "POST /port-forward HTTP/1.1\r\nHost: host.docker.internal\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",+ body.len(),+ body+ );+ let _ = s.write_all(req.as_bytes());+ }+}++fn route(method: &Method, url: &str, body: &str) -> Response<std::io::Cursor<Vec<u8>>> {+ match (method, url) {+ (Method::Get, "/") | (Method::Get, "/index.html") => html(APP_HTML),+ (Method::Get, "/favicon.svg") => svg(FAVICON),+ (Method::Get, "/state") => json_resp(&state_json()),+ (Method::Post, "/load") => {+ if let Ok(v) = serde_json::from_str::<Value>(body) {+ // Preferred: push raw .lbr XML and let the server lint it.+ if let Some(lbr) = v.get("lbr").and_then(|x| x.as_str()) {+ set_state(analyze_lbr(lbr));+ }+ }+ json_resp("{\"ok\":true}")+ }+ (Method::Post, "/eval") => {+ let code = serde_json::from_str::<Value>(body)+ .ok()+ .and_then(|v| v.get("code").and_then(|c| c.as_str()).map(String::from))+ .unwrap_or_default();+ let id = EVAL_ID.fetch_add(1, Ordering::SeqCst);+ EVAL_PENDING.lock().unwrap().push_back((id, code));+ json_resp(&json!({ "id": id.to_string() }).to_string())+ }+ (Method::Get, "/eval/pending") => match EVAL_PENDING.lock().unwrap().pop_front() {+ Some((id, code)) => json_resp(&json!({ "id": id.to_string(), "code": code }).to_string()),+ None => json_resp("{}"),+ },+ (Method::Get, "/console") => {+ let log = CONSOLE.lock().unwrap().clone().unwrap_or_default();+ json_resp(&json!({ "lines": log }).to_string())+ }+ (Method::Post, "/console") => {+ if let Ok(v) = serde_json::from_str::<Value>(body) {+ let line = format!(+ "[{}] {}",+ v.get("level").and_then(|l| l.as_str()).unwrap_or("log"),+ v.get("msg").and_then(|m| m.as_str()).unwrap_or("")+ );+ if let Some(buf) = CONSOLE.lock().unwrap().as_mut() {+ buf.push(line);+ let len = buf.len();+ if len > 500 {+ buf.drain(0..len - 500);+ }+ }+ }+ json_resp("{\"ok\":true}")+ }+ (Method::Post, "/deliver") => {+ let v = serde_json::from_str::<Value>(body).unwrap_or(json!({}));+ let target = v.get("target").and_then(|x| x.as_str()).unwrap_or("kicad").to_string();+ let (name, lbr) = match &*STATE.lock().unwrap() {+ Some(s) => (s.library_name.clone(), s.lbr.clone()),+ None => (String::new(), String::new()),+ };+ json_resp(&crate::deliver::deliver(&name, &lbr, &target).to_string())+ }+ (Method::Post, "/shutdown") => json_resp("{\"ok\":true}"),+ _ if url.starts_with("/eval/") => {+ let rest = &url["/eval/".len()..];+ if let Some(id_str) = rest.strip_suffix("/result") {+ if matches!(method, Method::Post) {+ if let (Ok(id), Ok(v)) = (id_str.parse::<u64>(), serde_json::from_str::<Value>(body)) {+ if let Some(map) = EVAL_RESULTS.lock().unwrap().as_mut() {+ map.insert(id, v);+ }+ }+ return json_resp("{\"ok\":true}");+ }+ }+ if let Ok(id) = rest.parse::<u64>() {+ let map = EVAL_RESULTS.lock().unwrap();+ if let Some(r) = map.as_ref().and_then(|m| m.get(&id)) {+ return json_resp(&json!({ "done": true, "result": r }).to_string());+ }+ return json_resp("{\"done\":false}");+ }+ not_found()+ }+ _ => not_found(),+ }+}++fn hdr(ct: &str) -> Header {+ Header::from_bytes(&b"Content-Type"[..], ct.as_bytes()).unwrap()+}+fn html(s: &str) -> Response<std::io::Cursor<Vec<u8>>> {+ Response::from_string(s).with_header(hdr("text/html; charset=utf-8"))+}+fn svg(s: &str) -> Response<std::io::Cursor<Vec<u8>>> {+ Response::from_string(s).with_header(hdr("image/svg+xml"))+}+fn json_resp(s: &str) -> Response<std::io::Cursor<Vec<u8>>> {+ Response::from_string(s).with_header(hdr("application/json"))+}+fn not_found() -> Response<std::io::Cursor<Vec<u8>>> {+ Response::from_string("{\"error\":\"not found\"}").with_status_code(404).with_header(hdr("application/json"))+}