← Commit history

Laptop full-toolbar hero, 3 detailed feature images, open-source section + Rust source

John Lauer ·70a6f07a31 ·4mo ago ·parent a2487f7
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:+![The Layers HUD](docs/layers.png) -- **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 footprint with copper, courtyard and paste dots](docs/board.png)++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++![Close-up of the 300 µm solder-paste dispensing dots](docs/pastezoom.png)++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&micro;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('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;")+}
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: &regex::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: &regex::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: &regex::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: &regex::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+}