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.16
15 files changed
+620−43
.gitignore+5−4@@ -1,9 +1,10 @@ # Rust build output /target -# Compiled binary is built by build.sh at publish time, never committed-# (keeps the repo small and prevents stale binaries / accidental stub publishes).-/bin/adom-footprint- # Package tarballs *.tgz++# NOTE: bin/adom-footprint is intentionally NOT ignored. adompkg applies+# .gitignore on top of package.json "files", so ignoring it would drop the+# binary from the published tarball (breaking `adompkg install`). build.sh+# stages a fresh binary there before each publish; leave it untracked locally.
Cargo.lock+1−1@@ -4,7 +4,7 @@ version = 4 [[package]] name = "adom-footprint"-version = "1.0.15"+version = "1.0.16" dependencies = [ "anyhow", "clap",
Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-footprint"-version = "1.0.15"+version = "1.0.16" edition = "2021" description = "Create, preview, lint, and deliver KiCad footprints (.kicad_mod) with interactive FpView viewer"
README.md+12−2@@ -3,9 +3,19 @@ Standalone Rust CLI + live app for **KiCad PCB footprints**. Generates `.kicad_mod` files natively in Rust, renders them as dark-themed SVGs through the shared **service-kicad** container, and serves an AI-drivable live viewer.-No Node, no gallia, no local KiCad install.+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.*++++*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.*++++*The paste-dots layer: 300µm solder-paste dispensing positions, computed for every footprint (off by default).* ## What it does
VERSION+1−1@@ -1 +1 @@-1.0.15+1.0.16
bin/adom-footprint⋯ 1 unchanged line ⋯
docs/screenshot-editor.pngadded⋯ 1 unchanged line ⋯
docs/screenshot-paste-dots.pngadded⋯ 1 unchanged line ⋯
docs/screenshot-viewer.png⋯ 1 unchanged line ⋯
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "adom-footprint",- "version": "1.0.15",+ "version": "1.0.16", "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.15",+ "version": "1.0.16", "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+365−27@@ -70,24 +70,47 @@ .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:radial-gradient(circle at 50% 42%,#121a26 0%,#0d1117 72%); overflow:hidden; position:relative; height:64vh; cursor:grab; }+ /* Full-bleed: the board canvas owns the whole layout area — no centered+ "card" wrapper (that was a holdover from the small thumbnail-viewer page). */+ main { flex:1; display:flex; min-height:0; padding:0; }+ .card { background:transparent; border:0; border-radius:0; padding:0; box-shadow:none; max-width:none; width:100%; flex:1; display:flex; flex-direction:column; min-height:0; }+ #content { flex:1; display:flex; flex-direction:column; min-height:0; }+ .card .name, .card .meta, .card .desc { display:none; } /* name + pad count already live in the top header */+ /* Full-bleed board canvas: no border, no rounding, edge-to-edge. */+ .svgbox { border:0; border-radius:0; background:radial-gradient(circle at 50% 42%,#121a26 0%,#0d1117 72%); overflow:hidden; position:relative; flex:1; min-height:0; cursor:grab; }+ /* ── left chip-fetcher library rail (in-flow: pushes the canvas, no overlay) ── */+ .bodyrow { display:flex; flex:1; min-height:0; min-width:0; }+ main { min-width:0; }+ #chiprail { display:flex; align-items:stretch; flex:0 0 auto; border-right:1px solid var(--border); background:var(--panel); }+ #chiprail.collapsed { align-items:center; }+ #chiprailToggle { display:none; align-items:center; gap:7px; background:var(--panel2); color:var(--muted); border:0; border-right:1px solid var(--border); padding:11px 8px; cursor:pointer; font-size:11.5px; font-weight:700; writing-mode:vertical-rl; letter-spacing:.6px; height:100%; }+ #chiprailToggle:hover { color:#fff; background:#1a2230; }+ #chiprailToggle .cr-arrow { writing-mode:horizontal-tb; }+ #chiprail.collapsed #chiprailToggle { display:flex; }+ #chiprail.collapsed #chiplistWrap { display:none; }+ #chiplistWrap { width:232px; background:var(--panel); display:flex; flex-direction:column; }+ .cr-head { 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); letter-spacing:.4px; text-transform:uppercase; }+ #chiprailClose { background:transparent; border:0; color:var(--muted); font-size:16px; cursor:pointer; padding:0 4px; line-height:1; }+ #chiprailClose:hover { color:#fff; }+ .cr-search, .cr-project { margin:7px 10px 2px; padding:5px 8px; background:#0a0e14; color:var(--text); border:1px solid var(--border); border-radius:7px; font-size:11.5px; }+ .cr-search:focus, .cr-project:focus { outline:none; border-color:var(--accent2); }+ .cr-project { margin-top:3px; cursor:pointer; }+ #chiplist { flex:1; overflow:auto; outline:none; }+ .cr-item { padding:7px 12px; cursor:pointer; font-size:12.5px; color:#aeb6c2; border-left:2px solid transparent; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }+ .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); } .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; } .zoomtools button { width:30px; height:30px; border-radius:8px; border:1px solid var(--border); background:var(--surface2,#1c2230); color:#cfd8e2; font-size:16px; cursor:pointer; display:grid; place-items:center; } .zoomtools button:hover { border-color:var(--accent2); color:var(--accent2); }+ .zoomtools button:focus, .zoomtools button:focus-visible { outline:none; } .panhint { position:absolute; right:10px; bottom:9px; color:var(--text3); font-size:11px; z-index:5; pointer-events:none; } #svgbox [data-pad] { cursor:pointer; } #svgbox [data-pad]:hover > * { stroke:#fff !important; stroke-width:0.12 !important; }@@ -101,6 +124,39 @@ box-shadow:0 12px 40px rgba(0,0,0,0.55); } #tt.show { opacity:1; } [data-tooltip] { cursor:help; }++ /* ── >NAME / >VALUE editable text labels (overlaid in footprint space) ── */+ .fp-textlabel { cursor:grab; }+ .fp-textlabel.dragging { cursor:grabbing; }+ .fp-textlabel text { paint-order:stroke; stroke:#0d1117; stroke-width:0.22; font-family:monospace; font-weight:700; user-select:none; }+ .fp-textlabel.ref text { fill:#36e08a; }+ .fp-textlabel.val text { fill:#e6b84a; }+ .fp-textlabel rect { fill:transparent; }+ .fp-textlabel.sel rect { fill:rgba(0,184,177,0.08); stroke:var(--accent2); stroke-width:0.05; }+ /* Text HUD controls */+ #textBody .tbtn { width:100%; box-sizing:border-box; border:1px solid var(--border); background:var(--surface2,#1c2230); color:#cfd8e2; border-radius:8px; padding:7px; font-size:12.5px; cursor:pointer; }+ #textBody .tbtn:hover { border-color:var(--accent2); color:var(--accent2); }+ #textBody .tbtn-primary { background:linear-gradient(180deg,#10485f,#0e3346); color:#aef3ec; border-color:#1d6f7a; font-weight:700; margin-bottom:10px; }+ #textBody .trow { display:flex; align-items:center; gap:6px; margin:7px 0; font-size:12px; }+ #textBody .tlab { width:64px; display:flex; align-items:center; gap:5px; color:#cfd8e2; font-family:monospace; }+ #textBody .tdot { width:9px; height:9px; border-radius:2px; flex:0 0 9px; }+ #textBody .tdot.ref { background:#36e08a; } #textBody .tdot.val { background:#e6b84a; }+ #textBody label { display:flex; align-items:center; gap:3px; color:var(--text3); font-size:10px; }+ #textBody input { width:46px; background:#0d1117; border:1px solid var(--border); border-radius:6px; color:#e6edf3; padding:3px 4px; font-size:11px; }+ #textBody input:focus { outline:none; border-color:var(--accent2); }+ #textBody .tnudge { display:flex; gap:2px; }+ #textBody .tnudge button { width:20px; height:22px; border:1px solid var(--border); background:var(--surface2,#1c2230); color:#cfd8e2; border-radius:5px; cursor:pointer; font-size:11px; line-height:1; }+ #textBody .tnudge button:hover { border-color:var(--accent2); color:var(--accent2); }+ #textBody .tstatus { margin-top:9px; font-size:11.5px; border-radius:6px; padding:6px 8px; }+ #textBody .tstatus.ok { background:rgba(54,224,138,0.10); color:#7ef0b6; border:1px solid rgba(54,224,138,0.3); }+ #textBody .tstatus.warn { background:rgba(230,160,74,0.12); color:#f0c477; border:1px solid rgba(230,160,74,0.35); }+ #textBody .ttoolrow { margin:0 0 10px; padding-bottom:9px; border-bottom:1px solid var(--border); }+ #textBody select#toolSel { flex:1; background:#0d1117; border:1px solid var(--border); border-radius:6px; color:#e6edf3; padding:4px 6px; font-size:12px; cursor:pointer; }+ #textBody select#toolSel:focus { outline:none; border-color:var(--accent2); }+ #textBody .tterm { font-family:monospace; }+ #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; } </style> </head> <body>@@ -122,7 +178,8 @@ <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>+ <button class="layers-reopen" id="layersBtn" style="display:none" data-tooltip="Show the Layers panel — toggle copper, assembly, courtyard, paste apertures, and the 300µm solder-paste dots (off by default).">▦ Layers</button>+ <button class="layers-reopen" id="textBtn" style="display:none" data-tooltip="Edit the >NAME (RefDes) and >VALUE text placement — drag the labels on the board, nudge with arrows/inputs, or hit Auto-layout to snap them clear of the pads. Saved into the .kicad_mod so every tool (KiCad/Fusion/Altium/OrCAD) shows it.">⌖ Text</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>@@ -138,22 +195,76 @@ </div> <div class="hud-body" id="layersBody"></div> <div class="hud-foot">- <b>InstaPCB</b> jets paste dot-by-dot — these 300µm positions travel <i>with</i> the footprint across the Adom ecosystem.+ Solder-paste dispensing dots — <b>300µm</b> each — computed for every footprint and carried <i>with</i> it across the Adom ecosystem. Off by default.+ </div>+ </div>++ <div class="hud" id="textHud" style="display:none; right:22px; bottom:30px; width:258px">+ <div class="hud-head" id="textHead">+ <span class="grip">⠿</span>+ <span class="htitle">Text placement</span>+ <button class="hbtn" id="textClose" data-tooltip="Hide (reopen from the ⌖ Text button).">✕</button>+ </div>+ <div class="hud-body" id="textBody">+ <div class="trow ttoolrow">+ <span class="tlab">Tool</span>+ <select id="toolSel" data-tooltip="Show the labels using your EDA tool's own wording — the placement geometry is identical, only the names differ. KiCad: Reference/Value · Fusion 360 / EAGLE: >NAME/>VALUE · Altium: Designator/Comment · OrCAD: RefDes/Value.">+ <option value="kicad">KiCad</option>+ <option value="fusion">Fusion 360 / EAGLE</option>+ <option value="altium">Altium</option>+ <option value="orcad">OrCAD</option>+ </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>+ <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>+ </div>+ <div class="trow" data-which="reference">+ <span class="tlab"><span class="tdot ref"></span><span class="tterm"></span></span>+ <label>X<input type="number" step="0.1" class="tx"></label>+ <label>Y<input type="number" step="0.1" class="ty"></label>+ <span class="tnudge"><button data-d="up">↑</button><button data-d="down">↓</button></span>+ </div>+ <div class="trow" data-which="value">+ <span class="tlab"><span class="tdot val"></span><span class="tterm"></span></span>+ <label>X<input type="number" step="0.1" class="tx"></label>+ <label>Y<input type="number" step="0.1" class="ty"></label>+ <span class="tnudge"><button data-d="up">↑</button><button data-d="down">↓</button></span>+ </div>+ <div id="textStatus" class="tstatus"></div>+ </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. </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 class="bodyrow">+ <!-- Left chip-fetcher library rail. Collapsed by default; expand to browse /+ filter / arrow-nav the whole library. The AI can deep-link via ?mpn=. -->+ <div id="chiprail" class="collapsed" style="display:none">+ <button id="chiprailToggle" title="Chip library — expand to browse the chip-fetcher library (↑/↓)"><span class="cr-arrow">▸</span><span>Library</span></button>+ <div id="chiplistWrap">+ <div class="cr-head"><span id="cr-count">Library</span><button id="chiprailClose" title="Collapse">‹</button></div>+ <input id="cr-search" class="cr-search" placeholder="Filter chips…" autocomplete="off" spellcheck="false">+ <select id="cr-project" class="cr-project" autocomplete="off" title="Filter by chip-fetcher project"><option value="">All chips</option></select>+ <div id="chiplist" tabindex="0"></div>+ <div class="cr-hint">↑ / ↓ to switch · click to load · ?mpn= to deep-link</div> </div> </div>- </main>- <footer>rendered via service-kicad — standalone Rust, no local KiCad, no gallia</footer>+ <main>+ <div class="card" id="card">+ <div class="empty" id="empty">Waiting for a footprint… run <code>adom-footprint create</code>, open the Library, 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>+ </div>+ <footer>rendered via service-kicad — standalone Rust, no local KiCad install</footer> <div id="tt"></div> <div id="toast"></div>@@ -207,6 +318,7 @@ document.getElementById('sendto').style.display=(has&&s.hasMod)?'inline-flex':'none'; setupLayers(s); initPanZoom();+ setupTextEditor(s); } setInterval(pollState,1000); pollState(); @@ -249,7 +361,7 @@ {key:'courtyard', name:'Courtyard', kind:'group', sel:'.adom-layer-courtyard', sw:'#FF26E2', ring:true}, {key:'drill', name:'Drill', kind:'group', sel:'.adom-layer-drill', sw:'#888888', 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},+ {key:'paste-dots',name:'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 {}; } }@@ -286,23 +398,24 @@ btn.style.display='inline-flex'; const body=document.getElementById('layersBody'); body.innerHTML=''; LAYER_DEFS.forEach(def=>{- // Standard layers default visible (unless turned off); the InstaPCB paste-dots- // overlay is an opt-in preview → default OFF so it never obscures the copper.+ // Standard layers default visible (unless turned off); the paste-dots+ // overlay is opt-in → default OFF so it never obscures the copper (the+ // dots are always computed regardless — this only toggles their display). const on = def.special ? (layerState[def.key]===true) : (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.className='layer-row '+(on?'on':'off'); 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>'+ +'<span class="lname">'+def.name+'</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');+ // Layers is the primary panel — always shown by default on load.+ hud.style.display = 'flex'; } // Header buttons document.getElementById('layersBtn').onclick=()=>{ const h=document.getElementById('layersHud'); h.style.display='flex'; layerState.__closed=false; saveLayerState(layerState); };@@ -352,6 +465,221 @@ m.onclick=e=>{if(e.target===m)m.classList.remove('show')}; } + // ── >NAME / >VALUE text-placement editor ──────────────────────────────+ // Drag the labels on the board, edit X/Y, nudge, or Auto-layout. Saved into+ // the loaded .kicad_mod (POST /text) so delivery + export carry it.+ let _txt = { reference:null, value:null, bbox:null, sel:'reference', dirty:false };+ const _r2 = v => Math.round((v||0)*100)/100;+ function fpSvg(){ return document.querySelector('#svgbox svg'); }+ // Each EDA names the two texts differently — show the user's tool's wording+ // (the geometry/.kicad_mod is identical; reference→RefDes, value→Value).+ const TOOL_TERMS={ kicad:{ref:'Reference',val:'Value'}, fusion:{ref:'>NAME',val:'>VALUE'}, altium:{ref:'Designator',val:'Comment'}, orcad:{ref:'RefDes',val:'Value'} };+ function curTool(){ try{ return localStorage.getItem('adomFpTool')||'kicad'; }catch{ return 'kicad'; } }+ function terms(){ return TOOL_TERMS[curTool()]||TOOL_TERMS.kicad; }+ function updateHudTerms(){ const t=terms();+ [['reference',t.ref],['value',t.val]].forEach(([w,term])=>{+ const row=document.querySelector('#textBody .trow[data-which="'+w+'"]'); if(!row)return;+ const tt=row.querySelector('.tterm'); if(tt)tt.textContent=term;+ row.style.display=(_txt[w]&&_txt[w].hidden)?'none':''; // hide the row for a hidden label+ });+ const s=document.getElementById('toolSel'); if(s)s.value=curTool(); }+ // Undo / redo for placement edits (Ctrl/Cmd+Z · Ctrl/Cmd+Shift+Z).+ let _undo=[], _redo=[], _txtName=null;+ function _snap(){ return { reference:_txt.reference?{..._txt.reference}:null, value:_txt.value?{..._txt.value}:null }; }+ function _pushUndo(before){ if(!before||(!before.reference&&!before.value))return; _undo.push(before); if(_undo.length>60)_undo.shift(); _redo=[]; _refreshUndoBtns(); }+ function _refreshUndoBtns(){ const u=document.getElementById('undoBtn'),r=document.getElementById('redoBtn'); if(u)u.disabled=!_undo.length; if(r)r.disabled=!_redo.length; }+ function _restore(s){ if(!s)return; if(s.reference)_txt.reference={...s.reference}; if(s.value)_txt.value={...s.value}; _txt.dirty=true; injectLabels(); syncInputs(); updateStatus(); applyText(); }+ function undoText(){ if(!_undo.length){ showToast('Nothing to undo'); return 'nothing to undo'; } _redo.push(_snap()); _restore(_undo.pop()); _refreshUndoBtns(); showToast('↶ Undid placement change'); return 'undone'; }+ function redoText(){ if(!_redo.length){ showToast('Nothing to redo'); return 'nothing to redo'; } _undo.push(_snap()); _restore(_redo.pop()); _refreshUndoBtns(); showToast('↷ Redid placement change'); return 'redone'; }++ function setupTextEditor(s){+ 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+ 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;+ injectLabels(); updateHudTerms(); syncInputs(); updateStatus(); _refreshUndoBtns();+ }+ function injectLabels(){+ const sv=fpSvg(); if(!sv) return;+ sv.querySelectorAll('.fp-textlabel').forEach(e=>e.remove());+ const NS='http://www.w3.org/2000/svg', _t=terms();+ [['reference',_t.ref,_txt.reference],['value',_t.val,_txt.value]].forEach(([which,label,pos])=>{+ if(!pos || pos.hidden) return; // don't draw hidden labels (vendor value at 0,0)+ const sz=Math.max(0.8,pos.size||1);+ const g=document.createElementNS(NS,'g'); g.setAttribute('class','fp-textlabel '+(which==='reference'?'ref':'val')); g.dataset.which=which;+ const w=label.length*0.62*sz, h=sz*1.5;+ const r=document.createElementNS(NS,'rect'); r.setAttribute('x',pos.x-w/2); r.setAttribute('y',pos.y-h/2); r.setAttribute('width',w); r.setAttribute('height',h); r.setAttribute('rx',0.2);+ const t=document.createElementNS(NS,'text'); t.setAttribute('x',pos.x); t.setAttribute('y',pos.y); t.setAttribute('font-size',sz); t.setAttribute('text-anchor','middle'); t.setAttribute('dominant-baseline','central'); t.textContent=label;+ g.appendChild(r); g.appendChild(t); sv.appendChild(g); wireDrag(g);+ });+ sv.querySelectorAll('.fp-textlabel').forEach(g=>g.classList.toggle('sel', g.dataset.which===_txt.sel));+ recomputeViewBox();+ }+ // Grow the SVG viewBox to enclose the footprint AND the (possibly far-flung)+ // text labels + a margin, so the labels never clip and "fit" shows everything.+ function recomputeViewBox(){+ const sv=fpSvg(); if(!sv||!_txt.bbox) return;+ let minx=_txt.bbox.minx, miny=_txt.bbox.miny, maxx=_txt.bbox.maxx, maxy=_txt.bbox.maxy;+ sv.querySelectorAll('.fp-textlabel text').forEach(t=>{ try{ const b=t.getBBox(); minx=Math.min(minx,b.x); miny=Math.min(miny,b.y); maxx=Math.max(maxx,b.x+b.width); maxy=Math.max(maxy,b.y+b.height); }catch(e){} });+ const px=(maxx-minx)*0.05+0.6, py=(maxy-miny)*0.05+0.6;+ sv.setAttribute('viewBox',(minx-px)+' '+(miny-py)+' '+(maxx-minx+2*px)+' '+(maxy-miny+2*py));+ sv.setAttribute('preserveAspectRatio','xMidYMid meet');+ }+ function clientToMm(e){+ const sv=fpSvg(); if(!sv||!sv.getScreenCTM) return null;+ const m=sv.getScreenCTM(); if(!m) return null;+ const pt=sv.createSVGPoint(); pt.x=e.clientX; pt.y=e.clientY;+ const p=pt.matrixTransform(m.inverse()); return {x:p.x,y:p.y};+ }+ function moveLabel(which){+ const sv=fpSvg(); if(!sv)return; const g=sv.querySelector('.fp-textlabel[data-which="'+which+'"]'); if(!g)return;+ const pos=_txt[which], t=g.querySelector('text'), r=g.querySelector('rect');+ t.setAttribute('x',pos.x); t.setAttribute('y',pos.y);+ const w=+r.getAttribute('width'), h=+r.getAttribute('height');+ r.setAttribute('x',pos.x-w/2); r.setAttribute('y',pos.y-h/2);+ }+ function wireDrag(g){+ const which=g.dataset.which;+ g.addEventListener('pointerdown',e=>{+ e.stopPropagation(); e.preventDefault(); // don't pan the board+ _txt.sel=which; injectLabels();+ const gg=fpSvg().querySelector('.fp-textlabel[data-which="'+which+'"]');+ gg.classList.add('dragging'); gg.setPointerCapture(e.pointerId);+ const start=clientToMm(e), orig={..._txt[which]}, before=_snap(); let moved=false;+ const move=ev=>{ const m=clientToMm(ev); if(!m||!start)return; _txt[which].x=_r2(orig.x+(m.x-start.x)); _txt[which].y=_r2(orig.y+(m.y-start.y)); moved=true; _txt.dirty=true; moveLabel(which); syncInputs(); updateStatus(); };+ const up=()=>{ gg.classList.remove('dragging'); gg.removeEventListener('pointermove',move); gg.removeEventListener('pointerup',up); if(moved) _pushUndo(before); recomputeViewBox(); applyText(); };+ gg.addEventListener('pointermove',move); gg.addEventListener('pointerup',up);+ });+ }+ function syncInputs(){+ document.querySelectorAll('#textBody .trow[data-which]').forEach(row=>{ const p=_txt[row.dataset.which]; if(!p)return; row.querySelector('.tx').value=p.x; row.querySelector('.ty').value=p.y; });+ }+ function updateStatus(){+ const st=document.getElementById('textStatus'), b=_txt.bbox, _t=terms();+ if(!b){ st.className='tstatus'; st.textContent=''; return; }+ const r=_txt.reference, v=_txt.value, rVis=r&&!r.hidden, vVis=v&&!v.hidden;+ if(!rVis&&!vVis){ st.className='tstatus'; st.textContent='Both labels are hidden in this footprint.'; return; }+ const bad=[];+ if(rVis && !(r.y<=b.miny)) bad.push(_t.ref);+ if(vVis && !(v.y>=b.maxy)) bad.push(_t.val);+ 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(){+ 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();+ }+ let _applyTimer=null;+ function applyText(){+ 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);+ }+ // 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('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(); });+ // Keyboard undo/redo (ignored while typing in an input/select).+ document.addEventListener('keydown',e=>{+ if(e.target&&/^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName)) return;+ const z=(e.key==='z'||e.key==='Z');+ if((e.ctrlKey||e.metaKey)&&z&&!e.shiftKey){ e.preventDefault(); undoText(); }+ else if((e.ctrlKey||e.metaKey)&&((e.key==='y'||e.key==='Y')||(z&&e.shiftKey))){ e.preventDefault(); redoText(); }+ });+ document.querySelectorAll('#textBody .trow[data-which]').forEach(row=>{+ const w=row.dataset.which;+ const onInput=()=>{ if(!_txt[w])return; _pushUndo(_snap()); _txt[w].x=_r2(parseFloat(row.querySelector('.tx').value)||0); _txt[w].y=_r2(parseFloat(row.querySelector('.ty').value)||0); _txt.dirty=true; _txt.sel=w; injectLabels(); updateStatus(); applyText(); };+ row.querySelector('.tx').addEventListener('change',onInput);+ 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);+ })();++ // ── left chip-fetcher library rail ──────────────────────────────────────+ // Lists every chip in the chip-fetcher library; click / ↑↓ / filter to load+ // any footprint. The AI deep-links via ?mpn=<MPN>; navigating updates the URL.+ function _esc(s){ return String(s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c])); }+ let chips=[], projects={}, projFilter='', search='', shown=[], chipIdx=-1, loadedMpn=null, navT=null, _urlChecked=false;+ const rail=document.getElementById('chiprail'), chiplist=document.getElementById('chiplist'), projSel=document.getElementById('cr-project'), crSearch=document.getElementById('cr-search');+ function applyFilter(){+ const q=search.trim().toLowerCase();+ const base=(projFilter&&projects[projFilter])?chips.filter(c=>(projects[projFilter].mpns||[]).includes(c.mpn)):chips.slice();+ shown=base.filter(c=>!q||c.mpn.toLowerCase().includes(q));+ chipIdx=shown.findIndex(c=>c.current);+ }+ function renderChips(){+ applyFilter();+ chiplist.innerHTML=shown.map(c=>'<div class="cr-item'+(c.current?' cur':'')+'" data-mpn="'+_esc(c.mpn)+'">'+_esc(c.mpn)+'</div>').join('')||'<div class="cr-hint" style="border:0">no matching chips</div>';+ document.getElementById('cr-count').textContent=(projFilter||'Library')+' · '+shown.length;+ const cur=chiplist.querySelector('.cr-item.cur'); if(cur)cur.scrollIntoView({block:'nearest'});+ }+ function renderProjectOptions(){+ const names=Object.keys(projects).map(n=>[n,(projects[n].mpns||[]).filter(m=>chips.some(c=>c.mpn===m)).length]).filter(a=>a[1]>0).sort((a,b)=>a[0].toLowerCase().localeCompare(b[0].toLowerCase()));+ projSel.innerHTML='<option value="">All chips ('+chips.length+')</option>'+names.map(a=>'<option value="'+_esc(a[0])+'">'+_esc(a[0])+' ('+a[1]+')</option>').join('');+ if(projFilter&&!names.some(a=>a[0]===projFilter))projFilter='';+ projSel.value=projFilter;+ }+ async function loadChips(){+ try{+ const [cj,pj]=await Promise.all([+ fetch('api/chips',{cache:'no-store'}).then(r=>r.json()),+ fetch('api/projects',{cache:'no-store'}).then(r=>r.json()).catch(()=>({projects:{}})),+ ]);+ chips=cj.chips||[]; projects=pj.projects||{};+ if(!chips.length){ rail.style.display='none'; return; } // nothing to browse+ rail.style.display='';+ renderProjectOptions(); renderChips();+ if(!_urlChecked){ _urlChecked=true; const want=new URLSearchParams(location.search).get('mpn');+ if(want && chips.some(c=>c.mpn===want)){ const cur=(window._lastState&&window._lastState.mpn)||''; if(want!==cur) loadChipMpn(want); } }+ }catch(e){}+ }+ function setCurrent(mpn){ chips.forEach(c=>c.current=(c.mpn===mpn)); renderChips(); }+ function updateUrl(mpn){ try{ const u=new URL(location.href); u.searchParams.set('mpn',mpn); history.replaceState(null,'',u); }catch(e){} }+ async function loadChipMpn(mpn){+ if(!mpn||mpn===loadedMpn)return;+ const prev=loadedMpn; loadedMpn=mpn;+ try{ const r=await fetch('load-chip',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({mpn})});+ if(!r.ok){ loadedMpn=prev; showToast('✕ '+mpn+' not in library','err'); return; }+ _txt.dirty=false; updateUrl(mpn); pollState(); }+ catch(e){ loadedMpn=prev; }+ }+ // Highlight instantly; debounce the heavier load so ↑/↓ browsing only loads the chip you LAND on.+ function selectChip(mpn){ if(!mpn)return; setCurrent(mpn); clearTimeout(navT); navT=setTimeout(()=>{navT=null;loadChipMpn(mpn);},260); }+ function switchByOffset(d){ if(!shown.length)return; let i=chipIdx<0?(d>0?0:shown.length-1):chipIdx+d; i=Math.max(0,Math.min(shown.length-1,i)); selectChip(shown[i].mpn); }+ chiplist.addEventListener('click',e=>{const it=e.target.closest('.cr-item');if(it)selectChip(it.dataset.mpn);});+ projSel.addEventListener('change',()=>{projFilter=projSel.value;renderChips();chiplist.focus();});+ crSearch.addEventListener('input',()=>{search=crSearch.value;renderChips();});+ crSearch.addEventListener('keydown',e=>{ if(e.key==='ArrowDown'){e.preventDefault();switchByOffset(1);} else if(e.key==='ArrowUp'){e.preventDefault();switchByOffset(-1);} else if(e.key==='Enter'){e.preventDefault(); if(shown[chipIdx])loadChipMpn(shown[chipIdx].mpn); else if(shown[0])selectChip(shown[0].mpn);} });+ function setRail(open){ rail.classList.toggle('collapsed',!open); try{localStorage.setItem('adomFpChipRail',open?'1':'0')}catch(e){} if(open){loadChips();setTimeout(()=>crSearch.focus(),30);} }+ document.getElementById('chiprailToggle').onclick=()=>setRail(true);+ document.getElementById('chiprailClose').onclick=()=>setRail(false);+ (function(){let p=null;try{p=localStorage.getItem('adomFpChipRail')}catch(e){} setRail(p==='1');})(); // collapsed by default+ document.addEventListener('keydown',e=>{+ if(e.target&&/^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName))return;+ if(rail.classList.contains('collapsed'))return;+ if(e.key==='ArrowDown'){e.preventDefault();switchByOffset(1);}+ else if(e.key==='ArrowUp'){e.preventDefault();switchByOffset(-1);}+ });+ loadChips(); // populate immediately (so ?mpn deep-link works + rail hides if empty)+ // ── AI-driving API (ai-driven-apps contract) — drive the real UI from the CLI ── window.ui = { layer(name, on){@@ -365,6 +693,16 @@ zoom(dir){ const b=document.getElementById(dir==='in'?'fpZin':dir==='out'?'fpZout':'fpFit'); if(b)b.click(); return 'zoom '+dir; }, 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); },+ 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+')'; },+ undo(){ return undoText(); },+ redo(){ return redoText(); },+ loadChip(mpn){ if(!mpn)return 'mpn required'; setCurrent(mpn); loadChipMpn(mpn); return 'loading '+mpn; },+ chips(){ return chips.map(c=>c.mpn); },+ library(open){ setRail(open!==false&&open!=='off'); return 'library '+(open!==false?'open':'closed'); }, state(){ return window._lastState||{}; }, list(){ return Object.keys(window.ui).filter(k=>typeof window.ui[k]==='function'); } };
src/main.rs+68−2@@ -18,7 +18,7 @@ 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)")]+#[command(name = "adom-footprint", version, about = "Create and preview KiCad PCB footprints (standalone Rust)")] struct Cli { #[command(subcommand)] command: Cmd,@@ -207,12 +207,27 @@ fn run() -> Result<()> { 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))?;+ // Always bake the solder-paste dot group (same as /load) so the+ // paste-dots layer is present and toggleable in all cases.+ let svg = render::render_footprint_svg_with_paste(&content, bbox_from_mod(&content))?;+ // When launched on a chip-fetcher library chip+ // (<root>/<MPN>/<MPN>*.kicad_mod), wire the left library rail: mark+ // this chip current and set the root so the rail lists its siblings.+ let mut mpn = String::new();+ if let Some(dir) = f.parent() {+ if let Some(dname) = dir.file_name().and_then(|s| s.to_str()) {+ mpn = dname.to_string();+ }+ if let Some(root) = dir.parent() {+ server::set_library_root(root.to_string_lossy().to_string());+ }+ } server::set_state(server::AppState { footprint_name: name, svg, pad_count: content.matches("(pad ").count(), kicad_mod: content,+ mpn, ..Default::default() }); }@@ -397,6 +412,57 @@ pub(crate) fn first_footprint_name(content: &str) -> Option<String> { if name.is_empty() { None } else { Some(name.to_string()) } } +/// Current RefDes (`reference`/`>NAME`) and Value (`value`/`>VALUE`) text+/// positions + size, parsed from a `.kicad_mod` — fed to the live app so it can+/// 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 {+ let at = Regex::new(&format!(+ r#"\(fp_text {kind}\s+(?:"[^"]*"|\S+)\s+\(at\s+(-?[\d.]+)\s+(-?[\d.]+)(?:\s+-?[\d.]+)?\)"#+ )).unwrap();+ match at.captures(km) {+ Some(c) => {+ let x: f64 = c[1].parse().unwrap_or(0.0);+ let y: f64 = c[2].parse().unwrap_or(0.0);+ let size = Regex::new(&format!(r#"\(fp_text {kind}\b[\s\S]*?\(size\s+(-?[\d.]+)"#))+ .unwrap()+ .captures(km)+ .and_then(|m| m[1].parse::<f64>().ok())+ .unwrap_or(1.0);+ // Hidden? Look for a `hide` keyword within this fp_text's block (up+ // to the next fp_text/pad) — common in vendor footprints (value hidden+ // at 0,0). The editor skips hidden labels so they don't garble.+ let start = c.get(0).map(|m| m.start()).unwrap_or(0);+ let tail = &km[start..];+ let end = tail+ .get(1..)+ .and_then(|t| t.find("(fp_text ").map(|i| i + 1))+ .or_else(|| tail.find("\n (pad "))+ .unwrap_or_else(|| tail.len().min(400));+ let hidden = tail[..end.min(tail.len())].contains(" hide");+ serde_json::json!({ "x": x, "y": y, "size": size, "hidden": hidden })+ }+ None => serde_json::Value::Null,+ }+ };+ serde_json::json!({ "reference": one("reference"), "value": one("value") })+}++/// Rewrite the `reference`/`value` fp_text `(at x y)` positions in a `.kicad_mod`+/// (rotation dropped → horizontal, per the placement rule). Other text untouched.+pub(crate) fn rewrite_fp_text(km: &str, refx: f64, refy: f64, valx: f64, valy: f64) -> String {+ 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))] {+ let re = Regex::new(&format!(+ r#"(\(fp_text {kind}\s+(?:"[^"]*"|\S+)\s+\(at\s+)-?[\d.]+\s+-?[\d.]+(?:\s+-?[\d.]+)?(\s*\))"#+ )).unwrap();+ let rep = format!("${{1}}{x} {y}${{2}}");+ out = re.replace(&out, rep.as_str()).into_owned();+ }+ out+}+ /// 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+160@@ -20,9 +20,15 @@ pub struct AppState { pub tags: String, /// Raw `.kicad_mod` retained for "Send to" delivery (empty if loaded via SVG). pub kicad_mod: String,+ /// Library folder name (the chip's `<MPN>/` dir) — what the left library rail+ /// highlights as "current". Empty when not loaded from a chip-fetcher library.+ pub mpn: String, } 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); 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);@@ -35,16 +41,125 @@ pub fn set_state(s: AppState) { *STATE.lock().unwrap() = Some(s); } +pub fn set_library_root(p: String) {+ *LIBRARY_ROOT.lock().unwrap() = Some(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.+fn library_root() -> Option<String> {+ if let Some(r) = LIBRARY_ROOT.lock().unwrap().clone() {+ return Some(r);+ }+ let home = std::env::var("HOME").unwrap_or_default();+ for c in [+ format!("{home}/project/chip-fetcher/library"),+ "chip-fetcher/library".to_string(),+ "/home/adom/project/chip-fetcher/library".to_string(),+ ] {+ if std::path::Path::new(&c).is_dir() {+ return Some(c);+ }+ }+ None+}++/// Pick the footprint file to load for a chip: prefer the canonical `<mpn>.kicad_mod`+/// (what adom-lbr exports from), then the Fusion/Altium variants, then any `.kicad_mod`.+fn footprint_file_for(dir: &std::path::Path, mpn: &str) -> Option<std::path::PathBuf> {+ for cand in [+ format!("{mpn}.kicad_mod"),+ format!("{mpn}.fusion.kicad_mod"),+ format!("{mpn}.altium.kicad_mod"),+ format!("{mpn}.manufacturer.kicad_mod"),+ ] {+ let p = dir.join(&cand);+ if p.is_file() {+ return Some(p);+ }+ }+ std::fs::read_dir(dir).ok()?.flatten().map(|e| e.path()).find(|p| {+ p.extension().map(|x| x == "kicad_mod").unwrap_or(false)+ })+}++/// Every chip in the library (a `<MPN>/` dir with at least one `.kicad_mod`),+/// sorted, with the currently-loaded one flagged. Powers the left library rail.+fn list_chips() -> Vec<(String, bool)> {+ let root = match library_root() {+ Some(r) => r,+ None => return vec![],+ };+ let cur = STATE.lock().unwrap().as_ref().map(|s| s.mpn.clone()).unwrap_or_default();+ let mut out = Vec::new();+ if let Ok(rd) = std::fs::read_dir(&root) {+ for e in rd.flatten() {+ let p = e.path();+ if !p.is_dir() {+ continue;+ }+ let name = e.file_name().to_string_lossy().to_string();+ if footprint_file_for(&p, &name).is_some() {+ let is_cur = name == cur;+ out.push((name, is_cur));+ }+ }+ }+ out.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase()));+ out+}++/// Load a chip's footprint from the library into app state (rendered with the+/// solder-paste group baked, like /load). Returns false if not found.+fn load_chip(mpn: &str) -> bool {+ let root = match library_root() {+ Some(r) => r,+ None => return false,+ };+ let dir = std::path::Path::new(&root).join(mpn);+ let file = match footprint_file_for(&dir, mpn) {+ Some(f) => f,+ None => return false,+ };+ let content = match std::fs::read_to_string(&file) {+ Ok(c) => c,+ Err(_) => return false,+ };+ 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) => {+ set_state(AppState {+ footprint_name: name,+ svg,+ pad_count: content.matches("(pad ").count(),+ description: String::new(),+ tags: String::new(),+ kicad_mod: content,+ mpn: mpn.to_string(),+ });+ true+ }+ Err(_) => false,+ }+}+ 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;+ let (minx, miny, maxx, maxy) = crate::bbox_from_mod(&s.kicad_mod); json!({ "footprintName": s.footprint_name, "svg": s.svg, "padCount": s.pad_count, "description": s.description, "tags": s.tags, "hasMod": !s.kicad_mod.is_empty(),+ "mpn": s.mpn, "pasteApertures": aps.len(), "pasteDots": dot_count,+ // For the >NAME/>VALUE editor: current text positions + the+ // 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), }) .to_string() }@@ -112,6 +227,30 @@ fn route(method: &Method, url: &str, body: &str) -> Response<std::io::Cursor<Vec (Method::Get, "/") | (Method::Get, "/index.html") => html(APP_HTML), (Method::Get, "/favicon.svg") => svg(FAVICON), (Method::Get, "/state") => json_resp(&state_json()),+ // Left library rail: list every chip in the chip-fetcher library, mirror its+ // project groupings, and load a chosen chip's footprint.+ (Method::Get, "/api/chips") => {+ let arr: Vec<Value> = list_chips().into_iter().map(|(m, c)| json!({ "mpn": m, "current": c })).collect();+ let cur = STATE.lock().unwrap().as_ref().map(|s| s.mpn.clone()).unwrap_or_default();+ json_resp(&json!({ "chips": arr, "current": cur }).to_string())+ }+ (Method::Get, "/api/projects") => {+ let home = std::env::var("HOME").unwrap_or_default();+ 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()))+ }+ (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))+ .unwrap_or_default();+ if mpn.is_empty() {+ json_err(400, "mpn required")+ } else if load_chip(&mpn) {+ json_resp(&json!({ "ok": true, "mpn": mpn }).to_string())+ } else {+ json_err(404, "chip not found in library")+ }+ } (Method::Post, "/load") => { if let Ok(v) = serde_json::from_str::<Value>(body) { // Preferred: push raw .kicad_mod; the server renders it via@@ -135,6 +274,7 @@ fn route(method: &Method, url: &str, body: &str) -> Response<std::io::Cursor<Vec 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(),+ mpn: v.get("mpn").and_then(|x| x.as_str()).unwrap_or("").to_string(), }), Err(e) => return json_err(422, &e.to_string()), }@@ -146,6 +286,7 @@ fn route(method: &Method, url: &str, body: &str) -> Response<std::io::Cursor<Vec 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(),+ mpn: v.get("mpn").and_then(|x| x.as_str()).unwrap_or("").to_string(), }); } }@@ -194,6 +335,25 @@ fn route(method: &Method, url: &str, body: &str) -> Response<std::io::Cursor<Vec }; json_resp(&crate::deliver::deliver(&name, &km, &target).to_string()) }+ // Bake new >NAME/>VALUE positions into the loaded .kicad_mod so delivery+ // and export carry the placement the user set in the editor.+ (Method::Post, "/text") => {+ let v = serde_json::from_str::<Value>(body).unwrap_or(json!({}));+ let num = |k: &str, sub: &str| v.get(k).and_then(|o| o.get(sub)).and_then(|n| n.as_f64());+ match (num("reference", "x"), num("reference", "y"), num("value", "x"), num("value", "y")) {+ (Some(rx), Some(ry), Some(vx), Some(vy)) => {+ let mut st = STATE.lock().unwrap();+ 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);+ json_resp(&json!({ "ok": true, "text": crate::fp_text_state(&s.kicad_mod) }).to_string())+ }+ _ => json_err(422, "no .kicad_mod loaded to edit"),+ }+ }+ _ => json_err(400, "need reference.{x,y} and value.{x,y}"),+ }+ } (Method::Post, "/shutdown") => json_resp("{\"ok\":true}"), _ if url.starts_with("/eval/") => { let rest = &url["/eval/".len()..];