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.6
5 files changed
+134−8
bin/adom-footprint⋯ 1 unchanged line ⋯
package.json+3−3@@ -1,8 +1,8 @@ { "slug": "adom-footprint",- "version": "1.0.5",+ "version": "1.0.6", "type": "app",- "description": "A live PCB-footprint viewer with a floating Layers HUD and the opt-in InstaPCB 300um solder-paste dispensing dots no other tool shows.",+ "description": "A live PCB-footprint viewer with a Layers HUD, the opt-in InstaPCB paste dots, and an interactive HTML embed export.", "dependencies": {}, "tags": [ "eda",@@ -14,7 +14,7 @@ "hero": { "type": "billboard", "headline": "Footprints with a layers HUD.",- "subhead": "Copper, courtyard and fab on by default \u2014 flip on the InstaPCB 300\u00b5m paste-dot preview no other tool shows.",+ "subhead": "Toggle copper, fab and the InstaPCB paste dots \u2014 then export an interactive embed for any page.", "screenshot": "screenshots/shot.png", "accent": "#00b8b1" },
page.json+4−5@@ -1,8 +1,8 @@ { "slug": "adom-footprint",- "version": "1.0.5",+ "version": "1.0.6", "type": "app",- "description": "A live PCB-footprint viewer with a floating Layers HUD and the opt-in InstaPCB 300um solder-paste dispensing dots no other tool shows.",+ "description": "A live PCB-footprint viewer with a Layers HUD, the opt-in InstaPCB paste dots, and an interactive HTML embed export.", "dependencies": {}, "tags": [ "eda",@@ -14,7 +14,7 @@ "hero": { "type": "billboard", "headline": "Footprints with a layers HUD.",- "subhead": "Copper, courtyard and fab on by default — flip on the InstaPCB 300µm paste-dot preview no other tool shows.",+ "subhead": "Toggle copper, fab and the InstaPCB paste dots — then export an interactive embed for any page.", "screenshot": "screenshots/shot.png", "accent": "#00b8b1" },@@ -27,6 +27,5 @@ "author": { "name": "John Lauer", "email": "[email protected]"- },- "title": "Adom Footprint"+ } }
src/embed.htmladded+96@@ -0,0 +1,96 @@+<!doctype html><html><head><meta charset="utf-8">+<title>__NAME__ — adom-footprint</title>+<meta name="viewport" content="width=device-width,initial-scale=1">+<style>+ :root{--bg:#0d1117;--panel:#11161f;--panel2:#161c27;--border:#222b38;--text:#e6edf3;--muted:#8b949e;--accent:#37c8a6}+ *{box-sizing:border-box;margin:0;padding:0}html,body{height:100%}+ body{background:var(--bg);color:var(--text);font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;display:flex;flex-direction:column;overflow:hidden}+ header{display:flex;align-items:center;gap:9px;padding:9px 14px;border-bottom:1px solid var(--border);flex:0 0 auto}+ header .dot{width:9px;height:9px;border-radius:50%;background:var(--accent);box-shadow:0 0 10px var(--accent)}+ header .b{font-weight:700;font-size:14px}header .b span{color:var(--muted);font-weight:400}+ header .nm{margin-left:auto;font-weight:600;font-size:13px;color:#cfd8e2}+ main{flex:1;display:flex;min-height:0}+ .vpwrap{flex:1;position:relative;min-width:0}+ .vp{position:absolute;inset:0;overflow:hidden;cursor:grab;background:radial-gradient(circle at 50% 40%,#121a26 0%,#0d1117 70%)}+ .vp.grab{cursor:grabbing}+ .vp svg{position:absolute;left:0;top:0;width:100%;height:100%;transform-origin:0 0;display:block}+ .tools{position:absolute;left:10px;top:10px;display:flex;gap:6px;z-index:5}+ .tools button{width:30px;height:30px;border-radius:8px;border:1px solid var(--border);background:var(--panel2);color:#cfd8e2;font-size:16px;cursor:pointer;display:grid;place-items:center}+ .tools button:hover{border-color:var(--accent);color:var(--accent)}+ .hint{position:absolute;right:10px;bottom:9px;color:var(--muted);font-size:11px;z-index:5;pointer-events:none}+ aside{flex:0 0 248px;border-left:1px solid var(--border);background:var(--panel);display:flex;flex-direction:column;min-height:0}+ aside .ah{padding:9px 12px;border-bottom:1px solid var(--border);font-size:12px;color:var(--muted)}+ .layers{padding:6px}+ .lyr{display:flex;align-items:center;gap:9px;padding:8px 9px;border-radius:8px;cursor:pointer}+ .lyr:hover{background:var(--panel2)}+ .lyr .sw{width:13px;height:13px;border-radius:3px;border:1px solid rgba(255,255,255,.15);flex:0 0 13px}+ .lyr .sw.ring{background:transparent!important}+ .lyr .ln{flex:1;font-size:13px}+ .lyr .meta{font-size:10px;color:var(--muted)}+ .lyr .chk{width:16px;height:16px;border-radius:4px;border:1.5px solid #3a4757;display:grid;place-items:center;color:#0b0f17;font-size:11px}+ .lyr.on .chk{background:var(--accent);border-color:var(--accent)}+ .lyr.off .ln,.lyr.off .sw{opacity:.4}+ .lyr.special{background:linear-gradient(100deg,rgba(55,200,166,.10),transparent);border:1px solid rgba(55,200,166,.25);margin-top:4px}+ .lyr.special .ln{color:#7ef0d2;font-weight:600}+ .note{font-size:11px;color:var(--muted);line-height:1.45;padding:8px 11px}+ .note b{color:#7ef0d2}+</style></head>+<body>+ <header><span class="dot"></span><span class="b">adom-footprint <span>· interactive</span></span><span class="nm">__NAME__</span></header>+ <main>+ <div class="vpwrap">+ <div class="vp" id="vp">+ <div class="tools"><button id="zin">+</button><button id="zout">−</button><button id="fit">⤢</button></div>+ <div class="hint">drag to pan · scroll to zoom</div>+ __SVG__+ </div>+ </div>+ <aside>+ <div class="ah">Layers</div>+ <div class="layers" id="layers"></div>+ <div class="note"><b>InstaPCB</b> jets paste dot-by-dot — those 300µm positions travel with the footprint. Off by default; toggle it on.</div>+ </aside>+ </main>+<script>+var META=__META__;+(function(){+ var vp=document.getElementById('vp'), svg=vp.querySelector('svg');+ if(svg){ svg.removeAttribute('width'); svg.removeAttribute('height'); }+ var scale=1,tx=0,ty=0;+ function apply(){ if(svg) svg.style.transform='translate('+tx+'px,'+ty+'px) scale('+scale+')'; }+ function zoomAt(cx,cy,f){ tx=cx-(cx-tx)*f; ty=cy-(cy-ty)*f; scale*=f; apply(); }+ function fit(){ scale=1;tx=0;ty=0;apply(); }+ vp.addEventListener('wheel',function(e){ e.preventDefault(); var r=vp.getBoundingClientRect(); zoomAt(e.clientX-r.left,e.clientY-r.top,e.deltaY<0?1.12:1/1.12); },{passive:false});+ var drag=false,lx=0,ly=0;+ vp.addEventListener('pointerdown',function(e){ drag=true;lx=e.clientX;ly=e.clientY;vp.classList.add('grab');vp.setPointerCapture(e.pointerId); });+ vp.addEventListener('pointermove',function(e){ if(!drag)return; tx+=e.clientX-lx;ty+=e.clientY-ly;lx=e.clientX;ly=e.clientY;apply(); });+ vp.addEventListener('pointerup',function(){ drag=false;vp.classList.remove('grab'); });+ document.getElementById('zin').onclick=function(){ var r=vp.getBoundingClientRect();zoomAt(r.width/2,r.height/2,1.2); };+ document.getElementById('zout').onclick=function(){ var r=vp.getBoundingClientRect();zoomAt(r.width/2,r.height/2,1/1.2); };+ document.getElementById('fit').onclick=fit;+ // layers+ var DEFS=[+ {key:'copper',name:'Copper',kind:'color',color:'#C83434',sw:'#C83434'},+ {key:'assembly',name:'Assembly (F.Fab)',kind:'color',color:'#C872AB',sw:'#C872AB'},+ {key:'courtyard',name:'Courtyard',kind:'color',color:'#F2EDA1',sw:'#F2EDA1',ring:true},+ {key:'paste-ap',name:'Paste apertures',kind:'group',sel:'.adom-layer-paste-apertures',sw:'#8aa0b8',ring:true},+ {key:'paste-dots',name:'InstaPCB paste dots',kind:'group',sel:'.adom-layer-paste-dots',sw:'#37c8a6',special:true}+ ];+ function cmatch(el,hex){ hex=hex.toUpperCase(); var a=((el.getAttribute('stroke')||'')+'|'+(el.getAttribute('fill')||'')+'|'+(el.getAttribute('style')||'')).toUpperCase(); return a.indexOf(hex)>=0; }+ function applyLayer(def,on){ if(!svg)return;+ if(def.kind==='group'){ var g=svg.querySelectorAll(def.sel); for(var i=0;i<g.length;i++)g[i].style.display=on?'':'none'; }+ else { var els=svg.querySelectorAll('path,rect,circle,polygon,line'); for(var j=0;j<els.length;j++){ if(cmatch(els[j],def.color)) els[j].style.display=on?'':'none'; } } }+ var box=document.getElementById('layers'), state={};+ DEFS.forEach(function(def){+ var on = def.special ? false : true; state[def.key]=on;+ var meta = def.key==='paste-dots' ? ((META.pasteDots||0)+' @ 300µm') : def.key==='paste-ap' ? ((META.pasteApertures||0)+' openings') : '';+ var row=document.createElement('div'); row.className='lyr '+(on?'on':'off')+(def.special?' special':'');+ row.innerHTML='<span class="sw'+(def.ring?' ring':'')+'" style="'+(def.ring?('border-color:'+def.sw):('background:'+def.sw))+'"></span>'+ +'<span class="ln">'+def.name+'</span>'+(meta?'<span class="meta">'+meta+'</span>':'')+'<span class="chk">'+(on?'✓':'')+'</span>';+ row.onclick=function(){ var nv=!row.classList.contains('on'); row.classList.toggle('on',nv); row.classList.toggle('off',!nv); row.querySelector('.chk').textContent=nv?'✓':''; state[def.key]=nv; applyLayer(def,nv); };+ box.appendChild(row); applyLayer(def,on);+ });+ apply();+})();+</script>+</body></html>
src/main.rs+31@@ -53,6 +53,16 @@ enum Cmd { #[arg(long)] out: PathBuf, },+ /// Export a self-contained, INTERACTIVE HTML package (pan/zoom + a Layers+ /// panel with the InstaPCB paste-dot overlay) for embedding in an iframe —+ /// e.g. a wiki component page. No server, no external assets.+ Embed {+ #[arg(long)]+ file: PathBuf,+ /// Output .html path (default: <footprintName>-embed.html next to the input)+ #[arg(long)]+ out: Option<PathBuf>,+ }, /// Run the live app server (AI-drivable: /state, /eval, /console). Serve { #[arg(long, default_value_t = 8782)]@@ -107,6 +117,26 @@ fn main() { } } +fn cmd_embed(file: PathBuf, out: Option<PathBuf>) -> Result<()> {+ let content = fs::read_to_string(&file).with_context(|| format!("reading {}", file.display()))?;+ let name = first_footprint_name(&content).unwrap_or_else(|| "footprint".to_string());+ let svg = render::render_footprint_svg(&content, bbox_from_mod(&content))?;+ let aps = paste::paste_apertures(&content);+ let dot_count = paste::paste_overlay_svg(&aps, 0.0, 0.0).2;+ let meta = serde_json::json!({ "pasteApertures": aps.len(), "pasteDots": dot_count });+ let html = include_str!("embed.html")+ .replace("__NAME__", &esc(&name))+ .replace("__SVG__", &svg)+ .replace("__META__", &meta.to_string());+ let out = out.unwrap_or_else(|| {+ let folder = file.parent().map(|p| p.to_path_buf()).unwrap_or_else(|| PathBuf::from("."));+ folder.join(format!("{name}-embed.html"))+ });+ fs::write(&out, &html).with_context(|| format!("writing {}", out.display()))?;+ println!("OK: interactive embed ({} pads, {} paste dots) → {}", content.matches("(pad ").count(), dot_count, out.display());+ Ok(())+}+ fn run() -> Result<()> { match Cli::parse().command { Cmd::Create { file, out } => cmd_create(file, out),@@ -118,6 +148,7 @@ fn run() -> Result<()> { Ok(()) } Cmd::RenderDs2sf { file, out } => cmd_render_ds2sf(file, out),+ Cmd::Embed { file, out } => cmd_embed(file, out), Cmd::Preview { file, out } => cmd_preview(file, out), Cmd::Serve { port, file } => { if let Some(f) = file {