← Commit history
Cargo.lock+1−1
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "adom-footprint"-version = "1.0.16"+version = "1.0.17" dependencies = [  "anyhow",  "clap",
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-footprint"-version = "1.0.16"+version = "1.0.17" edition = "2021" description = "Create, preview, lint, and deliver KiCad footprints (.kicad_mod) with interactive FpView viewer" 
README.md+4−4
@@ -5,13 +5,13 @@ Standalone Rust CLI + live app for **KiCad PCB footprints**. Generates shared **service-kicad** container, and serves an AI-drivable live viewer. No Node, no local KiCad install. -![The adom-footprint live app — a full-bleed footprint canvas with the left chip-fetcher library rail and the Layers panel](docs/screenshot-viewer.png)+![The adom-footprint live app — the chip-fetcher library rail on the left, the footprint canvas in the middle, and a docked details panel (Layers, Text placement, and a 3D chip preview) on the right](docs/screenshot-viewer.png) -*The live app: browse the whole chip-fetcher library on the left (filter / ↑↓ / click, or deep-link with `?mpn=`); toggle layers on the right.*+*The live app: browse the whole chip-fetcher library on the left (filter / ↑↓ / click, or deep-link with `?mpn=`); on the right, a docked details panel with Layers, the text-placement editor, and a 3D chip preview + specs so you can see exactly which part you're on.* -![The RefDes/Value text-placement editor with Auto-layout, manual drag, undo/redo and per-tool naming](docs/screenshot-editor.png)+![The text-placement editor with Auto-layout, per-tool naming, undo/redo, an "Auto-layout on open" checkbox, and a Save-to-library button](docs/screenshot-editor.png) -*Place the RefDes/Value text: one-click Auto-layout clears the pads, or drag/nudge it yourself — with undo and per-EDA naming (KiCad Reference/Value · Altium Designator/Comment · …). Saved into the `.kicad_mod` so every tool shows it.*+*Place the RefDes/Value text: one-click Auto-layout clears the pads (and normalizes the size), or drag/nudge it yourself — with undo and per-EDA naming (KiCad Reference/Value · Altium Designator/Comment · …). **Save** writes it back to the library `.kicad_mod` (original backed up to `.orig`); tick **Auto-layout on open** to sweep the whole library, auto-saving as you go.*  ![The paste-dots layer — 300µm solder-paste dispensing positions on every pad](docs/screenshot-paste-dots.png) 
VERSION+1−1
@@ -1 +1 @@-1.0.16\ No newline at end of file+1.0.17\ No newline at end of file
bin/adom-footprint
⋯ 1 unchanged line ⋯
docs/screenshot-editor.png
⋯ 1 unchanged line ⋯
docs/screenshot-viewer.png
⋯ 1 unchanged line ⋯
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "adom-footprint",-  "version": "1.0.16",+  "version": "1.0.17",   "type": "app",   "description": "KiCad footprint creator with layer HUD viewer and delivery to KiCad/Fusion 360",   "dependencies": {
page.json+6−4
@@ -1,6 +1,6 @@ {   "slug": "adom-footprint",-  "version": "1.0.16",+  "version": "1.0.17",   "type": "app",   "description": "KiCad footprint creator with layer HUD viewer and delivery to KiCad/Fusion 360",   "dependencies": {@@ -19,8 +19,10 @@     "pads"   ],   "hero": {-    "type": "image",-    "path": "adom-footprint-hero-v4.png"+    "type": "billboard",+    "headline": "KiCad footprints, instantly",+    "subhead": "Toggle copper, fab and paste; render and deliver footprints to KiCad and Fusion 360.",+    "screenshot": "screenshots/shot.png"   },   "scripts": {     "install": "./install.sh",@@ -36,4 +38,4 @@   "org": "adom",   "updated_at": "2026-06-25T01:59:28.504Z",   "source_visibility": "public"-}+}\ No newline at end of file
screenshots/shot.png
⋯ 1 unchanged line ⋯
src/app.html+142−33
@@ -105,6 +105,36 @@     .cr-item:hover { background:rgba(0,184,177,.10); color:#fff; }     .cr-item.cur { background:linear-gradient(90deg,rgba(0,184,177,.20),transparent); border-left-color:var(--accent2); color:#fff; font-weight:600; }     .cr-hint { padding:7px 12px; border-top:1px solid var(--border); font-size:10px; color:var(--text3); }+    /* ── docked right details panel (Layers · Text · Chip) — reserves real space ── */+    #rightpanel { order:3; flex:0 0 auto; width:300px; align-self:stretch; display:flex; border-left:1px solid var(--border); background:var(--panel); }+    #rightpanel.collapsed { width:auto; }+    #rpToggle { display:none; align-items:center; gap:7px; background:var(--panel2); color:var(--muted); border:0; border-left:1px solid var(--border); padding:11px 8px; cursor:pointer; font-size:11.5px; font-weight:700; writing-mode:vertical-rl; letter-spacing:.6px; }+    #rpToggle:hover { color:#fff; background:#1a2230; }+    #rightpanel.collapsed #rpToggle { display:flex; }+    #rightpanel.collapsed #rpInner { display:none; }+    #rpInner { flex:1; display:flex; flex-direction:column; overflow-y:auto; min-width:0; }+    #rpInner::-webkit-scrollbar { width:9px; } #rpInner::-webkit-scrollbar-thumb { background:rgba(255,255,255,.13); border-radius:7px; border:2px solid transparent; background-clip:padding-box; }+    #rpHead { display:flex; align-items:center; justify-content:space-between; padding:9px 12px; border-bottom:1px solid var(--border); font-size:11px; font-weight:700; color:var(--muted); text-transform:uppercase; letter-spacing:.5px; flex:0 0 auto; }+    #rpCollapse { background:transparent; border:0; color:var(--muted); font-size:15px; cursor:pointer; line-height:1; padding:0 4px; } #rpCollapse:hover { color:#fff; }+    /* Dock the (formerly floaty) Layers + Text HUDs as in-flow sections. */+    #rightpanel .hud { position:static; width:auto; max-height:none; box-shadow:none; border:0; border-radius:0; border-bottom:1px solid var(--border); z-index:auto; backdrop-filter:none; background:transparent; flex:0 0 auto; }+    #rightpanel .hud-head { cursor:pointer; }+    #rightpanel .hud-head .grip { display:none; }+    #rightpanel .hud.collapsed .hud-body, #rightpanel .hud.collapsed .hud-foot { display:none; }+    /* Chip details section */+    #chippanel { padding:10px 12px 18px; }+    .rp-sec-head { font-size:11px; font-weight:700; color:var(--muted); text-transform:uppercase; letter-spacing:.4px; margin:0 0 9px; }+    #chip3dwrap { position:relative; background:radial-gradient(circle at 50% 38%,#16202c,#0d1117); border:1px solid var(--border); border-radius:10px; min-height:150px; display:grid; place-items:center; overflow:hidden; }+    #chip3d { max-width:100%; max-height:230px; display:none; }+    #chip3dempty { color:var(--text3); font-size:11px; padding:30px; text-align:center; }+    #chipinfo { margin-top:11px; }+    #chipinfo .ci-mpn { font-size:14px; font-weight:700; color:var(--text); word-break:break-all; }+    #chipinfo .ci-mfr { color:var(--accent2); font-size:12px; margin-top:1px; }+    #chipinfo .ci-desc { color:#aeb6c2; font-size:11.5px; line-height:1.45; margin:8px 0 6px; }+    #chipinfo .ci-row { display:flex; justify-content:space-between; gap:10px; padding:4px 0; border-top:1px solid #1a2230; font-size:12px; }+    #chipinfo .ci-k { color:var(--text3); flex:0 0 auto; } #chipinfo .ci-v { color:var(--text); text-align:right; word-break:break-word; }+    #chipinfo a.ci-ds { display:inline-block; margin-top:9px; color:var(--accent2); text-decoration:none; font-size:11.5px; border:1px solid rgba(0,184,177,.4); border-radius:7px; padding:4px 9px; }+    #chipinfo a.ci-ds:hover { background:rgba(0,184,177,.12); }     .svgbox.grab { cursor:grabbing; }     .svgbox svg { position:absolute; left:0; top:0; width:100%; height:100%; transform-origin:0 0; display:block; }     .zoomtools { position:absolute; left:10px; top:10px; display:flex; gap:6px; z-index:5; }@@ -157,6 +187,13 @@     #textBody .thistrow { display:flex; gap:6px; margin:8px 0 2px; }     #textBody .tmini { flex:1; padding:5px; font-size:11.5px; }     #textBody .tbtn:disabled { opacity:0.4; cursor:default; }+    #textBody .tcheck { display:flex; align-items:center; gap:7px; margin:9px 0 4px; font-size:12px; color:#cfd8e2; cursor:pointer; user-select:none; }+    #textBody .tcheck input { accent-color:var(--accent2); width:14px; height:14px; cursor:pointer; }+    #textBody .tcheck-note { color:var(--text3); font-size:11px; }+    #textBody .savebtn { width:100%; box-sizing:border-box; margin-top:10px; border-radius:8px; padding:7px; font-size:12.5px; font-weight:700; }+    #textBody .savebtn.saved { background:transparent; border:1px solid var(--border); color:var(--text3); cursor:default; }+    #textBody .savebtn.unsaved { background:linear-gradient(180deg,#1c7a4a,#155f39); border:1px solid #2a9c63; color:#dffff0; cursor:pointer; box-shadow:0 2px 10px rgba(40,180,110,.25); }+    #textBody .savebtn.saving { background:var(--surface2,#1c2230); border:1px solid var(--border); color:#cfd8e2; cursor:default; }   </style> </head> <body>@@ -199,7 +236,7 @@     </div>   </div> -  <div class="hud" id="textHud" style="display:none; right:22px; bottom:30px; width:258px">+  <div class="hud" id="textHud" style="display:none; right:22px; top:64px; width:258px">     <div class="hud-head" id="textHead">       <span class="grip">⠿</span>       <span class="htitle">Text placement</span>@@ -216,6 +253,9 @@         </select>       </div>       <button id="autoLayoutBtn" class="tbtn tbtn-primary" data-tooltip="Snap the RefDes above the top edge and the Value below the bottom edge, ~1 mm clear of the whole footprint (pads ∪ courtyard ∪ body).">✦ Auto-layout</button>+      <label class="tcheck" data-tooltip="While ON, opening/navigating to a footprint auto-runs Auto-layout AND saves it to the library file — so you can sweep the library fixing placement. Uncheck when you're happy. Off by default.">+        <input type="checkbox" id="autoOnOpen"> Auto-layout on open <span class="tcheck-note">· auto-saves</span>+      </label>       <div class="thistrow">         <button id="undoBtn" class="tbtn tmini" disabled data-tooltip="Undo the last placement change — Ctrl/Cmd+Z.">↶ Undo</button>         <button id="redoBtn" class="tbtn tmini" disabled data-tooltip="Redo — Ctrl/Cmd+Shift+Z.">↷ Redo</button>@@ -233,6 +273,7 @@         <span class="tnudge"><button data-d="up">↑</button><button data-d="down">↓</button></span>       </div>       <div id="textStatus" class="tstatus"></div>+      <button id="saveBtn" class="tbtn savebtn saved" data-tooltip="Save this footprint's current text placement back to its library .kicad_mod (the original is backed up once to <file>.orig). adom-lbr then exports the saved placement to Fusion/Altium/OrCAD.">✓ Saved to library</button>     </div>     <div class="hud-foot">       Drag a label on the board, or edit X/Y. Saved into the <code>.kicad_mod</code> — adom-lbr carries it to Fusion/Altium/OrCAD.@@ -263,6 +304,19 @@         </div>       </div>     </main>+    <!-- Docked right details panel. Layers + Text HUDs are moved in here by JS;+         the Chip section (3D preview + metadata) tells you WHAT part this is. -->+    <div id="rightpanel">+      <button id="rpToggle" title="Show the details panel"><span>›</span><span>Details</span></button>+      <div id="rpInner">+        <div id="rpHead"><span>Details</span><button id="rpCollapse" title="Collapse the panel">›</button></div>+        <div id="chippanel">+          <div class="rp-sec-head">Chip</div>+          <div id="chip3dwrap"><img id="chip3d" alt="3D chip preview"><div id="chip3dempty">No 3D preview for this part</div></div>+          <div id="chipinfo"></div>+        </div>+      </div>+    </div>   </div>   <footer>rendered via service-kicad — standalone Rust, no local KiCad install</footer> @@ -319,6 +373,7 @@       setupLayers(s);       initPanZoom();       setupTextEditor(s);+      renderChipPanel(s);     }     setInterval(pollState,1000); pollState(); @@ -418,20 +473,13 @@       hud.style.display = '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'; }-    })();+    // Docked section: ▦ Layers expands the panel + jumps to Layers; ✕/▾ and a+    // header click collapse just that section.+    function _collapseSec(id,arrowId){ const h=document.getElementById(id); h.classList.toggle('collapsed'); if(arrowId)document.getElementById(arrowId).textContent=h.classList.contains('collapsed')?'▸':'▾'; }+    document.getElementById('layersBtn').onclick=()=>{ document.getElementById('rightpanel').classList.remove('collapsed'); const h=document.getElementById('layersHud'); h.classList.remove('collapsed'); document.getElementById('layersCollapse').textContent='▾'; h.scrollIntoView({block:'nearest'}); };+    document.getElementById('layersClose').onclick=()=>_collapseSec('layersHud','layersCollapse');+    document.getElementById('layersCollapse').onclick=()=>_collapseSec('layersHud','layersCollapse');+    document.getElementById('layersHead').addEventListener('click',e=>{ if(!e.target.closest('.hbtn')) document.getElementById('layersCollapse').click(); });     // 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 }; @@ -496,13 +544,21 @@       const btn=document.getElementById('textBtn'), hud=document.getElementById('textHud');       if(!s.footprintName || !s.hasMod || !s.text){ btn.style.display='none'; hud.style.display='none'; return; }       btn.style.display='inline-flex';-      if(s.footprintName!==_txtName){ _txtName=s.footprintName; _undo=[]; _redo=[]; }  // fresh part → fresh history+      const fresh = s.footprintName!==_txtName;+      if(fresh){ _txtName=s.footprintName; _undo=[]; _redo=[]; }   // fresh part → fresh history       if(!_txt.dirty){         if(s.text.reference) _txt.reference={...s.text.reference};         if(s.text.value) _txt.value={...s.text.value};       }       _txt.bbox = s.bbox || _txt.bbox;+      _canSave = (s.canSave!==false);       injectLabels(); updateHudTerms(); syncInputs(); updateStatus(); _refreshUndoBtns();+      hud.style.display='flex';   // always shown as a docked section in the right panel+      const cb=document.getElementById('autoOnOpen'); if(cb)cb.checked=autoOnOpenChecked();+      setSaveState(_canSave ? 'saved' : 'nofile');+      // While "Auto-layout on open" is checked, every freshly-opened footprint gets+      // auto-laid-out and auto-saved — so you can sweep the library fixing placement.+      if(fresh && autoOnOpenChecked() && _txt.reference && _txt.value){ autoLayout(true); }     }     function injectLabels(){       const sv=fpSvg(); if(!sv) return;@@ -570,26 +626,53 @@       if(!bad.length){ st.className='tstatus ok'; st.textContent='✓ Clear of the footprint (keep-out top '+b.miny.toFixed(2)+' / bottom '+b.maxy.toFixed(2)+' mm)'; }       else { st.className='tstatus warn'; st.textContent='⚠ '+bad.join(' & ')+' over the pads — Auto-layout to clear.'; }     }-    function autoLayout(){+    // Save state: edits go to the in-memory working copy (/text); "Save" persists+    // it to the library .kicad_mod (/save). 'saved' | 'unsaved' | 'saving' | 'nofile'.+    let _saveState='saved', _canSave=true;+    function setSaveState(st){ _saveState=st; const b=document.getElementById('saveBtn'); if(!b)return;+      if(st==='nofile'){ b.style.display='none'; return; }+      b.style.display=''; b.disabled=(st!=='unsaved'); b.className='tbtn savebtn '+st;+      b.textContent = st==='saving' ? 'Saving…' : st==='saved' ? '✓ Saved to library' : '● Save to library';+    }+    async function saveFootprint(quiet){+      if(_saveState==='saving' || !_canSave) return false;+      setSaveState('saving');+      try{ const r=await fetch('save',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'});+        if(r.ok){ setSaveState('saved'); if(!quiet)showToast('✓ Saved to the library footprint','ok'); return true; }+        const j=await r.json().catch(()=>({})); setSaveState('unsaved'); if(!quiet)showToast('✕ '+(j.error||'Save failed'),'err'); return false;+      }catch(e){ setSaveState('unsaved'); if(!quiet)showToast('✕ Save failed: '+e,'err'); return false; }+    }+    function _txtItem(p){ return {x:p.x, y:p.y, size:p.size}; }+    async function applyTextNow(){+      if(!_txt.reference||!_txt.value) return;+      try{ await fetch('text',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({reference:_txtItem(_txt.reference),value:_txtItem(_txt.value)})}); _txt.dirty=false; setSaveState('unsaved'); }catch(e){}+    }+    // Normalized text height: 1 mm default, scaled down so a label never dwarfs a tiny part.+    function _textSize(b){ const me=Math.max(b.maxx-b.minx, b.maxy-b.miny); return Math.round(Math.max(0.5,Math.min(1.0, me/2.5))*10)/10; }+    async function autoLayout(save){       const b=_txt.bbox; if(!b||!_txt.reference||!_txt.value)return;       _pushUndo(_snap());-      _txt.reference.x=0; _txt.reference.y=_r2(b.miny-1);-      _txt.value.x=0; _txt.value.y=_r2(b.maxy+1);-      _txt.dirty=true; injectLabels(); syncInputs(); updateStatus(); applyText();+      const sz=_textSize(b);+      _txt.reference.x=0; _txt.reference.y=_r2(b.miny-1); _txt.reference.size=sz;+      _txt.value.x=0; _txt.value.y=_r2(b.maxy+1); _txt.value.size=sz;+      _txt.dirty=true; injectLabels(); syncInputs(); updateStatus();+      await applyTextNow();+      if(save && _canSave) await saveFootprint(true);   // "auto-layout on open" → quiet auto-save     }+    function autoOnOpenChecked(){ try{ return localStorage.getItem('adomFpAutoOpen')==='1'; }catch(e){ return false; } }     let _applyTimer=null;-    function applyText(){+    function applyText(){   // debounced — edits go to the working copy; click Save to persist       if(!_txt.reference||!_txt.value) return;       clearTimeout(_applyTimer);-      _applyTimer=setTimeout(async()=>{-        try{ await fetch('text',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({reference:{x:_txt.reference.x,y:_txt.reference.y},value:{x:_txt.value.x,y:_txt.value.y}})}); _txt.dirty=false; showToast('✓ Placement saved into the .kicad_mod','ok'); }-        catch(e){ showToast('✕ Save failed: '+e,'err'); }-      },220);+      _applyTimer=setTimeout(()=>{ applyTextNow(); },220);     }     // HUD wiring-    document.getElementById('textBtn').onclick=()=>{ document.getElementById('textHud').style.display='flex'; };-    document.getElementById('textClose').onclick=()=>{ document.getElementById('textHud').style.display='none'; };-    document.getElementById('autoLayoutBtn').onclick=autoLayout;+    document.getElementById('textBtn').onclick=()=>{ document.getElementById('rightpanel').classList.remove('collapsed'); const h=document.getElementById('textHud'); h.classList.remove('collapsed'); h.scrollIntoView({block:'nearest'}); };+    document.getElementById('textClose').onclick=()=>document.getElementById('textHud').classList.toggle('collapsed');+    document.getElementById('textHead').addEventListener('click',e=>{ if(!e.target.closest('.hbtn')) document.getElementById('textHud').classList.toggle('collapsed'); });+    document.getElementById('autoLayoutBtn').onclick=()=>autoLayout(false);   // manual: update working copy; click Save to persist+    document.getElementById('saveBtn').onclick=()=>saveFootprint(false);+    document.getElementById('autoOnOpen').addEventListener('change',e=>{ try{ localStorage.setItem('adomFpAutoOpen', e.target.checked?'1':'0'); }catch{} if(e.target.checked) autoLayout(true); });     document.getElementById('undoBtn').onclick=undoText;     document.getElementById('redoBtn').onclick=redoText;     document.getElementById('toolSel').addEventListener('change',e=>{ try{ localStorage.setItem('adomFpTool',e.target.value); }catch{} updateHudTerms(); injectLabels(); updateStatus(); });@@ -607,11 +690,35 @@       row.querySelector('.ty').addEventListener('change',onInput);       row.querySelectorAll('.tnudge button').forEach(btn=>btn.onclick=()=>{ if(!_txt[w])return; _pushUndo(_snap()); _txt[w].y=_r2(_txt[w].y+(btn.dataset.d==='up'?-0.5:0.5)); _txt.dirty=true; _txt.sel=w; injectLabels(); syncInputs(); updateStatus(); applyText(); });     });-    (function(){ const hud=document.getElementById('textHud'), head=document.getElementById('textHead'); 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'; });-      head.addEventListener('pointerup',()=>drag=false);+    // ── dock the Layers + Text HUDs into the right panel + the Chip details ──+    (function(){+      const inner=document.getElementById('rpInner'), chip=document.getElementById('chippanel'), panel=document.getElementById('rightpanel');+      [document.getElementById('layersHud'), document.getElementById('textHud')].forEach(h=>{+        if(!h) return;+        ['position','right','top','left','bottom','width'].forEach(p=>h.style[p]='');   // drop floaty positioning+        h.style.display='none'; inner.insertBefore(h, chip);+      });+      function setPanel(open){ panel.classList.toggle('collapsed',!open); try{localStorage.setItem('adomFpPanel',open?'1':'0')}catch(e){} }+      document.getElementById('rpCollapse').onclick=()=>setPanel(false);+      document.getElementById('rpToggle').onclick=()=>setPanel(true);+      (function(){ let p=null; try{p=localStorage.getItem('adomFpPanel')}catch(e){} setPanel(p!=='0'); })();   // open by default     })();+    // Chip details (3D preview + metadata) — so you can see WHICH part this is.+    function renderChipPanel(s){+      const wrap=document.getElementById('chippanel'); if(!wrap) return;+      const c=s&&s.chip, img=document.getElementById('chip3d'), empty=document.getElementById('chip3dempty'), info=document.getElementById('chipinfo');+      if(!c||!c.mpn){ wrap.style.display='none'; return; }+      wrap.style.display='';+      if(c.has3d){ empty.style.display='none'; img.style.display='block'; img.src='api/chip3d?mpn='+encodeURIComponent(c.mpn); }+      else { img.style.display='none'; empty.style.display=''; img.removeAttribute('src'); }+      const bd=c.bodyDimensions, dim=(bd&&bd.x&&bd.y)?(bd.x+' × '+bd.y+(bd.thickness_max?' × '+bd.thickness_max:'')+' mm'):null;+      const rows=[['Package',c.package],['Body',dim],['Pads',(c.padCount!=null?c.padCount:(s.padCount||null))],['Family',c.family]].filter(r=>r[1]!=null&&r[1]!=='');+      info.innerHTML='<div class="ci-mpn">'+_esc(c.mpn)+'</div>'+        +(c.manufacturer?'<div class="ci-mfr">'+_esc(c.manufacturer)+'</div>':'')+        +(c.description?'<div class="ci-desc">'+_esc(c.description)+'</div>':'')+        +rows.map(r=>'<div class="ci-row"><span class="ci-k">'+r[0]+'</span><span class="ci-v">'+_esc(String(r[1]))+'</span></div>').join('')+        +(c.datasheet?'<a class="ci-ds" href="'+_esc(c.datasheet)+'" target="_blank" rel="noopener">Datasheet ↗</a>':'');+    }      // ── left chip-fetcher library rail ──────────────────────────────────────     // Lists every chip in the chip-fetcher library; click / ↑↓ / filter to load@@ -694,7 +801,9 @@       send(target){ const b=document.querySelector('.sbtn[data-target="'+target+'"]'); if(b){ b.click(); return 'send to '+target; } return 'no send target: '+target; },       toast(m){ showToast(String(m)); return 'toast: '+m; },       text(){ document.getElementById('textBtn').click(); return 'text editor open'; },-      textAuto(){ autoLayout(); return 'auto-layout: >NAME '+JSON.stringify(_txt.reference)+' >VALUE '+JSON.stringify(_txt.value); },+      textAuto(save){ autoLayout(!!save); return 'auto-layout'+(save?'+save':'')+': >NAME '+JSON.stringify(_txt.reference)+' >VALUE '+JSON.stringify(_txt.value); },+      textSave(){ saveFootprint(false); return 'saving'; },+      autoSaveOnOpen(on){ const v=(on!==false); try{localStorage.setItem('adomFpAutoOpen',v?'1':'0')}catch(e){} const cb=document.getElementById('autoOnOpen'); if(cb)cb.checked=v; return 'auto-layout-on-open '+(v?'ON':'OFF'); },       textSet(which,x,y){ const w=(which==='value'||which==='>VALUE')?'value':'reference'; if(!_txt[w])return 'no '+w; _pushUndo(_snap()); if(x!=null)_txt[w].x=_r2(+x); if(y!=null)_txt[w].y=_r2(+y); _txt.dirty=true; _txt.sel=w; injectLabels(); syncInputs(); updateStatus(); applyText(); return w+' → '+_txt[w].x+','+_txt[w].y; },       textState(){ return {reference:_txt.reference, value:_txt.value, bbox:_txt.bbox, tool:curTool(), terms:terms()}; },       textTool(t){ const v=['kicad','fusion','altium','orcad'].includes(t)?t:'kicad'; try{ localStorage.setItem('adomFpTool',v); }catch{} updateHudTerms(); injectLabels(); updateStatus(); return 'tool: '+v+' ('+terms().ref+' / '+terms().val+')'; },
src/main.rs+18−2
@@ -222,6 +222,7 @@ fn run() -> Result<()> {                         server::set_library_root(root.to_string_lossy().to_string());                     }                 }+                server::set_loaded_path(Some(f.to_string_lossy().to_string()));                 server::set_state(server::AppState {                     footprint_name: name,                     svg,@@ -417,8 +418,10 @@ pub(crate) fn first_footprint_name(content: &str) -> Option<String> { /// render the draggable text labels at the right spot. pub(crate) fn fp_text_state(km: &str) -> serde_json::Value {     let one = |kind: &str| -> serde_json::Value {+        // `(at x y [rot] [unlocked|locked])` — tolerate the trailing rotation and/or+        // KiCad's lock keyword (newer footprints write `(at 0 0 unlocked)`).         let at = Regex::new(&format!(-            r#"\(fp_text {kind}\s+(?:"[^"]*"|\S+)\s+\(at\s+(-?[\d.]+)\s+(-?[\d.]+)(?:\s+-?[\d.]+)?\)"#+            r#"\(fp_text {kind}\s+(?:"[^"]*"|\S+)\s+\(at\s+(-?[\d.]+)\s+(-?[\d.]+)[^)]*\)"#         )).unwrap();         match at.captures(km) {             Some(c) => {@@ -454,8 +457,9 @@ pub(crate) fn rewrite_fp_text(km: &str, refx: f64, refy: f64, valx: f64, valy: f     let r2 = |v: f64| (v * 100.0).round() / 100.0;     let mut out = km.to_string();     for (kind, x, y) in [("reference", r2(refx), r2(refy)), ("value", r2(valx), r2(valy))] {+        // Drop any trailing rotation / lock keyword → clean horizontal `(at x y)`.         let re = Regex::new(&format!(-            r#"(\(fp_text {kind}\s+(?:"[^"]*"|\S+)\s+\(at\s+)-?[\d.]+\s+-?[\d.]+(?:\s+-?[\d.]+)?(\s*\))"#+            r#"(\(fp_text {kind}\s+(?:"[^"]*"|\S+)\s+\(at\s+)-?[\d.]+\s+-?[\d.]+[^)]*(\))"#         )).unwrap();         let rep = format!("${{1}}{x} {y}${{2}}");         out = re.replace(&out, rep.as_str()).into_owned();@@ -463,6 +467,18 @@ pub(crate) fn rewrite_fp_text(km: &str, refx: f64, refy: f64, valx: f64, valy: f     out } +/// Rewrite the font `(size H W)` + `(thickness T)` of a `reference`/`value`+/// fp_text (thickness tracks size at ~15%). No-op if that text has no font block.+pub(crate) fn set_fp_text_size(km: &str, kind: &str, size: f64) -> String {+    let r2 = |v: f64| (v * 100.0).round() / 100.0;+    let (sz, th) = (r2(size), r2(size * 0.15));+    let re = Regex::new(&format!(+        r#"(\(fp_text {kind}\b[\s\S]*?\(size )[-\d.]+ [-\d.]+(\)[\s\S]*?\(thickness )[-\d.]+"#+    )).unwrap();+    let rep = format!("${{1}}{sz} {sz}${{2}}{th}");+    re.replace(km, rep.as_str()).into_owned()+}+ /// 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();
src/server.rs+112
@@ -29,6 +29,9 @@ static STATE: Mutex<Option<AppState>> = Mutex::new(None); /// Root of the chip-fetcher library (a folder of `<MPN>/` dirs) — powers the left /// library rail. Set from `serve --file <…/MPN/MPN.kicad_mod>`, else auto-detected. static LIBRARY_ROOT: Mutex<Option<String>> = Mutex::new(None);+/// On-disk path of the currently-loaded `.kicad_mod` — what `/save` writes back to+/// (so text-placement edits persist per-footprint). None for an in-memory push.+static LOADED_PATH: Mutex<Option<String>> = 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);@@ -45,6 +48,10 @@ pub fn set_library_root(p: String) {     *LIBRARY_ROOT.lock().unwrap() = Some(p); } +pub fn set_loaded_path(p: Option<String>) {+    *LOADED_PATH.lock().unwrap() = p;+}+ /// The library root: the explicitly-set one (from `serve --file`), else the first /// chip-fetcher library we can find on disk — so the rail + `?mpn=` work even when /// the app was launched without a file.@@ -129,6 +136,7 @@ fn load_chip(mpn: &str) -> bool {     let name = crate::first_footprint_name(&content).unwrap_or_else(|| mpn.to_string());     match crate::render::render_footprint_svg_with_paste(&content, crate::bbox_from_mod(&content)) {         Ok(svg) => {+            *LOADED_PATH.lock().unwrap() = Some(file.to_string_lossy().to_string());             set_state(AppState {                 footprint_name: name,                 svg,@@ -144,6 +152,66 @@ fn load_chip(mpn: &str) -> bool {     } } +/// The 3D preview PNG for the current chip (chip-thumbnailer output), if any.+fn chip_3d_png() -> Option<Vec<u8>> {+    let root = library_root()?;+    let mpn = STATE.lock().unwrap().as_ref().map(|s| s.mpn.clone()).unwrap_or_default();+    if mpn.is_empty() {+        return None;+    }+    let dir = std::path::Path::new(&root).join(&mpn);+    for cand in [+        format!("{mpn}-3d-iso-named.png"),+        format!("{mpn}-3d-iso.png"),+        format!("{mpn}-3d-iso-lg.png"),+    ] {+        if let Ok(b) = std::fs::read(dir.join(&cand)) {+            return Some(b);+        }+    }+    None+}++/// Chip metadata for the right-panel "Chip" details — so the user can see WHAT+/// part they're working on, not just an anonymous pad pattern. Merged from the+/// library's `<mpn>-footprint.extracted.json` and `info.json`.+fn chip_details(mpn: &str) -> Value {+    let root = match library_root() {+        Some(r) => r,+        None => return Value::Null,+    };+    if mpn.is_empty() {+        return Value::Null;+    }+    let dir = std::path::Path::new(&root).join(mpn);+    let read_json = |name: String| -> Value {+        std::fs::read_to_string(dir.join(name))+            .ok()+            .and_then(|s| serde_json::from_str::<Value>(&s).ok())+            .unwrap_or(Value::Null)+    };+    let ext = read_json(format!("{mpn}-footprint.extracted.json"));+    let info = read_json("info.json".to_string());+    // Prefer the extraction, fall back to info.json.+    let pick = |k: &str| -> Value {+        let a = ext.get(k).cloned().unwrap_or(Value::Null);+        if a.is_null() { info.get(k).cloned().unwrap_or(Value::Null) } else { a }+    };+    let has3d = dir.join(format!("{mpn}-3d-iso-named.png")).is_file()+        || dir.join(format!("{mpn}-3d-iso.png")).is_file();+    json!({+        "mpn": mpn,+        "manufacturer": pick("manufacturer"),+        "package": ext.get("package").cloned().unwrap_or(Value::Null),+        "bodyDimensions": ext.get("bodyDimensions").cloned().unwrap_or(Value::Null),+        "padCount": ext.get("padCount").cloned().unwrap_or(Value::Null),+        "family": info.get("family").cloned().unwrap_or(Value::Null),+        "description": pick("description"),+        "datasheet": info.get("datasheet_url").cloned().unwrap_or(Value::Null),+        "has3d": has3d,+    })+}+ fn state_json() -> String {     match &*STATE.lock().unwrap() {         Some(s) => {@@ -160,6 +228,10 @@ fn state_json() -> String {                 // keep-out bbox (pads ∪ rects) the Auto-layout button clears.                 "bbox": { "minx": minx, "miny": miny, "maxx": maxx, "maxy": maxy },                 "text": crate::fp_text_state(&s.kicad_mod),+                // For the right-panel "Chip" details (what part is this?).+                "chip": chip_details(&s.mpn),+                // Whether the placement editor can persist to disk (loaded from a file).+                "canSave": LOADED_PATH.lock().unwrap().is_some() && !s.kicad_mod.is_empty(),             })             .to_string()         }@@ -239,6 +311,12 @@ fn route(method: &Method, url: &str, body: &str) -> Response<std::io::Cursor<Vec             let path = std::path::Path::new(&home).join(".config/adom-chip-fetcher/projects.json");             json_resp(&std::fs::read_to_string(&path).unwrap_or_else(|_| "{\"projects\":{}}".to_string()))         }+        // The current chip's 3D preview PNG (serves state.mpn; the client cache-busts+        // with a ?mpn= query, which we ignore — the path is what's routed).+        (Method::Get, "/api/chip3d") => match chip_3d_png() {+            Some(b) => png(b),+            None => not_found(),+        },         (Method::Post, "/load-chip") => {             let mpn = serde_json::from_str::<Value>(body).ok()                 .and_then(|v| v.get("mpn").and_then(|x| x.as_str()).map(String::from))@@ -252,6 +330,8 @@ fn route(method: &Method, url: &str, body: &str) -> Response<std::io::Cursor<Vec             }         }         (Method::Post, "/load") => {+            // An in-memory push has no source file → can't /save back to disk.+            *LOADED_PATH.lock().unwrap() = None;             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@@ -346,6 +426,13 @@ fn route(method: &Method, url: &str, body: &str) -> Response<std::io::Cursor<Vec                     match st.as_mut() {                         Some(s) if !s.kicad_mod.is_empty() => {                             s.kicad_mod = crate::rewrite_fp_text(&s.kicad_mod, rx, ry, vx, vy);+                            // Optional font-size normalization (Auto-layout scales tiny parts).+                            if let Some(rs) = num("reference", "size") {+                                s.kicad_mod = crate::set_fp_text_size(&s.kicad_mod, "reference", rs);+                            }+                            if let Some(vs) = num("value", "size") {+                                s.kicad_mod = crate::set_fp_text_size(&s.kicad_mod, "value", vs);+                            }                             json_resp(&json!({ "ok": true, "text": crate::fp_text_state(&s.kicad_mod) }).to_string())                         }                         _ => json_err(422, "no .kicad_mod loaded to edit"),@@ -354,6 +441,28 @@ fn route(method: &Method, url: &str, body: &str) -> Response<std::io::Cursor<Vec                 _ => json_err(400, "need reference.{x,y} and value.{x,y}"),             }         }+        // Persist the current (text-edited) .kicad_mod back to its library file, so+        // placement changes stick per-footprint. Backs the original up once to+        // `<file>.orig` before the first overwrite.+        (Method::Post, "/save") => {+            let path = LOADED_PATH.lock().unwrap().clone();+            let km = STATE.lock().unwrap().as_ref().map(|s| s.kicad_mod.clone()).unwrap_or_default();+            match path {+                Some(p) if !km.is_empty() => {+                    let orig = format!("{p}.orig");+                    if !std::path::Path::new(&orig).exists() {+                        if let Ok(existing) = std::fs::read(&p) {+                            let _ = std::fs::write(&orig, existing);+                        }+                    }+                    match std::fs::write(&p, &km) {+                        Ok(_) => json_resp(&json!({ "ok": true, "path": p }).to_string()),+                        Err(e) => json_err(500, &e.to_string()),+                    }+                }+                _ => json_err(422, "no file to save (footprint was pushed, not loaded from disk)"),+            }+        }         (Method::Post, "/shutdown") => json_resp("{\"ok\":true}"),         _ if url.starts_with("/eval/") => {             let rest = &url["/eval/".len()..];@@ -392,6 +501,9 @@ fn svg(s: &str) -> Response<std::io::Cursor<Vec<u8>>> { fn json_resp(s: &str) -> Response<std::io::Cursor<Vec<u8>>> {     Response::from_string(s).with_header(hdr("application/json")) }+fn png(data: Vec<u8>) -> Response<std::io::Cursor<Vec<u8>>> {+    Response::from_data(data).with_header(hdr("image/png"))+} fn not_found() -> Response<std::io::Cursor<Vec<u8>>> {     Response::from_string("{\"error\":\"not found\"}").with_status_code(404).with_header(hdr("application/json")) }