← Commit history

Publish 1.0.6

John Lauer ·b5e21a1122 ·4mo ago ·parent dbcd2e4
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"+  } }\ No newline at end of file
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 {