app
Adom Footprint
Public Made by Adomby adom
KiCad footprint creator with layer HUD viewer and delivery to KiCad/Fusion 360
← Commit history
Laptop full-toolbar hero, 3 detailed feature images, open-source section + Rust source
13 files changed
+1799−5
Cargo.tomladded+24@@ -0,0 +1,24 @@+[package]+name = "adom-footprint"+version = "0.1.6"+edition = "2021"+description = "Create, preview, lint, and deliver KiCad footprints (.kicad_mod) with interactive FpView viewer"++[[bin]]+name = "adom-footprint"+path = "src/main.rs"++[dependencies]+tiny_http = "0.12"+regex = "1"+anyhow = "1"+clap = { version = "4", features = ["derive"] }+serde = { version = "1", features = ["derive"] }+serde_json = "1"+reqwest = { version = "0.12", features = ["blocking", "json"] }+clap_complete = "4"++[profile.release]+opt-level = "z"+lto = true+strip = true
README.md+24−5@@ -1,21 +1,40 @@ # adom-footprint -**A live PCB-footprint viewer with a floating Layers HUD — and the InstaPCB solder-paste dispensing dots that no other PCB tool shows.**+**A live PCB-footprint viewer with a floating Layers HUD — and the InstaPCB 300 µm solder-paste dispensing dots, jetted dot-by-dot, that no other PCB tool shows.** adom-footprint renders a `.kicad_mod` through the shared `service-kicad` (no local KiCad), crops tightly to the part, and themes it onto a dark PCB canvas. It reads legacy KiCad-5 `(module …)` footprints as well as modern ones. Standalone Rust, no Node, no gallia. ## The Layers HUD -A draggable, collapsible floating panel toggles each layer independently:+ -- **Copper · Assembly (F.Fab) · Courtyard** — the KiCad-rendered geometry.-- **Paste apertures** — the stencil openings.-- **InstaPCB paste dots `NEW`** — the **actual 300 µm solder-paste dispensing positions**, computed by Adom and overlaid as real dots. Adom's InstaPCB line jets paste dot-by-dot instead of using a stencil, so a footprint in the Adom ecosystem carries *where the paste will land* — a first in PCB CAD.+A draggable, collapsible floating panel toggles each layer independently — **Copper**, **Assembly (F.Fab)**, **Courtyard**, **Paste apertures**, and the **InstaPCB paste dots**. Each row carries a live count (pads, openings, dots), so you can isolate exactly the layer you want to inspect. The HUD remembers its position and which layers are on.++## The board++++The rendered footprint on the dark canvas: red copper pads with the central thermal pad, the yellow courtyard corners, the pin-1 marker, and the green InstaPCB paste dots sitting on every pad. Everything is cropped tight to the part — no KiCad page frame, no worksheet title block — so the footprint fills the view.++## The signature feature: InstaPCB paste dots++++Those green dots are the **actual 300 µm solder-paste dispensing positions**, computed by Adom and overlaid on the copper. Adom's InstaPCB line **jets paste dot-by-dot instead of using a stencil**, so a footprint in the Adom ecosystem carries *where the paste will land* — the positions travel with the footprint, pad to pad. That's a first in PCB CAD: you're previewing the dispense program, not just a stencil aperture. ## Send to KiCad (native footprint), Fusion 360 / Altium / OrCAD (`.lbr` via **adom-lbr**). +## Open source — check it out, vibe-code it, send it back++adom-footprint 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-footprint ./adom-footprint` so the `adom-footprint` you run is your local copy.+3. **Vibe-code a change.** Open the folder in Claude Code and describe what you want — a new layer, a different paste-dot pitch, another Send-to target. The legacy-KiCad normaliser and the paste-dot generator are each one small, readable module.+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 Opened from **chip-fetcher**; pairs with **adom-symbol**, **adom-lbr**, and **ds2sf**.
docs/board.pngadded⋯ 1 unchanged line ⋯
docs/layers.pngadded⋯ 1 unchanged line ⋯
docs/pastezoom.pngadded⋯ 1 unchanged line ⋯
src/app.htmladded+314@@ -0,0 +1,314 @@+<!doctype html>+<html lang="en">+<head>+ <meta charset="utf-8">+ <meta name="viewport" content="width=device-width, initial-scale=1">+ <title>adom-footprint</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;+ }+ * { 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)}+ /* ── Floaty Layers HUD (per adom-ui-design / floating-windows) ── */+ :root { --z-hud:50; }+ .hud { position:fixed; z-index:var(--z-hud); display:flex; flex-direction:column; width:236px; max-height:calc(100% - 80px);+ background:#161b22; border:1px solid #30363d; border-radius:12px; box-shadow:0 18px 50px rgba(0,0,0,.5); user-select:none; }+ .hud-head { cursor:grab; display:flex; align-items:center; gap:8px; padding:9px 11px; border-bottom:1px solid #232c39; }+ .hud-head:active { cursor:grabbing; }+ .hud-head .grip { color:var(--text3); font-size:13px; letter-spacing:1px; }+ .hud-head .htitle { font-weight:700; font-size:12px; flex:1; }+ .hud-head .hbtn { width:18px; height:18px; display:grid; place-items:center; border:none; background:transparent; color:var(--muted); cursor:pointer; border-radius:4px; font-size:13px; line-height:1; }+ .hud-head .hbtn:hover { background:#222c3a; color:var(--text); }+ .hud-body { flex:1 1 auto; min-height:0; overflow:auto; padding:7px; }+ .hud.collapsed .hud-body, .hud.collapsed .hud-foot { display:none; }+ .layer-row { display:flex; align-items:center; gap:8px; padding:6px 7px; border-radius:7px; cursor:pointer; }+ .layer-row:hover { background:#1c2230; }+ .layer-row .sw { width:13px; height:13px; border-radius:3px; flex:0 0 13px; border:1px solid rgba(255,255,255,.15); }+ .layer-row .sw.ring { background:transparent; }+ .layer-row .lname { flex:1; font-size:12px; color:var(--text); }+ .layer-row .lmeta { font-size:10px; color:var(--text3); }+ .layer-row .chk { width:15px; height:15px; flex:0 0 15px; border-radius:4px; border:1.5px solid #3a4757; display:grid; place-items:center; color:#0b0f17; font-size:11px; }+ .layer-row.on .chk { background:var(--accent2); border-color:var(--accent2); }+ .layer-row.off .lname, .layer-row.off .sw { opacity:.4; }+ .layer-row.paste-dots { background:linear-gradient(100deg,rgba(55,200,166,.10),transparent); border:1px solid rgba(55,200,166,.25); margin-top:4px; }+ .layer-row.paste-dots .lname { color:#7ef0d2; font-weight:600; }+ .hud-foot { padding:7px 11px; border-top:1px solid #232c39; font-size:10px; color:var(--text3); line-height:1.4; }+ .hud-foot b { color:#7ef0d2; font-weight:600; }+ .layers-reopen { background:var(--surface2,#1c2230); border:1px solid var(--border2); color:var(--text); font-size:11px; border-radius:7px; padding:4px 9px; cursor:pointer; display:inline-flex; align-items:center; gap:5px; }+ .layers-reopen:hover { border-color:var(--accent2); color:var(--accent2); }++ main { flex:1; display:flex; align-items:center; justify-content:center; padding:28px; min-height:0; }+ .card { background:var(--panel); border:1px solid var(--border); border-radius:14px; padding:20px 24px; box-shadow:0 24px 80px rgba(0,0,0,.5); max-width:min(94vw,760px); width:100%; }+ .card .name { font-size:20px; font-weight:700; }+ .card .meta { color:var(--muted); font-size:13px; margin-top:2px; }+ .card .desc { color:#aeb6c2; font-size:13px; margin-top:6px; line-height:1.4; }+ .svgbox { margin-top:16px; border:1px solid var(--border); border-radius:10px; background:#0d1117; overflow:hidden; min-height:200px; display:flex; align-items:center; justify-content:center; }+ .svgbox svg { display:block; width:100%; height:auto; max-height:62vh; }+ .empty { color:var(--text3); font-size:13px; padding:40px; }+ 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-footprint — a standalone Rust tool that creates KiCad PCB footprints (.kicad_mod) from structured pad data and renders them via the shared service-kicad container. No local KiCad install needed.">+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><rect x="6.5" y="9" width="3" height="6" rx="0.6"/><rect x="14.5" y="9" width="3" height="6" rx="0.6"/><path d="M6 6.2h1.2"/></svg>+ <span class="app-name">adom-footprint</span>+ <span class="app-sub">PCB footprint viewer</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 footprint to a desktop EDA tool via Adom Desktop. KiCad receives the native .kicad_mod; Fusion 360 / Altium / OrCAD receive a true EAGLE .lbr (a symbol is synthesized from the pads so it's placeable).">+ <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>+ <button class="layers-reopen" id="layersBtn" style="display:none" data-tooltip="Show the Layers panel — toggle copper, assembly, courtyard, paste apertures, and the InstaPCB solder-paste dispensing dots.">▦ Layers</button>+ <span class="conn" id="conn" data-tooltip="Live connection to the adom-footprint Rust server. Green = the UI is polling /state and the AI can drive this view in real time (push a new footprint, run /eval, read the console).">+ <span class="dot"></span><span id="connText">connecting…</span>+ </span>+ </div>+ </header>++ <div class="hud" id="layersHud" style="display:none; right:22px; top:64px">+ <div class="hud-head" id="layersHead">+ <span class="grip">⠿</span>+ <span class="htitle">Layers</span>+ <button class="hbtn" id="layersCollapse" data-tooltip="Collapse / expand">▾</button>+ <button class="hbtn" id="layersClose" data-tooltip="Hide the Layers panel (reopen from the ▦ Layers button).">✕</button>+ </div>+ <div class="hud-body" id="layersBody"></div>+ <div class="hud-foot">+ <b>InstaPCB</b> jets paste dot-by-dot — these 300µm positions travel <i>with</i> the footprint across the Adom ecosystem.+ </div>+ </div>++ <main>+ <div class="card" id="card">+ <div class="empty" id="empty">Waiting for a footprint… run <code>adom-footprint create</code> or the AI will push one here.</div>+ <div id="content" style="display:none">+ <div class="name" id="cName"></div>+ <div class="meta" id="cMeta"></div>+ <div class="desc" id="cDesc"></div>+ <div class="svgbox" id="svgbox"></div>+ </div>+ </div>+ </main>+ <footer>rendered via service-kicad — standalone Rust, no local KiCad, no gallia</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; } });+ })();++ 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.footprintName,(s.svg||'').length]);+ 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.footprintName;+ document.getElementById('empty').style.display=has?'none':'';+ document.getElementById('content').style.display=has?'':'none';+ document.getElementById('subjectName').textContent=has?s.footprintName:'—';+ document.getElementById('subjectMeta').textContent=has&&s.padCount?(s.padCount+' pads'):'';+ if(!has) return;+ document.getElementById('cName').textContent=s.footprintName;+ document.getElementById('cMeta').textContent=[s.padCount?s.padCount+' pads':'', s.tags].filter(Boolean).join(' · ');+ document.getElementById('cDesc').textContent=s.description||'';+ document.getElementById('svgbox').innerHTML=s.svg||'';+ document.getElementById('sendto').style.display=(has&&s.hasMod)?'inline-flex':'none';+ setupLayers(s);+ }+ setInterval(pollState,1000); pollState();++ // ── Floaty Layers HUD ──+ // Layer definitions: KiCad-colored geometry is matched by color; the+ // Adom-computed paste overlays are matched by their group class.+ const LAYER_DEFS=[+ {key:'copper', name:'Copper', kind:'color', color:'#C83434', sw:'#C83434'},+ {key:'assembly', name:'Assembly (F.Fab)', kind:'color', color:'#C872AB', sw:'#C872AB'},+ {key:'courtyard',name:'Courtyard', kind:'color', color:'#F2EDA1', sw:'#F2EDA1', ring:true},+ {key:'paste-ap', name:'Paste apertures', kind:'group', sel:'.adom-layer-paste-apertures', sw:'#8aa0b8', ring:true},+ {key:'paste-dots',name:'InstaPCB paste dots', kind:'group', sel:'.adom-layer-paste-dots', sw:'#37c8a6', special:true},+ ];+ const LPREF='adomFpLayers';+ function loadLayerState(){ try{ return JSON.parse(localStorage.getItem(LPREF))||{}; }catch{ return {}; } }+ function saveLayerState(st){ try{ localStorage.setItem(LPREF, JSON.stringify(st)); }catch{} }+ function svgEl(){ return document.querySelector('#svgbox svg'); }+ function colorMatch(el, hex){+ hex=hex.toUpperCase();+ const a=(el.getAttribute('stroke')||'')+'|'+(el.getAttribute('fill')||'')+'|'+(el.getAttribute('style')||'');+ return a.toUpperCase().includes(hex);+ }+ function applyLayer(def, on){+ const sv=svgEl(); if(!sv) return;+ if(def.kind==='group'){+ sv.querySelectorAll(def.sel).forEach(g=>g.style.display=on?'':'none');+ } else {+ sv.querySelectorAll('path,rect,circle,polygon,line').forEach(el=>{+ if(colorMatch(el,def.color)) el.style.display=on?'':'none';+ });+ }+ }+ let layerState=loadLayerState();+ function setupLayers(s){+ const hud=document.getElementById('layersHud'), btn=document.getElementById('layersBtn');+ if(!s.footprintName){ hud.style.display='none'; btn.style.display='none'; return; }+ btn.style.display='inline-flex';+ // default visible unless user turned off; paste-dots default ON to showcase+ const body=document.getElementById('layersBody'); body.innerHTML='';+ LAYER_DEFS.forEach(def=>{+ const on = layerState[def.key]!==false;+ const meta = def.key==='paste-dots' ? ((s.pasteDots||0)+' @ 300µm')+ : def.key==='paste-ap' ? ((s.pasteApertures||0)+' openings') : '';+ const row=document.createElement('div');+ row.className='layer-row '+(on?'on':'off')+(def.special?' paste-dots':'');+ row.innerHTML='<span class="sw'+(def.ring?' ring':'')+'" style="'+(def.ring?('border-color:'+def.sw):('background:'+def.sw))+'"></span>'+ +'<span class="lname">'+def.name+(def.special?' <span style="font-size:9px;color:#37c8a6;border:1px solid #37c8a6;border-radius:3px;padding:0 3px;margin-left:2px;">NEW</span>':'')+'</span>'+ +(meta?'<span class="lmeta">'+meta+'</span>':'')+ +'<span class="chk">'+(on?'✓':'')+'</span>';+ row.onclick=()=>{ const nowOn=!row.classList.contains('on'); row.classList.toggle('on',nowOn); row.classList.toggle('off',!nowOn); row.querySelector('.chk').textContent=nowOn?'✓':''; layerState[def.key]=nowOn; saveLayerState(layerState); applyLayer(def,nowOn); };+ body.appendChild(row);+ applyLayer(def,on); // apply current state to the freshly injected svg+ });+ if(layerState.__open!==false && hud.style.display==='none'){ /* keep last open/closed */ }+ hud.style.display = (layerState.__closed? 'none':'flex');+ }+ // Header buttons+ document.getElementById('layersBtn').onclick=()=>{ const h=document.getElementById('layersHud'); h.style.display='flex'; layerState.__closed=false; saveLayerState(layerState); };+ document.getElementById('layersClose').onclick=()=>{ document.getElementById('layersHud').style.display='none'; layerState.__closed=true; saveLayerState(layerState); };+ document.getElementById('layersCollapse').onclick=()=>{ const h=document.getElementById('layersHud'); h.classList.toggle('collapsed'); document.getElementById('layersCollapse').textContent=h.classList.contains('collapsed')?'▸':'▾'; };+ document.getElementById('layersHead').addEventListener('dblclick',e=>{ if(!e.target.closest('.hbtn')) document.getElementById('layersCollapse').click(); });+ // Drag (never clamp to edge; just keep on-screen)+ (function(){+ const hud=document.getElementById('layersHud'), head=document.getElementById('layersHead');+ let dx=0,dy=0,drag=false;+ head.addEventListener('pointerdown',e=>{ if(e.target.closest('.hbtn'))return; drag=true; const r=hud.getBoundingClientRect(); dx=e.clientX-r.left; dy=e.clientY-r.top; hud.style.right='auto'; hud.style.left=r.left+'px'; hud.style.top=r.top+'px'; head.setPointerCapture(e.pointerId); });+ head.addEventListener('pointermove',e=>{ if(!drag)return; let x=e.clientX-dx, y=e.clientY-dy; x=Math.max(6,Math.min(x,innerWidth-hud.offsetWidth-6)); y=Math.max(6,Math.min(y,innerHeight-40)); hud.style.left=x+'px'; hud.style.top=y+'px'; layerState.__pos={x,y}; });+ head.addEventListener('pointerup',e=>{ if(drag){ drag=false; saveLayerState(layerState); } });+ // restore position+ if(layerState.__pos){ hud.style.right='auto'; hud.style.left=layerState.__pos.x+'px'; hud.style.top=layerState.__pos.y+'px'; }+ })();+ // AI-drivable+ window.layers={ toggle:(key,on)=>{ const def=LAYER_DEFS.find(d=>d.key===key); if(!def)return 'no layer '+key; layerState[key]=on; saveLayerState(layerState); applyLayer(def,on); setupLayers({footprintName:document.getElementById('subjectName').textContent}); return key+'='+on; }, show:()=>document.getElementById('layersBtn').onclick(), state:()=>layerState };++ // ── 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+109@@ -0,0 +1,109 @@+//! "Send to" delivery — push the current footprint to the user's desktop EDA+//! tools via the adom-desktop bridge. KiCad takes the native `.kicad_mod`+//! directly (footprint-only); the EAGLE-family tools (Fusion 360 / Altium /+//! OrCAD) import a true `.lbr`, which adom-lbr builds from the footprint pads.+//! Best-effort: a clear message when the bridge is offline.++use serde_json::{json, Value};+use std::process::Command;++/// Run an adom-desktop verb with a JSON payload; return parsed JSON (or wrapped+/// raw output). Errors only if adom-desktop itself can't be spawned.+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 footprint to `target` ("kicad" | "fusion360" | "altium" | "orcad").+/// Returns a JSON result for the UI toast.+pub fn deliver(footprint_name: &str, kicad_mod: &str, target: &str) -> Value {+ if kicad_mod.is_empty() {+ return json!({ "ok": false, "error": "no footprint loaded" });+ }+ let dir = std::env::temp_dir().join(format!("adom-footprint-deliver-{}", std::process::id()));+ let _ = std::fs::create_dir_all(&dir);+ let safe = footprint_name.replace(['/', ' '], "_");+ let mod_path = dir.join(format!("{safe}.kicad_mod"));+ if std::fs::write(&mod_path, kicad_mod).is_err() {+ return json!({ "ok": false, "error": "could not write temp footprint" });+ }++ if target == "kicad" {+ let send = ad("send_files", json!({ "filePaths": [mod_path.to_string_lossy()], "targetApp": "kicad", "destinationFolder": "footprints" }));+ let inst = ad("kicad_install_library", json!({ "libraryPath": mod_path.to_string_lossy(), "libraryName": footprint_name, "libraryType": "footprint" }));+ return summarize("KiCad", "footprint", &[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}") }),+ };++ // Build a true .lbr via adom-lbr (synthesizes a symbol from the pads so the+ // deviceset is placeable in the EAGLE-family tool).+ let lbr_path = dir.join(format!("{safe}.lbr"));+ match Command::new("adom-lbr")+ .args(["generate", "--fp"]).arg(&mod_path)+ .args(["--name", footprint_name])+ .arg("--output").arg(&lbr_path)+ .output()+ {+ Ok(o) if o.status.success() => {}+ Ok(o) => return json!({ "ok": false, "error": "adom-lbr generate failed", "detail": String::from_utf8_lossy(&o.stderr) }),+ Err(e) => return json!({ "ok": false, "error": format!("adom-lbr not available: {e}") }),+ }+ 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 built, 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/fp_gen.rsadded+154@@ -0,0 +1,154 @@+//! `.kicad_mod` generator — faithful Rust port of the Node `generateKicadMod`.+//! Pure string-building from explicit pad/body/courtyard data; output is+//! byte-identical to the Node generator (verified by the golden parity test).+//! No gallia, no Node.++pub struct Pad {+ pub number: String,+ pub x: f64,+ pub y: f64,+ pub dx: f64,+ pub dy: f64,+ pub shape: Option<String>,+ pub angle: Option<f64>,+ pub layers: Option<String>,+}++pub struct Rect {+ pub x1: f64,+ pub y1: f64,+ pub x2: f64,+ pub y2: f64,+}++pub struct FpInput {+ pub name: String,+ pub pads: Vec<Pad>,+ pub body: Option<Rect>,+ pub courtyard: Option<Rect>,+ pub description: String,+ pub tags: String,+}++/// Print an f64 the way JS `${x}` / Number.toString does (shortest round-trip,+/// integers without a trailing ".0"). Rust's `{}` already matches JS here,+/// including artifacts like `-0.5700000000000001`.+fn n(x: f64) -> String {+ format!("{x}")+}++pub fn generate_kicad_mod(input: &FpInput) -> String {+ let mut s = format!("(footprint \"{}\"\n", input.name);+ s += " (version 20240108)\n (generator \"adom-footprint\")\n (layer \"F.Cu\")\n";+ if !input.description.is_empty() {+ s += &format!(" (descr \"{}\")\n", input.description);+ }+ if !input.tags.is_empty() {+ s += &format!(" (tags \"{}\")\n", input.tags);+ }+ s += " (attr smd)\n";++ // Courtyard+ if let Some(c) = &input.courtyard {+ s += &format!(" (fp_rect (start {} {}) (end {} {})\n", n(c.x1), n(c.y1), n(c.x2), n(c.y2));+ s += " (stroke (width 0.05) (type default)) (fill none) (layer \"F.CrtYd\"))\n";+ }++ // Fab outline+ if let Some(b) = &input.body {+ s += &format!(" (fp_rect (start {} {}) (end {} {})\n", n(b.x1), n(b.y1), n(b.x2), n(b.y2));+ s += " (stroke (width 0.1) (type default)) (fill none) (layer \"F.Fab\"))\n";+ }++ // Silkscreen outline (slightly outside fab)+ if let Some(b) = &input.body {+ let m = 0.12;+ s += &format!(" (fp_rect (start {} {}) (end {} {})\n", n(b.x1 - m), n(b.y1 - m), n(b.x2 + m), n(b.y2 + m));+ s += " (stroke (width 0.12) (type default)) (fill none) (layer \"F.SilkS\"))\n";+ }++ // Pin 1 marker+ if !input.pads.is_empty() {+ if let Some(b) = &input.body {+ s += &format!(" (fp_line (start {} {}) (end {} {})\n", n(b.x1 - 0.3), n(b.y1 - 0.3), n(b.x1 + 0.3), n(b.y1 - 0.3));+ s += " (stroke (width 0.12) (type default)) (layer \"F.SilkS\"))\n";+ }+ }++ // Reference and Value+ let ref_y = input.courtyard.as_ref().map(|c| c.y1).or_else(|| input.body.as_ref().map(|b| b.y1)).unwrap_or(-2.0) - 0.8;+ let val_y = input.courtyard.as_ref().map(|c| c.y2).or_else(|| input.body.as_ref().map(|b| b.y2)).unwrap_or(2.0) + 0.8;+ s += &format!(" (fp_text reference \"REF**\" (at 0 {})\n", n(ref_y));+ s += " (layer \"F.SilkS\") (effects (font (size 1 1) (thickness 0.15))))\n";+ s += &format!(" (fp_text value \"{}\" (at 0 {})\n", input.name, n(val_y));+ s += " (layer \"F.Fab\") (effects (font (size 1 1) (thickness 0.15))))\n";++ // Pads+ for pad in &input.pads {+ let shape = pad.shape.clone().unwrap_or_else(|| "roundrect".to_string());+ let layers = pad.layers.clone().unwrap_or_else(|| "\"F.Cu\" \"F.Paste\" \"F.Mask\"".to_string());+ let angle = match pad.angle {+ Some(a) if a != 0.0 => format!(" {}", n(a)),+ _ => String::new(),+ };+ s += &format!(" (pad \"{}\" smd {} (at {} {}{}) (size {} {})\n", pad.number, shape, n(pad.x), n(pad.y), angle, n(pad.dx), n(pad.dy));+ s += &format!(" (layers {})", layers);+ if shape == "roundrect" {+ s += " (roundrect_rratio 0.25)";+ }+ s += ")\n";+ }++ s += ")\n";+ s+}++/// In-plane bounding box of the footprint (courtyard ∪ body ∪ pad extents),+/// in footprint-local mm — used to crop the rendered SVG.+pub fn bbox(input: &FpInput) -> (f64, f64, f64, f64) {+ let mut xs = Vec::new();+ let mut ys = Vec::new();+ for r in [input.courtyard.as_ref(), input.body.as_ref()].into_iter().flatten() {+ xs.push(r.x1);+ xs.push(r.x2);+ ys.push(r.y1);+ ys.push(r.y2);+ }+ for p in &input.pads {+ xs.push(p.x - p.dx / 2.0);+ xs.push(p.x + p.dx / 2.0);+ ys.push(p.y - p.dy / 2.0);+ ys.push(p.y + p.dy / 2.0);+ }+ if xs.is_empty() {+ return (-2.0, -2.0, 2.0, 2.0);+ }+ (+ xs.iter().cloned().fold(f64::INFINITY, f64::min),+ ys.iter().cloned().fold(f64::INFINITY, f64::min),+ xs.iter().cloned().fold(f64::NEG_INFINITY, f64::max),+ ys.iter().cloned().fold(f64::NEG_INFINITY, f64::max),+ )+}++#[cfg(test)]+mod tests {+ use super::*;+ #[test]+ fn r0402_matches_golden() {+ let input = FpInput {+ name: "R_0402_1005Metric".into(),+ pads: vec![+ Pad { number: "1".into(), x: -0.485, y: 0.0, dx: 0.6, dy: 0.7, shape: Some("roundrect".into()), angle: None, layers: None },+ Pad { number: "2".into(), x: 0.485, y: 0.0, dx: 0.6, dy: 0.7, shape: Some("roundrect".into()), angle: None, layers: None },+ ],+ body: Some(Rect { x1: -0.5, y1: -0.27, x2: 0.5, y2: 0.27 }),+ courtyard: Some(Rect { x1: -0.93, y1: -0.47, x2: 0.93, y2: 0.47 }),+ description: "0402 (1005 metric) resistor".into(),+ tags: "resistor 0402".into(),+ };+ let got = generate_kicad_mod(&input);+ let golden = include_str!("../tests/golden/R_0402.kicad_mod");+ assert_eq!(got, golden, "must be byte-identical to the Node generator");+ }+}
src/main.rsadded+331@@ -0,0 +1,331 @@+//! adom-footprint — standalone Rust CLI + live app for KiCad PCB footprints.+//! No Node, no gallia. Generation is native Rust (`fp_gen`); SVG rendering goes+//! through service-kicad (`render`); `serve` runs the AI-drivable live app.++mod deliver;+mod fp_gen;+mod paste;+mod render;+mod server;+mod theme;++use anyhow::{anyhow, Context, Result};+use clap::{Parser, Subcommand};+use regex::Regex;+use std::fs;+use std::path::PathBuf;++#[derive(Parser)]+#[command(name = "adom-footprint", version, about = "Create and preview KiCad PCB footprints (standalone Rust, no gallia)")]+struct Cli {+ #[command(subcommand)]+ command: Cmd,+}++#[derive(Subcommand)]+enum Cmd {+ /// Create a footprint from structured pad data (JSON on stdin or --file).+ Create {+ #[arg(long)]+ file: Option<PathBuf>,+ #[arg(long)]+ out: Option<PathBuf>,+ },+ /// Render an existing .kicad_mod to a themed viewer HTML.+ Preview {+ #[arg(long)]+ file: PathBuf,+ #[arg(long)]+ out: Option<PathBuf>,+ },+ /// Render any `.kicad_mod` to a themed thumbnail SVG (for source variants).+ Render {+ #[arg(long)]+ file: PathBuf,+ #[arg(long)]+ out: PathBuf,+ },+ /// Render a ds2sf `<mpn>-footprint.extracted.json` to a themed thumbnail SVG.+ /// Used by chip-fetcher for the ds2sf footprint source variant.+ RenderDs2sf {+ #[arg(long)]+ file: PathBuf,+ #[arg(long)]+ out: PathBuf,+ },+ /// Run the live app server (AI-drivable: /state, /eval, /console).+ Serve {+ #[arg(long, default_value_t = 8782)]+ port: u16,+ #[arg(long)]+ file: Option<PathBuf>,+ },+}++#[derive(serde::Deserialize)]+struct CreateInput {+ #[serde(rename = "footprintName")]+ footprint_name: String,+ pads: Vec<JsonPad>,+ #[serde(default)]+ body: Option<JsonRect>,+ #[serde(default)]+ courtyard: Option<JsonRect>,+ #[serde(default)]+ description: String,+ #[serde(default)]+ tags: String,+}++#[derive(serde::Deserialize)]+struct JsonPad {+ number: String,+ x: f64,+ y: f64,+ dx: f64,+ dy: f64,+ #[serde(default)]+ shape: Option<String>,+ #[serde(default)]+ angle: Option<f64>,+ #[serde(default)]+ layers: Option<String>,+}++#[derive(serde::Deserialize)]+struct JsonRect {+ x1: f64,+ y1: f64,+ x2: f64,+ y2: f64,+}++fn main() {+ if let Err(e) = run() {+ eprintln!("ERROR: {e:#}");+ std::process::exit(1);+ }+}++fn run() -> Result<()> {+ match Cli::parse().command {+ Cmd::Create { file, out } => cmd_create(file, out),+ Cmd::Render { file, out } => {+ let content = fs::read_to_string(&file).with_context(|| format!("reading {}", file.display()))?;+ let svg = render::render_footprint_svg(&content, bbox_from_mod(&content))?;+ fs::write(&out, &svg).with_context(|| format!("writing {}", out.display()))?;+ println!("OK: rendered {} → {}", file.display(), out.display());+ Ok(())+ }+ Cmd::RenderDs2sf { file, out } => cmd_render_ds2sf(file, out),+ Cmd::Preview { file, out } => cmd_preview(file, out),+ Cmd::Serve { port, file } => {+ if let Some(f) = file {+ let content = fs::read_to_string(&f).with_context(|| format!("reading {}", f.display()))?;+ let name = first_footprint_name(&content).unwrap_or_else(|| "footprint".to_string());+ let svg = render::render_footprint_svg(&content, bbox_from_mod(&content))?;+ server::set_state(server::AppState {+ footprint_name: name,+ svg,+ pad_count: content.matches("(pad ").count(),+ kicad_mod: content,+ ..Default::default()+ });+ }+ server::run(port)+ }+ }+}++fn to_input(c: &CreateInput) -> fp_gen::FpInput {+ fp_gen::FpInput {+ name: c.footprint_name.clone(),+ pads: c+ .pads+ .iter()+ .map(|p| fp_gen::Pad {+ number: p.number.clone(),+ x: p.x,+ y: p.y,+ dx: p.dx,+ dy: p.dy,+ shape: p.shape.clone(),+ angle: p.angle,+ layers: p.layers.clone(),+ })+ .collect(),+ body: c.body.as_ref().map(|r| fp_gen::Rect { x1: r.x1, y1: r.y1, x2: r.x2, y2: r.y2 }),+ courtyard: c.courtyard.as_ref().map(|r| fp_gen::Rect { x1: r.x1, y1: r.y1, x2: r.x2, y2: r.y2 }),+ description: c.description.clone(),+ tags: c.tags.clone(),+ }+}++fn cmd_create(file: Option<PathBuf>, out: Option<PathBuf>) -> Result<()> {+ let body = match file {+ Some(p) => fs::read_to_string(&p).with_context(|| format!("reading {}", p.display()))?,+ None => {+ use std::io::Read;+ let mut s = String::new();+ std::io::stdin().read_to_string(&mut s)?;+ s+ }+ };+ let input: CreateInput = serde_json::from_str(&body).context("parsing create JSON")?;+ if input.footprint_name.is_empty() {+ return Err(anyhow!("footprintName is required"));+ }+ if input.pads.is_empty() {+ return Err(anyhow!("pads array is required"));+ }+ let fpinput = to_input(&input);+ let kicad_mod = fp_gen::generate_kicad_mod(&fpinput);+ let bbox = fp_gen::bbox(&fpinput);++ let folder = out.unwrap_or_else(|| PathBuf::from(&input.footprint_name));+ fs::create_dir_all(&folder)?;+ let mod_path = folder.join(format!("{}.kicad_mod", input.footprint_name));+ fs::write(&mod_path, &kicad_mod)?;++ let svg = render::render_footprint_svg(&kicad_mod, bbox)?;+ let svg_path = folder.join(format!("{}.svg", input.footprint_name));+ fs::write(&svg_path, &svg)?;+ let html_path = folder.join(format!("{}-viewer.html", input.footprint_name));+ fs::write(&html_path, viewer_html(&input.footprint_name, &svg, input.pads.len(), &input.description, &input.tags))?;++ println!("created {}", mod_path.display());+ println!("svg {}", svg_path.display());+ println!("viewer {}", html_path.display());+ Ok(())+}++fn cmd_render_ds2sf(file: PathBuf, out: PathBuf) -> Result<()> {+ let raw = fs::read_to_string(&file).with_context(|| format!("reading {}", file.display()))?;+ let v: serde_json::Value = serde_json::from_str(&raw).context("parsing ds2sf footprint JSON")?;+ let name = v.get("footprintName").and_then(|x| x.as_str()).unwrap_or("footprint").to_string();+ let arr = v.get("pads").and_then(|x| x.as_array()).ok_or_else(|| anyhow!("ds2sf footprint has no pads[]"))?;+ let num = |a: Option<&serde_json::Value>| a.and_then(|x| x.as_f64()).unwrap_or(0.0);+ let mut pads = Vec::new();+ for p in arr {+ let at = p.get("at").and_then(|x| x.as_array());+ let size = p.get("size").and_then(|x| x.as_array());+ pads.push(fp_gen::Pad {+ number: p.get("number").and_then(|x| x.as_str()).unwrap_or("").to_string(),+ x: num(at.and_then(|a| a.first())),+ y: num(at.and_then(|a| a.get(1))),+ dx: num(size.and_then(|a| a.first())),+ dy: num(size.and_then(|a| a.get(1))),+ shape: p.get("shape").and_then(|x| x.as_str()).map(String::from),+ angle: p.get("rotation").and_then(|x| x.as_f64()).or_else(|| p.get("angle").and_then(|x| x.as_f64())),+ layers: None,+ });+ }+ if pads.is_empty() {+ return Err(anyhow!("ds2sf footprint extraction has no pads"));+ }+ // Body outline from bodyDimensions ([w,h] or {x,y}/{w,h}), centered.+ let body = v.get("bodyDimensions").and_then(|b| {+ let (w, h) = if let Some(a) = b.as_array() {+ (a.first().and_then(|x| x.as_f64())?, a.get(1).and_then(|x| x.as_f64())?)+ } else {+ let g = |k1: &str, k2: &str| b.get(k1).or_else(|| b.get(k2)).and_then(|x| x.as_f64());+ (g("x", "w")?, g("y", "h")?)+ };+ Some(fp_gen::Rect { x1: -w / 2.0, y1: -h / 2.0, x2: w / 2.0, y2: h / 2.0 })+ });+ let input = fp_gen::FpInput {+ name,+ pads,+ body,+ courtyard: None,+ description: v.get("description").and_then(|x| x.as_str()).unwrap_or("").to_string(),+ tags: String::new(),+ };+ let kicad_mod = fp_gen::generate_kicad_mod(&input);+ let svg = render::render_footprint_svg(&kicad_mod, fp_gen::bbox(&input))?;+ fs::write(&out, &svg).with_context(|| format!("writing {}", out.display()))?;+ println!("OK: rendered ds2sf footprint → {} ({} pads)", out.display(), input.pads.len());+ Ok(())+}++fn cmd_preview(file: PathBuf, out: Option<PathBuf>) -> Result<()> {+ let content = fs::read_to_string(&file).with_context(|| format!("reading {}", file.display()))?;+ let name = first_footprint_name(&content).unwrap_or_else(|| "footprint".to_string());+ let svg = render::render_footprint_svg(&content, bbox_from_mod(&content))?;+ let folder = out.unwrap_or_else(|| file.parent().map(|p| p.to_path_buf()).unwrap_or_else(|| PathBuf::from(".")));+ let pads = content.matches("(pad ").count();+ let html_path = folder.join(format!("{name}-viewer.html"));+ fs::write(&html_path, viewer_html(&name, &svg, pads, "", ""))?;+ println!("viewer {}", html_path.display());+ Ok(())+}++pub(crate) fn first_footprint_name(content: &str) -> Option<String> {+ // Modern: (footprint "NAME" …). Legacy KiCad 5/6: (footprint NAME (layer …)).+ if let Some(start) = content.find("(footprint \"") {+ let i = start + "(footprint \"".len();+ let j = content[i..].find('"')? + i;+ return Some(content[i..j].to_string());+ }+ let i = content.find("(footprint ")? + "(footprint ".len();+ let rest = &content[i..];+ let end = rest.find(|c: char| c.is_whitespace() || c == '(').unwrap_or(rest.len());+ let name = rest[..end].trim();+ if name.is_empty() { None } else { Some(name.to_string()) }+}++/// Bounding box (minx,miny,maxx,maxy) from pad + fp_rect extents in a .kicad_mod.+pub(crate) fn bbox_from_mod(content: &str) -> (f64, f64, f64, f64) {+ let mut xs = Vec::new();+ let mut ys = Vec::new();+ let pad = Regex::new(r"\(at (-?[\d.]+) (-?[\d.]+)(?: [-\d.]+)?\) \(size (-?[\d.]+) (-?[\d.]+)\)").unwrap();+ for c in pad.captures_iter(content) {+ if let (Ok(x), Ok(y), Ok(dx), Ok(dy)) = (c[1].parse::<f64>(), c[2].parse::<f64>(), c[3].parse::<f64>(), c[4].parse::<f64>()) {+ xs.push(x - dx / 2.0);+ xs.push(x + dx / 2.0);+ ys.push(y - dy / 2.0);+ ys.push(y + dy / 2.0);+ }+ }+ let rect = Regex::new(r"\(start (-?[\d.]+) (-?[\d.]+)\) \(end (-?[\d.]+) (-?[\d.]+)\)").unwrap();+ for c in rect.captures_iter(content) {+ if let (Ok(x1), Ok(y1), Ok(x2), Ok(y2)) = (c[1].parse::<f64>(), c[2].parse::<f64>(), c[3].parse::<f64>(), c[4].parse::<f64>()) {+ xs.push(x1);+ xs.push(x2);+ ys.push(y1);+ ys.push(y2);+ }+ }+ if xs.is_empty() {+ return (-2.0, -2.0, 2.0, 2.0);+ }+ (+ xs.iter().cloned().fold(f64::INFINITY, f64::min),+ ys.iter().cloned().fold(f64::INFINITY, f64::min),+ xs.iter().cloned().fold(f64::NEG_INFINITY, f64::max),+ ys.iter().cloned().fold(f64::NEG_INFINITY, f64::max),+ )+}++fn viewer_html(name: &str, svg: &str, pads: usize, desc: &str, tags: &str) -> String {+ let meta = [format!("{pads} pads"), tags.to_string()].into_iter().filter(|s| !s.is_empty() && s != "0 pads").collect::<Vec<_>>().join(" · ");+ format!(+ "<!doctype html><html><head><meta charset=\"utf-8\"><title>{t} — adom-footprint</title>\+<style>body{{margin:0;background:#0d1117;color:#e6edf3;font-family:-apple-system,sans-serif;min-height:100vh;display:flex;flex-direction:column}}\+header{{display:flex;align-items:center;gap:10px;padding:14px 22px;border-bottom:1px solid #222b38}}.dot{{width:10px;height:10px;border-radius:50%;background:#00e6dc;box-shadow:0 0 12px #00e6dc}}\+.brand{{font-weight:700}}.brand span{{color:#8b949e;font-weight:400}}.wrap{{flex:1;display:flex;align-items:center;justify-content:center;padding:32px}}\+.card{{background:#11161f;border:1px solid #222b38;border-radius:14px;padding:22px 26px;max-width:min(92vw,720px)}}.name{{font-size:22px;font-weight:700}}.meta{{color:#8b949e;font-size:13px;margin-top:2px}}.desc{{color:#aeb6c2;font-size:13px;margin-top:6px}}\+.svgbox{{margin-top:16px;border:1px solid #222b38;border-radius:10px;background:#0d1117;overflow:hidden}}.svgbox svg{{display:block;width:100%;height:auto;max-height:64vh}}footer{{text-align:center;color:#5b6675;font-size:11px;padding:10px}}</style></head>\+<body><header><span class=\"dot\"></span><span class=\"brand\">adom-footprint <span>· PCB footprint viewer</span></span></header>\+<div class=\"wrap\"><div class=\"card\"><div class=\"name\">{t}</div>{m}{d}<div class=\"svgbox\">{svg}</div></div></div>\+<footer>rendered via service-kicad — standalone, no local KiCad</footer></body></html>",+ t = esc(name),+ m = if meta.is_empty() { String::new() } else { format!("<div class=\"meta\">{}</div>", esc(&meta)) },+ d = if desc.is_empty() { String::new() } else { format!("<div class=\"desc\">{}</div>", esc(desc)) },+ svg = svg,+ )+}++fn esc(s: &str) -> String {+ s.replace('&', "&").replace('<', "<").replace('>', ">")+}
src/paste.rsadded+123@@ -0,0 +1,123 @@+//! InstaPCB solder-paste **dispensing positions** — an Adom-computed overlay.+//!+//! Standard PCB CAD shows a solid F.Paste aperture (the stencil opening). Adom's+//! InstaPCB line doesn't use a stencil — it *jets* solder paste dot-by-dot. So a+//! footprint in the Adom ecosystem also carries the actual dispensing positions:+//! a grid of ~300 µm paste dots tiled across each aperture. This module parses+//! the pads from a `.kicad_mod`, computes those dots, and emits two SVG layer+//! groups (aperture outlines + dispensing dots) in the same page coordinates the+//! footprint is rendered at, so they overlay exactly.++use regex::Regex;++/// One paste aperture: center, size (pre-rotation), rotation in degrees.+pub struct Aperture {+ pub cx: f64,+ pub cy: f64,+ pub w: f64,+ pub h: f64,+ pub rot: f64,+}++const DOT_DIA: f64 = 0.30; // 300 µm jetted paste dot+const DOT_PITCH: f64 = 0.45; // center-to-center spacing of dispensing positions+const MARGIN: f64 = 0.06; // keep dots just inside the aperture edge+const MAX_DOTS: usize = 600; // safety cap so a huge thermal pad can't explode the DOM++/// Parse SMD pad apertures (pads that get solder paste) from a `.kicad_mod`.+pub fn paste_apertures(kicad_mod: &str) -> Vec<Aperture> {+ let mut out = Vec::new();+ let bytes = kicad_mod.as_bytes();+ let at_re = Regex::new(r"\(at\s+(-?[\d.]+)\s+(-?[\d.]+)(?:\s+(-?[\d.]+))?\)").unwrap();+ let size_re = Regex::new(r"\(size\s+(-?[\d.]+)\s+(-?[\d.]+)\)").unwrap();+ // Walk each "(pad " occurrence and take a balanced-paren slice as the block.+ let mut search = 0;+ while let Some(rel) = kicad_mod[search..].find("(pad ") {+ let start = search + rel;+ let mut depth = 0i32;+ let mut end = start;+ for i in start..kicad_mod.len() {+ match bytes[i] {+ b'(' => depth += 1,+ b')' => {+ depth -= 1;+ if depth == 0 {+ end = i;+ break;+ }+ }+ _ => {}+ }+ }+ let block = &kicad_mod[start..=end.min(kicad_mod.len() - 1)];+ search = end + 1;+ // Only SMD pads on the paste layer get jetted paste. Skip NPTH/edge pads.+ let smd = block.contains(" smd ");+ let has_paste = block.contains("F.Paste") || !block.contains("(layers"); // default smd → paste+ if !smd || !has_paste {+ continue;+ }+ if let (Some(a), Some(s)) = (at_re.captures(block), size_re.captures(block)) {+ let cx = a[1].parse().unwrap_or(0.0);+ let cy = a[2].parse().unwrap_or(0.0);+ let rot = a.get(3).and_then(|m| m.as_str().parse().ok()).unwrap_or(0.0);+ let w: f64 = s[1].parse().unwrap_or(0.0);+ let h: f64 = s[2].parse().unwrap_or(0.0);+ if w > 0.0 && h > 0.0 {+ out.push(Aperture { cx, cy, w, h, rot });+ }+ }+ }+ out+}++/// Emit two SVG `<g>` layer groups (apertures + 300 µm dispensing dots) in page+/// coordinates: footprint-local (ax,ay) maps to (ox+ax, oy+ay). Returns+/// `(apertures_group, dots_group, dot_count)`.+pub fn paste_overlay_svg(aps: &[Aperture], ox: f64, oy: f64) -> (String, String, usize) {+ let mut apert = String::from(r#"<g class="adom-layer-paste-apertures">"#);+ let mut dots = String::from(r#"<g class="adom-layer-paste-dots">"#);+ let mut n = 0usize;++ for ap in aps {+ let (px, py) = (ox + ap.cx, oy + ap.cy);+ let (rad, cos, sin) = {+ let r = ap.rot.to_radians();+ (r, r.cos(), r.sin())+ };+ let _ = rad;+ // Aperture outline (rotated rect via transform).+ apert.push_str(&format!(+ r##"<rect x="{x:.3}" y="{y:.3}" width="{w:.3}" height="{h:.3}" rx="0.05" transform="rotate({rot:.2} {px:.3} {py:.3})" fill="none" stroke="#8aa0b8" stroke-width="0.03" stroke-dasharray="0.12 0.08" opacity="0.7"/>"##,+ x = px - ap.w / 2.0, y = py - ap.h / 2.0, w = ap.w, h = ap.h, rot = ap.rot, px = px, py = py,+ ));++ // Dispensing dot grid, centered in the aperture, inset by MARGIN.+ let usable_w = (ap.w - 2.0 * MARGIN - DOT_DIA).max(0.0);+ let usable_h = (ap.h - 2.0 * MARGIN - DOT_DIA).max(0.0);+ let nx = (usable_w / DOT_PITCH).floor() as i32 + 1;+ let ny = (usable_h / DOT_PITCH).floor() as i32 + 1;+ let span_x = (nx - 1) as f64 * DOT_PITCH;+ let span_y = (ny - 1) as f64 * DOT_PITCH;+ for ix in 0..nx {+ for iy in 0..ny {+ if n >= MAX_DOTS {+ break;+ }+ // local offset within aperture (centered), then rotate.+ let lx = -span_x / 2.0 + ix as f64 * DOT_PITCH;+ let ly = -span_y / 2.0 + iy as f64 * DOT_PITCH;+ let dx = px + lx * cos - ly * sin;+ let dy = py + lx * sin + ly * cos;+ dots.push_str(&format!(+ r##"<circle cx="{dx:.3}" cy="{dy:.3}" r="{r:.3}" fill="#37c8a6" opacity="0.92"/>"##,+ dx = dx, dy = dy, r = DOT_DIA / 2.0,+ ));+ n += 1;+ }+ }+ }+ apert.push_str("</g>");+ dots.push_str("</g>");+ (apert, dots, n)+}
src/render.rsadded+212@@ -0,0 +1,212 @@+//! Render a `.kicad_mod` to a themed, cropped footprint SVG via **service-kicad**+//! (no local KiCad, no gallia). Wraps the footprint in a minimal one-footprint+//! board, POSTs to `/kicad/pcb/export/svg`, crops the page to the footprint+//! bounding box, and applies the Adom footprint theme.++use anyhow::{anyhow, Context, Result};+use regex::Regex;++const KICAD_SERVICE_DEFAULT: &str = "https://kicad-rk5ue5pcfemi.adom.cloud";++fn service_url() -> String {+ std::env::var("KICAD_SERVICE_API")+ .unwrap_or_else(|_| KICAD_SERVICE_DEFAULT.to_string())+ .trim_end_matches('/')+ .to_string()+}++// Where the footprint is placed on the wrapper board (page center, fully on-page).+const OX: f64 = 148.5;+const OY: f64 = 105.0;+const MARGIN: f64 = 1.0;++/// Render the footprint to a themed SVG, cropped to `bbox` (footprint-local mm).+pub fn render_footprint_svg(kicad_mod: &str, bbox: (f64, f64, f64, f64)) -> Result<String> {+ let pcb = build_pcb_wrap(kicad_mod);+ let url = format!("{}/kicad/pcb/export/svg?side=top", service_url());+ let resp = reqwest::blocking::Client::builder()+ .timeout(std::time::Duration::from_secs(60))+ .build()?+ .post(&url)+ .header("Content-Type", "application/octet-stream")+ .body(pcb)+ .send()+ .with_context(|| format!("POST {url}"))?;+ if !resp.status().is_success() {+ return Err(anyhow!("service-kicad {} — {}", resp.status(), resp.text().unwrap_or_default()));+ }+ let svg = resp.text()?;++ // Crop to the footprint by scanning the ACTUAL rendered geometry rather than+ // assuming where KiCad placed it. Old-format mods (`(layer F.Cu)` unquoted,+ // `tedit`) don't honor our injected `(at)`, so a placement-based viewBox+ // misses the part entirely. Geometry-based cropping is format- and+ // placement-agnostic. Fall back to the mod-local bbox if the scan finds+ // nothing (e.g. an all-text footprint).+ let (vbx, vby, vbw, vbh) = match svg_geometry_bbox(&svg, OX, OY) {+ Some((minx, miny, maxx, maxy)) => (+ minx - MARGIN,+ miny - MARGIN,+ (maxx - minx) + 2.0 * MARGIN,+ (maxy - miny) + 2.0 * MARGIN,+ ),+ None => {+ let (minx, miny, maxx, maxy) = bbox;+ (OX + minx - MARGIN, OY + miny - MARGIN, (maxx - minx) + 2.0 * MARGIN, (maxy - miny) + 2.0 * MARGIN)+ }+ };+ let re = Regex::new(r#"width="[^"]*mm" height="[^"]*mm" viewBox="[^"]*""#).unwrap();+ let replacement = format!(+ "width=\"{vbw:.3}mm\" height=\"{vbh:.3}mm\" viewBox=\"{vbx:.4} {vby:.4} {vbw:.4} {vbh:.4}\""+ );+ let svg = re.replace(&svg, replacement.as_str()).into_owned();+ let themed = crate::theme::theme_footprint_svg(&svg);++ // Overlay the InstaPCB solder-paste dispensing positions (Adom-computed,+ // page coords matching the placed footprint) as two toggleable layer groups.+ let aps = crate::paste::paste_apertures(kicad_mod);+ let (apert_g, dots_g, _n) = crate::paste::paste_overlay_svg(&aps, OX, OY);+ let themed = if let Some(idx) = themed.rfind("</svg>") {+ let mut s = String::with_capacity(themed.len() + apert_g.len() + dots_g.len() + 2);+ s.push_str(&themed[..idx]);+ s.push_str(&apert_g);+ s.push('\n');+ s.push_str(&dots_g);+ s.push('\n');+ s.push_str(&themed[idx..]);+ s+ } else {+ themed+ };+ Ok(themed)+}++/// Bounding box of the real drawn geometry in a service-kicad PCB SVG, in the+/// SVG's own user units (mm). Scans `<circle>`, `<rect>`, and `<path d=…>`+/// coordinate pairs, then keeps only the points within `CLUSTER_R` of the+/// **known placement center** (`cx_seed`, `cy_seed`) — where we placed the+/// footprint on the page. This rejects the A4 page frame AND the worksheet+/// title block (a dense ~1500-point text cluster in the bottom-right corner+/// that would otherwise capture the median and blow up the crop).+fn svg_geometry_bbox(svg: &str, cx_seed: f64, cy_seed: f64) -> Option<(f64, f64, f64, f64)> {+ const CLUSTER_R: f64 = 30.0; // half-extent ceiling: parts up to ~60 mm across+ let mut xs: Vec<f64> = Vec::new();+ let mut ys: Vec<f64> = Vec::new();++ let circle = Regex::new(r#"<circle\b[^>]*?cx="(-?[\d.]+)"[^>]*?cy="(-?[\d.]+)"(?:[^>]*?r="(-?[\d.]+)")?"#).unwrap();+ for c in circle.captures_iter(svg) {+ if let (Ok(cx), Ok(cy)) = (c[1].parse::<f64>(), c[2].parse::<f64>()) {+ let r = c.get(3).and_then(|m| m.as_str().parse::<f64>().ok()).unwrap_or(0.0);+ xs.push(cx - r); xs.push(cx + r);+ ys.push(cy - r); ys.push(cy + r);+ }+ }+ let rect = Regex::new(r#"<rect\b[^>]*?x="(-?[\d.]+)"[^>]*?y="(-?[\d.]+)"[^>]*?width="(-?[\d.]+)"[^>]*?height="(-?[\d.]+)""#).unwrap();+ for c in rect.captures_iter(svg) {+ if let (Ok(x), Ok(y), Ok(w), Ok(h)) = (c[1].parse::<f64>(), c[2].parse::<f64>(), c[3].parse::<f64>(), c[4].parse::<f64>()) {+ xs.push(x); xs.push(x + w);+ ys.push(y); ys.push(y + h);+ }+ }+ let dattr = Regex::new(r#"\bd="([^"]*)""#).unwrap();+ let pair = Regex::new(r"(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)").unwrap();+ for d in dattr.captures_iter(svg) {+ for p in pair.captures_iter(&d[1]) {+ if let (Ok(x), Ok(y)) = (p[1].parse::<f64>(), p[2].parse::<f64>()) {+ xs.push(x);+ ys.push(y);+ }+ }+ }+ if xs.is_empty() {+ return None;+ }+ let (mut minx, mut miny, mut maxx, mut maxy) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);+ for (&x, &y) in xs.iter().zip(ys.iter()) {+ if (x - cx_seed).abs() > CLUSTER_R || (y - cy_seed).abs() > CLUSTER_R {+ continue; // page frame / worksheet title block — not the footprint+ }+ minx = minx.min(x); maxx = maxx.max(x);+ miny = miny.min(y); maxy = maxy.max(y);+ }+ if minx.is_finite() && maxx > minx {+ Some((minx, miny, maxx, maxy))+ } else {+ None+ }+}++/// Wrap a `.kicad_mod` footprint into a minimal one-footprint board, placed at+/// the page center.+/// Upgrade a legacy KiCad-5 `(module …)` footprint to the modern `(footprint …)`+/// s-expression that KiCad-10's board loader accepts. Real vendor mods (Ultra+/// Librarian, older SnapEDA) still ship the old format, which the strict board+/// parser rejects token-by-token. Handles: the top keyword, the 3D model block+/// (`(at (xyz` → `(offset (xyz`, quote path), unquoted `fp_text` values, legacy+/// graphic widths (`… (width W))` → `(stroke (width W) (type solid)))`), and+/// legacy arcs (`(start=center)(end=point)(angle)` → modern `(start)(mid)(end)`).+fn normalize_legacy(m: &str) -> String {+ let mut s = m.replacen("(module ", "(footprint ", 1);+ // 3D model: quote the path, and (at (xyz → (offset (xyz.+ let model_re = Regex::new(r"(?m)\(model\s+([^\s()\n]+)").unwrap();+ s = model_re.replace_all(&s, |c: ®ex::Captures| {+ let p = &c[1];+ let pq = if p.starts_with('"') { p.to_string() } else { format!("\"{p}\"") };+ format!("(model {pq}")+ }).into_owned();+ s = s.replace("(at (xyz", "(offset (xyz");+ // fp_text reference/value/user: quote an unquoted value token.+ let txt_re = Regex::new(r#"\(fp_text (reference|value|user) ([^"\s()][^\s()]*) "#).unwrap();+ s = txt_re.replace_all(&s, |c: ®ex::Captures| format!("(fp_text {} \"{}\" ", &c[1], &c[2])).into_owned();+ // legacy fp_arc: start=center, end=arc start point, angle=sweep° → 3-point.+ let arc_re = Regex::new(r"\(fp_arc \(start (-?[\d.]+) (-?[\d.]+)\) \(end (-?[\d.]+) (-?[\d.]+)\) \(angle (-?[\d.]+)\) \(layer ([^)]+)\) \(width ([\d.]+)\)\)").unwrap();+ s = arc_re.replace_all(&s, |c: ®ex::Captures| {+ let (cx, cy): (f64, f64) = (c[1].parse().unwrap_or(0.0), c[2].parse().unwrap_or(0.0));+ let (px, py): (f64, f64) = (c[3].parse().unwrap_or(0.0), c[4].parse().unwrap_or(0.0));+ let sweep = c[5].parse::<f64>().unwrap_or(0.0).to_radians();+ let (layer, w) = (&c[6], &c[7]);+ let r = ((px - cx).powi(2) + (py - cy).powi(2)).sqrt();+ let t0 = (py - cy).atan2(px - cx);+ let (ex, ey) = (cx + r * (t0 + sweep).cos(), cy + r * (t0 + sweep).sin());+ let (mx, my) = (cx + r * (t0 + sweep / 2.0).cos(), cy + r * (t0 + sweep / 2.0).sin());+ format!("(fp_arc (start {px:.4} {py:.4}) (mid {mx:.4} {my:.4}) (end {ex:.4} {ey:.4}) (stroke (width {w}) (type solid)) (layer {layer}))")+ }).into_owned();+ // remaining legacy graphic widths (fp_line / fp_circle / fp_poly).+ let w_re = Regex::new(r"\(width ([\d.]+)\)\)").unwrap();+ s = w_re.replace_all(&s, "(stroke (width $1) (type solid)))").into_owned();+ s+}++fn build_pcb_wrap(kicad_mod: &str) -> String {+ // Upgrade legacy KiCad-5 `(module …)` footprints so the strict board loader+ // accepts them (real vendor mods still ship the old format).+ let normalized = normalize_legacy(kicad_mod);+ let kicad_mod = normalized.as_str();+ // Insert an (at OX OY) right after the footprint's layer line so the part+ // is placed at a known page location. Handle BOTH the modern quoted+ // `(layer "F.Cu")` and the legacy unquoted `(layer F.Cu)` (KiCad 5/6 mods+ // still carry the latter) — matching only the quoted form silently left old+ // footprints at their native position and off the computed viewBox.+ let layer_re = Regex::new(r#"\(layer "?F\.Cu"?\)"#).unwrap();+ let placed = match layer_re.find(kicad_mod) {+ Some(m) => {+ let mut s = kicad_mod.trim_end().to_string();+ s.insert_str(m.end(), &format!("\n (at {OX} {OY})"));+ s+ }+ None => kicad_mod.trim_end().to_string(),+ };+ let indented = placed.replace('\n', "\n ");+ format!(+ "(kicad_pcb (version 20240108) (generator \"adom\")\n \+ (general (thickness 1.6))\n (paper \"A4\")\n \+ (layers\n \+ (0 \"F.Cu\" signal)\n (31 \"B.Cu\" signal)\n \+ (36 \"B.SilkS\" user) (37 \"F.SilkS\" user)\n \+ (38 \"B.Mask\" user) (39 \"F.Mask\" user)\n \+ (44 \"Edge.Cuts\" user)\n \+ (49 \"F.Fab\" user) (51 \"B.Fab\" user)\n \+ (46 \"B.CrtYd\" user) (47 \"F.CrtYd\" user)\n )\n \+ {indented}\n)\n"+ )+}
src/server.rsadded+238@@ -0,0 +1,238 @@+//! adom-footprint live app server — the AI-drivable surface (app-creator §7/§7b/§8).+//! Same shell as adom-symbol: GET / (UI), GET /state (poller), POST /load,+//! 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::io::Read;+use std::sync::atomic::{AtomicU64, Ordering};+use std::sync::Mutex;+use tiny_http::{Header, Method, Response, Server};++#[derive(Default, Clone)]+pub struct AppState {+ pub footprint_name: String,+ pub svg: String,+ pub pad_count: usize,+ pub description: String,+ pub tags: String,+ /// Raw `.kicad_mod` retained for "Send to" delivery (empty if loaded via SVG).+ pub kicad_mod: 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"><rect x="3" y="3" width="18" height="18" rx="2"/><rect x="6.5" y="9" width="3" height="6" rx="0.6"/><rect x="14.5" y="9" width="3" height="6" rx="0.6"/><path d="M6 6.2h1.2"/></svg>"##;++pub fn set_state(s: AppState) {+ *STATE.lock().unwrap() = Some(s);+}++fn state_json() -> String {+ match &*STATE.lock().unwrap() {+ Some(s) => {+ let aps = crate::paste::paste_apertures(&s.kicad_mod);+ let dot_count = crate::paste::paste_overlay_svg(&aps, 0.0, 0.0).2;+ json!({+ "footprintName": s.footprint_name, "svg": s.svg, "padCount": s.pad_count,+ "description": s.description, "tags": s.tags, "hasMod": !s.kicad_mod.is_empty(),+ "pasteApertures": aps.len(),+ "pasteDots": dot_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-footprint] serving on http://127.0.0.1:{port}");+ register_port(port, "adom-footprint", "PCB footprint viewer 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 .kicad_mod; the server renders it via+ // service-kicad (mirrors adom-step's /api/load). This is how+ // chip-fetcher drives the app — push a file, it renders.+ if let Some(km) = v.get("kicadMod").and_then(|x| x.as_str()) {+ let name = v+ .get("footprintName")+ .and_then(|x| x.as_str())+ .map(String::from)+ .or_else(|| crate::first_footprint_name(km))+ .unwrap_or_else(|| "footprint".to_string());+ match crate::render::render_footprint_svg(km, crate::bbox_from_mod(km)) {+ Ok(svg) => set_state(AppState {+ footprint_name: name,+ svg,+ pad_count: km.matches("(pad ").count(),+ description: v.get("description").and_then(|x| x.as_str()).unwrap_or("").to_string(),+ tags: v.get("tags").and_then(|x| x.as_str()).unwrap_or("").to_string(),+ kicad_mod: km.to_string(),+ }),+ Err(e) => return json_err(422, &e.to_string()),+ }+ } else {+ set_state(AppState {+ footprint_name: v.get("footprintName").and_then(|x| x.as_str()).unwrap_or("").to_string(),+ svg: v.get("svg").and_then(|x| x.as_str()).unwrap_or("").to_string(),+ pad_count: v.get("padCount").and_then(|x| x.as_u64()).unwrap_or(0) as usize,+ description: v.get("description").and_then(|x| x.as_str()).unwrap_or("").to_string(),+ tags: v.get("tags").and_then(|x| x.as_str()).unwrap_or("").to_string(),+ kicad_mod: v.get("kicadMod").and_then(|x| x.as_str()).unwrap_or("").to_string(),+ });+ }+ }+ 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, km) = match &*STATE.lock().unwrap() {+ Some(s) => (s.footprint_name.clone(), s.kicad_mod.clone()),+ None => (String::new(), String::new()),+ };+ json_resp(&crate::deliver::deliver(&name, &km, &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"))+}+fn json_err(code: u16, msg: &str) -> Response<std::io::Cursor<Vec<u8>>> {+ let body = json!({ "ok": false, "error": msg }).to_string();+ Response::from_string(body).with_status_code(code).with_header(hdr("application/json"))+}
src/theme.rsadded+270@@ -0,0 +1,270 @@+//! SVG theming for symbols + footprints.+//!+//! Symbol theming ports the regex-replacement pass from+//! gallia/symbol-creator/lib/svg-theme.js applyDarkTheme() into Rust.+//! It transforms KiCad CLI's default-themed symbol SVG into the Adom+//! dark palette (canvas #0d1117, body gradient, white pin labels,+//! amber group labels). The `<style>` body is wrapped in CDATA so the+//! output is valid standalone XML — applyDarkTheme as written embeds a+//! literal `<g class="stroked-text">` inside a CSS comment, which trips+//! strict XML parsers when SVG is loaded via <object> or as a wiki+//! asset.+//!+//! Footprint theming keeps kicad-cli's native KiCad palette (the same+//! red/yellow/magenta the FpView app shows) and just paints them on+//! the canonical FpView dark canvas with a 20-unit PCB-style grid+//! (rgba(255,255,255,0.03) lines), mirroring+//! gallia/viewer/kicad-footprint-viewer.js generateFootprintViewer().++use regex::Regex;++const KICAD_BODY_STROKE: &str = "#840000";+const KICAD_BODY_FILL: &str = "#ffffc2";+const KICAD_TEXT_STROKE: &str = "#A90000";+const KICAD_HIDDEN_TEXT: &str = "#006464";++// Adom dark theme target colors.+const CANVAS_BG: &str = "#0d1117";+const BODY_STROKE: &str = "#30363d";+const BODY_FILL: &str = "#1a2332";+const BODY_FILL_GRAD: &str = "#141c27";+const PIN_WIRE: &str = "#4a5568";+const PIN_NAME: &str = "#ffffff";+const REF_TEXT: &str = "#484f58";+const PIN_DOT: &str = "#4a5568";++/// Apply the Adom dark theme to a KiCad-CLI-exported symbol SVG.+pub fn apply_dark_theme_to_symbol(raw: &str) -> String {+ let mut s: String = raw+ .trim_start_matches('\u{feff}')+ .replace("\r\n", "\n");++ // Strip XML decl and DOCTYPE.+ s = strip_first_match(&s, r#"<\?xml[^?]*\?>"#);+ s = strip_first_match(&s, r#"<!DOCTYPE[^>]*>"#);+ s = strip_all_matches(&s, r#"<title>[^<]*</title>"#);+ s = strip_all_matches(&s, r#"<desc>[^<]*</desc>"#);+ let s = s.trim();++ // Replace KiCad's default white background rect (if present) — older+ // KiCad outputs may include one, newer ones don't. We also unconditionally+ // inject a viewBox-sized canvas <rect> below so the symbol always has+ // an opaque Adom-bg behind it.+ let bg_rect_re = Regex::new(r#"(<rect[^>]*fill=")(?i)white("[^>]*>)"#).unwrap();+ let s = bg_rect_re.replace(s, format!("${{1}}{}${{2}}", CANVAS_BG)).to_string();++ // Pull viewBox so we know how big to make the canvas bg rect.+ let (vb_x, vb_y, vb_w, vb_h) =+ match Regex::new(r#"viewBox="(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)""#)+ .unwrap()+ .captures(&s)+ {+ Some(c) => (+ c[1].parse::<f64>().unwrap_or(0.0),+ c[2].parse::<f64>().unwrap_or(0.0),+ c[3].parse::<f64>().unwrap_or(100.0),+ c[4].parse::<f64>().unwrap_or(100.0),+ ),+ None => (0.0, 0.0, 100.0, 100.0),+ };+ // Canvas: solid Adom dark + the same atmosphere SymView's+ // .canvas-wrap div adds via CSS — radial teal glow at center ++ // 20-unit PCB grid. Baked into the SVG so the thumbnail looks+ // identical to SymView when shown standalone (no HTML wrapper).+ let cx = vb_x + vb_w / 2.0;+ let cy = vb_y + vb_h / 2.0;+ let glow_r = (vb_w.max(vb_h)) * 0.7;+ let canvas_bg_rect = format!(+r##"<defs>+ <radialGradient id="adom-canvas-glow" cx="{cx}" cy="{cy}" r="{r}" gradientUnits="userSpaceOnUse">+ <stop offset="0%" stop-color="#00b8b0" stop-opacity="0.06"/>+ <stop offset="70%" stop-color="#00b8b0" stop-opacity="0"/>+ </radialGradient>+ <pattern id="adom-canvas-grid" x="0" y="0" width="2.54" height="2.54" patternUnits="userSpaceOnUse">+ <line x1="0" y1="0" x2="2.54" y2="0" stroke="rgba(255,255,255,0.025)" stroke-width="0.06"/>+ <line x1="0" y1="0" x2="0" y2="2.54" stroke="rgba(255,255,255,0.025)" stroke-width="0.06"/>+ </pattern>+</defs>+<rect x="{x}" y="{y}" width="{w}" height="{h}" fill="{bg}"/>+<rect x="{x}" y="{y}" width="{w}" height="{h}" fill="url(#adom-canvas-grid)"/>+<rect x="{x}" y="{y}" width="{w}" height="{h}" fill="url(#adom-canvas-glow)"/>"##,+ x = vb_x, y = vb_y, w = vb_w, h = vb_h,+ cx = cx, cy = cy, r = glow_r, bg = CANVAS_BG,+ );++ // Inject a <defs> + <style> right after the opening <svg ...>.+ // CDATA-wrap the style body so the literal `<g class=...>` inside the+ // CSS comment doesn't trip strict XML parsers.+ let defs_and_style = format!(+ r#"+<defs>+ <linearGradient id="adom-body-grad" x1="0" y1="0" x2="0" y2="1">+ <stop offset="0%" stop-color="{body_fill}"/>+ <stop offset="100%" stop-color="{body_fill_grad}"/>+ </linearGradient>+</defs>+<style><![CDATA[+ /* Stroked text groups (KiCad renders text as paths in <g class="stroked-text">) */+ g.stroked-text path {{+ stroke: {pin_name} !important;+ fill: {pin_name} !important;+ }}+ text[opacity="0"] {{+ fill: {pin_name} !important;+ }}+ .pin-highlight path {{+ stroke: #00e6dc !important;+ }}+ .pin-highlight g.stroked-text path {{+ stroke: #00e6dc !important;+ }}+ .group-highlight g.stroked-text path {{+ stroke: #FFD180 !important;+ }}+]]></style>"#,+ body_fill = BODY_FILL,+ body_fill_grad = BODY_FILL_GRAD,+ pin_name = PIN_NAME,+ );++ let svg_open = Regex::new(r#"(<svg[^>]*>)"#).unwrap();+ let s = svg_open+ .replace(s.as_ref(), format!("${{1}}\n{}\n{}", defs_and_style, canvas_bg_rect))+ .to_string();++ // Direct color replacements — CASE-INSENSITIVE on the hex digits+ // because kicad-cli emits both #840000 and #FFFFC2 (uppercase) and+ // the gallia source pattern uses /gi. Use Regex with `(?i)` for the+ // hex tail; literal prefix stays case-sensitive.+ let s = ci_replace_all(&s, &[+ (format!("stroke:{}", KICAD_BODY_STROKE), format!("stroke:{}", PIN_WIRE)),+ (format!("stroke=\"{}\"", KICAD_BODY_STROKE), format!("stroke=\"{}\"", PIN_WIRE)),+ (format!("fill:{}", KICAD_BODY_STROKE), format!("fill:{}", PIN_DOT)),+ (format!("fill=\"{}\"", KICAD_BODY_STROKE), format!("fill=\"{}\"", PIN_DOT)),+ (format!("fill:{}", KICAD_BODY_FILL), "fill:url(#adom-body-grad)".to_string()),+ (format!("fill=\"{}\"", KICAD_BODY_FILL), "fill=\"url(#adom-body-grad)\"".to_string()),+ (format!("stroke:{}", KICAD_TEXT_STROKE), format!("stroke:{}", PIN_NAME)),+ (format!("stroke=\"{}\"", KICAD_TEXT_STROKE), format!("stroke=\"{}\"", PIN_NAME)),+ (format!("fill:{}", KICAD_TEXT_STROKE), format!("fill:{}", PIN_NAME)),+ (format!("fill=\"{}\"", KICAD_TEXT_STROKE), format!("fill=\"{}\"", PIN_NAME)),+ (format!("stroke:{}", KICAD_HIDDEN_TEXT), format!("stroke:{}", REF_TEXT)),+ (format!("fill:{}", KICAD_HIDDEN_TEXT), format!("fill:{}", REF_TEXT)),+ ]);+ // rgb(...) form for the body fill — kicad-cli sometimes uses rgb()+ // notation for the same #FFFFC2 yellow.+ let rgb_body = Regex::new(r#"fill="rgb\(\s*255\s*,\s*255\s*,\s*194\s*\)""#).unwrap();+ let s = rgb_body.replace_all(&s, "fill=\"url(#adom-body-grad)\"").to_string();++ // Strip the root <g> default fill:#000000 so symbol body shapes don't+ // come out solid black on the dark canvas.+ let root_g_black = Regex::new(r#"(<g\s+style="fill:#000000[^"]*")"#).unwrap();+ let s = root_g_black+ .replace(&s, |c: ®ex::Captures| {+ c[1].replace("fill:#000000", "fill:none")+ })+ .to_string();++ s+}++/// Re-theme a KiCad-CLI footprint SVG to match the FpView app:+/// - dark canvas #0d1117 + 20-unit PCB-style grid behind the geometry+/// - strip invisible REF**/value text + stroked-text groups (else they+/// inflate the viewBox)+/// - keep kicad-cli's native pad/silk/courtyard colors as-is, just+/// painted on the dark grid+pub fn theme_footprint_svg(raw: &str) -> String {+ let mut s = raw.replace("\r\n", "\n");+ s = strip_first_match(&s, r#"<\?xml[^?]*\?>"#);+ s = strip_first_match(&s, r#"<!DOCTYPE[^>]*>"#);+ s = strip_all_matches(&s, r#"<title>[^<]*</title>"#);+ s = strip_all_matches(&s, r#"<desc>[^<]*</desc>"#);+ s = strip_all_matches(&s, r#"<text[^>]*opacity="0"[^<]*</text>"#);+ s = strip_all_dotall(&s, r#"<g class="stroked-text">.*?</g>"#);+ let s = s.trim();++ // Pull the SVG's viewBox so the bg rect covers the entire canvas.+ let vb = match Regex::new(r#"viewBox="(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)""#)+ .unwrap()+ .captures(s)+ {+ Some(c) => (+ c[1].parse::<f64>().unwrap_or(-50.0),+ c[2].parse::<f64>().unwrap_or(-50.0),+ c[3].parse::<f64>().unwrap_or(100.0),+ c[4].parse::<f64>().unwrap_or(100.0),+ ),+ None => (-50.0, -50.0, 100.0, 100.0),+ };++ let defs = r#"+<defs>+ <pattern id="adom-pcb-grid" x="0" y="0" width="1" height="1" patternUnits="userSpaceOnUse">+ <line x1="0" y1="0" x2="1" y2="0" stroke="rgba(255,255,255,0.03)" stroke-width="0.05"/>+ <line x1="0" y1="0" x2="0" y2="1" stroke="rgba(255,255,255,0.03)" stroke-width="0.05"/>+ </pattern>+</defs>"#;+ let bg = format!(+ "<rect x=\"{x}\" y=\"{y}\" width=\"{w}\" height=\"{h}\" fill=\"{CANVAS_BG}\"/>\n<rect x=\"{x}\" y=\"{y}\" width=\"{w}\" height=\"{h}\" fill=\"url(#adom-pcb-grid)\"/>",+ x = vb.0, y = vb.1, w = vb.2, h = vb.3,+ );++ let svg_open = Regex::new(r#"(<svg[^>]*>)"#).unwrap();+ let s = svg_open+ .replace(s, format!("${{1}}\n{}\n{}", defs, bg))+ .to_string();++ // Strip any KiCad-CLI default white-ish page-background rect that+ // would render in front of our dark canvas.+ let white_bg_rect = Regex::new(+ r#"<rect[^>]*fill="(?i)#?(?:f{2,6}|white)"[^>]*/?>"#,+ ).unwrap();+ let s = white_bg_rect.replace_all(&s, "").to_string();++ s+}++fn strip_first_match(s: &str, pat: &str) -> String {+ Regex::new(pat).unwrap().replace(s, "").to_string()+}+fn strip_all_matches(s: &str, pat: &str) -> String {+ Regex::new(pat).unwrap().replace_all(s, "").to_string()+}+fn strip_all_dotall(s: &str, pat: &str) -> String {+ Regex::new(&format!(r"(?s){}", pat))+ .unwrap()+ .replace_all(s, "")+ .to_string()+}+fn simple_replace_all(s: &str, pairs: &[(String, String)]) -> String {+ let mut out = s.to_string();+ for (from, to) in pairs {+ out = out.replace(from, to);+ }+ out+}++/// Case-insensitive replace_all on each (from, to) pair. The literal+/// prefix (`fill:`, `stroke="`) stays case-sensitive; only the hex tail+/// after the `#` is matched insensitively. This matches the JS source+/// `/.../gi` semantics from gallia/symbol-creator/lib/svg-theme.js.+fn ci_replace_all(s: &str, pairs: &[(String, String)]) -> String {+ let mut out = s.to_string();+ for (from, to) in pairs {+ // Split prefix-up-to-and-including-# vs. hex-tail at the first '#'+ // so we can keep the prefix literal and apply (?i) to the hex.+ let pat = if let Some(hash_idx) = from.find('#') {+ let prefix = regex::escape(&from[..hash_idx + 1]);+ let hex = regex::escape(&from[hash_idx + 1..]);+ format!("{prefix}(?i){hex}")+ } else {+ regex::escape(from)+ };+ let re = match Regex::new(&pat) {+ Ok(r) => r,+ Err(_) => continue,+ };+ out = re.replace_all(&out, to.as_str()).to_string();+ }+ out+}