app
Adom Footprint
Public Made by Adomby adom
KiCad footprint creator with layer HUD viewer and delivery to KiCad/Fusion 360
← Commit history
Publish 1.0.17
13 files changed
+286−47
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 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.* -+ -*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.* 
VERSION+1−1@@ -1 +1 @@-1.0.16+1.0.17
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"-}+}
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")) }