← Commit history

add <adom-moleculize>: board->molecule widget (reads .kicad_pcb client-side) live on the page + attached

Ray ·7f637198e5 ·2mo ago ·parent a659ab1
3 files changed +566−1
files/moleculize-viewer.htmladded+16
@@ -0,0 +1,16 @@+<!doctype html><html lang=en><head><meta charset=utf-8><meta name=viewport content="width=device-width,initial-scale=1">+<title>Moleculize viewer</title>+<style>html,body{margin:0;background:transparent}adom-moleculize{display:block}</style></head>+<body>+<!-- Embeddable board-to-molecule viewer. Drop a .kicad_pcb (read client-side), or+     seed dimensions with ?w=<mm>&h=<mm>&name=&pcb=. ?theme=light|dark. -->+<adom-moleculize id="w"></adom-moleculize>+<script src="moleculize-widget.js"></script>+<script>+const w=document.getElementById('w'), P=new URLSearchParams(location.search);+if(P.get('theme')) w.setAttribute('theme',P.get('theme'));+if(+P.get('w')>0 && +P.get('h')>0) w.data={name:P.get('name')||'board',width_mm:+P.get('w'),height_mm:+P.get('h'),kicad_pcb:P.get('pcb')||'board.kicad_pcb'};+function ph(){ try{ parent.postMessage({type:'readme-height',height:Math.max(120,Math.min(16000,document.body.scrollHeight||220))},'*'); }catch(_){}}+addEventListener('load',ph); setTimeout(ph,300); if('ResizeObserver' in window) new ResizeObserver(ph).observe(document.body);+</script>+</body></html>
files/moleculize-widget.jsadded+268
@@ -0,0 +1,268 @@+/*!+ * <adom-moleculize> — the "generate a molecule version of this board" widget.+ *+ * Drop it on any (non-molecule) board page. It previews exactly what+ * `kicad-to-molecule` would produce — the original board wrapped in workcell+ * wings with 4 corner machine pins, snapped to the grid — computed client-side+ * from the board's Edge.Cuts size, plus a one-click "generate the real file"+ * prompt (a wiki page can't run a container CLI, so the action is paste-to-Claude).+ *+ *   <adom-moleculize><script type="application/json">+ *     { "name":"Vimdrones ESC", "width_mm":35, "height_mm":85, "kicad_pcb":"board.kicad_pcb" }+ *   </script></adom-moleculize>+ *   // or: el.data = { name, width_mm, height_mm, kicad_pcb?, boardImg? }+ *+ * Sizing mirrors kicad-to-molecule/src/convert.py verbatim:+ *   max(w,h) > 60mm -> LARGE  (grid 16, offset 3, MachinePinLargeShort)+ *   else            -> MEDIUM (grid 8,  offset 1, MachinePinMediumShort)+ *   MC_span = ceil(orig/grid)*grid ; new = MC_span + 2*offset ; board centered ;+ *   4 corner MPs inset `offset` from the new corners.+ * Self-contained (Shadow DOM), theme-aware.+ */+(() => {+  const esc = s => String(s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));+  const ceil = (a, g) => Math.ceil((a - 1e-9) / g) * g;++  // The deterministic moleculization plan (matches convert.py).+  function plan(w, h) {+    const big = Math.max(w, h) > 60;+    const grid = big ? 16 : 8, offset = big ? 3 : 1;+    const mc = big+      ? { name: 'MachinePinLargeShort', pad: 4.4, drill: 2.62 }+      : { name: 'MachinePinMediumShort', pad: 1.3, drill: 0.78 };+    const spanW = ceil(w, grid), spanH = ceil(h, grid);+    const newW = spanW + 2 * offset, newH = spanH + 2 * offset;+    // corner MP centres in the new frame (origin at new top-left)+    const cx = [offset, newW - offset], cy = [offset, newH - offset];+    const mps = [];+    let n = 1;+    for (const y of cy) for (const x of cx) mps.push({ ref: 'MP' + (n++), x, y });+    return { big, grid, offset, mc, newW, newH, spanW, spanH,+             boardX: (newW - w) / 2, boardY: (newH - h) / 2, w, h, mps };+  }++  // Read a .kicad_pcb's Edge.Cuts bounding box entirely client-side (no backend).+  // Walks balanced (gr_line|gr_rect|gr_arc|gr_circle|gr_poly) blocks on the+  // Edge.Cuts layer and takes min/max of their coordinates. Verified against+  // kicad-to-molecule's samples (35x85 and 10.4x10.4, exact).+  function edgeCutsBBox(text) {+    let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity, found = false;+    const add = (x, y) => { if (isFinite(x) && isFinite(y)) { minX = Math.min(minX, x); minY = Math.min(minY, y); maxX = Math.max(maxX, x); maxY = Math.max(maxY, y); found = true; } };+    const KIND = /\((gr_line|gr_rect|gr_arc|gr_circle|gr_poly)\b/g; let m;+    while ((m = KIND.exec(text))) {+      const s = m.index; let depth = 0, i = s, end = -1;+      for (; i < text.length; i++) { const c = text[i]; if (c === '(') depth++; else if (c === ')') { depth--; if (depth === 0) { end = i + 1; break; } } }+      if (end < 0) break;+      const blk = text.slice(s, end); KIND.lastIndex = end;+      if (!/Edge\.Cuts/.test(blk)) continue;+      if (m[1] === 'gr_circle') {+        const cm = /\(center\s+(-?[\d.]+)\s+(-?[\d.]+)\)/.exec(blk), em = /\(end\s+(-?[\d.]+)\s+(-?[\d.]+)\)/.exec(blk);+        if (cm) { const cx = +cm[1], cy = +cm[2], r = em ? Math.hypot(+em[1] - cx, +em[2] - cy) : 0; add(cx - r, cy - r); add(cx + r, cy + r); }+      } else {+        const CM = /\((?:start|end|mid|xy|center)\s+(-?[\d.]+)\s+(-?[\d.]+)/g; let c;+        while ((c = CM.exec(blk))) add(+c[1], +c[2]);+      }+    }+    return found ? { w: +(maxX - minX).toFixed(3), h: +(maxY - minY).toFixed(3) } : null;+  }++  class AdomMoleculize extends HTMLElement {+    set data(d) { this._data = d; if (this.shadowRoot) this._render(); }+    get data() { return this._data; }+    connectedCallback() {+      if (this._init) return; this._init = true;+      this.attachShadow({ mode: 'open' });+      if (!this._data) {+        const inl = this.querySelector('script[type="application/json"]');+        if (inl) { try { this._data = JSON.parse(inl.textContent); } catch (e) { /* */ } }+        if (!this._data && this.getAttribute('width-mm')) this._data = {+          name: this.getAttribute('name') || 'board',+          width_mm: +this.getAttribute('width-mm'), height_mm: +this.getAttribute('height-mm'),+          kicad_pcb: this.getAttribute('kicad-pcb') || 'board.kicad_pcb',+        };+      }+      this._render();+    }++    _svg(p) {+      const S = 380, pad = 44;               // px canvas + margin for labels+      const sc = (S - 2 * pad) / Math.max(p.newW, p.newH);+      const ox = pad + (S - 2 * pad - p.newW * sc) / 2;+      const oy = pad + (S - 2 * pad - p.newH * sc) / 2;+      const X = mm => ox + mm * sc, Y = mm => oy + mm * sc;+      // grid dots anchored to the machine-pin lattice, so the 4 MPs sit ON grid+      // intersections (MC span is an integer multiple of grid). This is the whole+      // point of the snapping — show it.+      let dots = '';+      for (let gx = p.offset; gx <= p.newW - p.offset + 0.01; gx += p.grid)+        for (let gy = p.offset; gy <= p.newH - p.offset + 0.01; gy += p.grid)+          dots += `<circle cx="${X(gx).toFixed(1)}" cy="${Y(gy).toFixed(1)}" r="1.1" class="grid"/>`;+      // machine pins (hoverable)+      const pins = p.mps.map(m => {+        const px = X(m.x), py = Y(m.y);+        return `<g class="mp" data-ref="${m.ref}" data-x="${m.x.toFixed(1)}" data-y="${m.y.toFixed(1)}"><circle cx="${px.toFixed(1)}" cy="${py.toFixed(1)}" r="${(p.mc.pad / 2 * sc).toFixed(1)}" class="mppad"/>`+          + `<circle cx="${px.toFixed(1)}" cy="${py.toFixed(1)}" r="${(p.mc.drill / 2 * sc).toFixed(1)}" class="mpdrill"/>`+          + `<text x="${px.toFixed(1)}" y="${(py - p.mc.pad / 2 * sc - 3).toFixed(1)}" class="mplbl">${m.ref}</text></g>`;+      }).join('');+      return `<svg viewBox="0 0 ${S} ${S}" class="dia">+        <rect x="${X(0).toFixed(1)}" y="${Y(0).toFixed(1)}" width="${(p.newW * sc).toFixed(1)}" height="${(p.newH * sc).toFixed(1)}" rx="3" class="wing"/>+        ${dots}+        <rect x="${X(p.boardX).toFixed(1)}" y="${Y(p.boardY).toFixed(1)}" width="${(p.w * sc).toFixed(1)}" height="${(p.h * sc).toFixed(1)}" rx="2" class="board"/>+        <text x="${X(p.boardX + p.w / 2).toFixed(1)}" y="${Y(p.boardY + p.h / 2).toFixed(1)}" class="boardlbl">original<tspan x="${X(p.boardX + p.w / 2).toFixed(1)}" dy="13">board</tspan></text>+        ${pins}+        <text x="${X(p.newW / 2).toFixed(1)}" y="${(Y(0) - 12).toFixed(1)}" class="dim">${p.newW} × ${p.newH} mm molecule</text>+        <text x="${(X(0) - 8).toFixed(1)}" y="${Y(p.newH / 2).toFixed(1)}" class="dim vert" transform="rotate(-90 ${(X(0) - 8).toFixed(1)} ${Y(p.newH / 2).toFixed(1)})">${p.w} × ${p.h} mm board</text>+      </svg>`;+    }++    _render() {+      const root = this.shadowRoot;+      const d = this._data;+      if (!d || !(d.width_mm > 0) || !(d.height_mm > 0)) {+        root.innerHTML = `<style>${this._css()}</style><div class="card">+          <div class="drop${this._err ? ' err' : ''}" data-drop>+            <input type="file" accept=".kicad_pcb" hidden data-file>+            <div class="dropbig">Drop a <code>.kicad_pcb</code> here</div>+            <div class="dropsub">or <b>click to choose</b>&nbsp;·&nbsp;reads the board size locally, no upload, no compute</div>+            ${this._err ? `<div class="droperr">${esc(this._err)}</div>` : ''}+          </div></div>`;+        this._wireFile();+        return;+      }+      const p = plan(d.width_mm, d.height_mm);+      const pcb = d.kicad_pcb || 'board.kicad_pcb';+      const prompt = `moleculize ${pcb} with kicad-to-molecule and show me the result`;+      root.innerHTML = `<style>${this._css()}</style>+      <div class="card">+        <div class="hd"><span class="t">Molecule version</span><span class="badge">${p.big ? 'LARGE grid' : 'MEDIUM grid'}</span><span class="sub">${esc(d.name || 'board')}<button class="swap" data-swap title="load a different .kicad_pcb">&#8635;</button><input type="file" accept=".kicad_pcb" hidden data-file></span></div>+        <div class="body">+          <div class="diagram">+            <div class="readout" data-readout>4 machine pins on an ${p.grid} mm grid&nbsp;·&nbsp;hover one</div>+            ${this._svg(p)}+            <div class="legend"><span><i class="l-board"></i>original board (unchanged)</span><span><i class="l-wing"></i>workcell wings</span><span><i class="l-mp"></i>machine pins</span></div>+          </div>+          <div class="panel">+            <table>+              <tr><td>Original</td><td>${p.w} × ${p.h} mm</td></tr>+              <tr><td>Molecule</td><td><b>${p.newW} × ${p.newH} mm</b></td></tr>+              <tr><td>Grid</td><td>${p.grid} mm</td></tr>+              <tr><td>Edge offset</td><td>${p.offset} mm</td></tr>+              <tr><td>Contacts</td><td>4 × ${p.mc.name}</td></tr>+              <tr><td>Pad · drill</td><td>${p.mc.pad} · ${p.mc.drill} mm</td></tr>+            </table>+            <div class="ready"><i></i>workcell-ready: pick · place · probe</div>+            <div class="gen">+              <div class="genh">Build the real molecule</div>+              <div class="genflow">The preview is instant. Generating the actual <code>.molecule.kicad_pcb</code> runs <code>kicad-to-molecule</code>, so it happens in an <b>Adom AI thread</b>, not on this static page.</div>+              <button class="askai" data-prompt="${esc(prompt)}">Ask the Adom AI to build it &rarr;</button>+              <div class="prompt"><span>"${esc(prompt)}"</span><button class="copy" data-prompt="${esc(prompt)}">copy</button></div>+              <div class="cli">CLI: <code>kicad-to-molecule convert ${esc(pcb)}</code></div>+            </div>+          </div>+        </div>+      </div>`;+      const copyTo = (el, done) => { try { navigator.clipboard.writeText(el.dataset.prompt); } catch (e) { /* */ } done(); };+      const cbtn = root.querySelector('.copy');+      if (cbtn) cbtn.onclick = () => copyTo(cbtn, () => { cbtn.textContent = 'copied'; setTimeout(() => cbtn.textContent = 'copy', 1200); });+      const ask = root.querySelector('.askai');+      if (ask) ask.onclick = () => copyTo(ask, () => { const t = ask.textContent; ask.textContent = 'copied — paste into an Adom AI thread'; ask.classList.add('ok'); setTimeout(() => { ask.textContent = t; ask.classList.remove('ok'); }, 1600); });+      // machine-pin hover → readout + highlight+      const ro = root.querySelector('[data-readout]'), roDefault = ro ? ro.innerHTML : '';+      root.querySelectorAll('svg .mp').forEach(g => {+        g.onmouseenter = () => { g.classList.add('hot'); if (ro) ro.innerHTML = `<b>${g.dataset.ref}</b>&nbsp;·&nbsp;(${g.dataset.x}, ${g.dataset.y}) mm&nbsp;·&nbsp;${esc(p.mc.name)}`; };+        g.onmouseleave = () => { g.classList.remove('hot'); if (ro) ro.innerHTML = roDefault; };+      });+      this._wireFile();+    }++    // Read a dropped/selected .kicad_pcb, parse its Edge.Cuts bbox, render the plan.+    _loadFile(file) {+      if (!file) return;+      const rdr = new FileReader();+      rdr.onload = () => {+        const bb = edgeCutsBBox(String(rdr.result || ''));+        if (!bb || !(bb.w > 0) || !(bb.h > 0)) { this._err = 'No Edge.Cuts outline found in ' + file.name; this._data = null; this._render(); return; }+        this._err = null;+        this._data = { name: file.name.replace(/\.kicad_pcb$/i, ''), width_mm: bb.w, height_mm: bb.h, kicad_pcb: file.name };+        this._render();+      };+      rdr.onerror = () => { this._err = 'could not read ' + file.name; this._render(); };+      rdr.readAsText(file);+    }++    _wireFile() {+      const root = this.shadowRoot;+      const input = root.querySelector('[data-file]');+      if (!input) return;+      input.onchange = () => { if (input.files && input.files[0]) this._loadFile(input.files[0]); };+      const drop = root.querySelector('[data-drop]');+      if (drop) {+        drop.onclick = () => input.click();+        drop.ondragover = e => { e.preventDefault(); drop.classList.add('over'); };+        drop.ondragleave = () => drop.classList.remove('over');+        drop.ondrop = e => { e.preventDefault(); drop.classList.remove('over'); const f = e.dataTransfer && e.dataTransfer.files[0]; if (f) this._loadFile(f); };+      }+      const swap = root.querySelector('[data-swap]');+      if (swap) swap.onclick = e => { e.stopPropagation(); input.click(); };+    }++    _css() {+      return `+      :host{display:block;+        --bg:#0d1420;--panel:#121a26;--line:#26324a;--line2:#33425f;--text:#dbe2ec;--dim:#8b97ac;--dim2:#66738c;+        --accent:#00e6dc;--wing:rgba(0,214,204,.07);--board:#2b3a52;--boardln:#4c9dff;--mp:#ffb454;--grid:#2a3651}+      @media(prefers-color-scheme:light){:host{--bg:#f6f8fc;--panel:#fff;--line:#dbe2ee;--line2:#c3cede;--text:#1a2230;--dim:#5a6678;--dim2:#8592a6;--accent:#0d8f88;--wing:rgba(13,143,136,.06);--board:#dbe6f5;--boardln:#2f6fd6;--mp:#e08a1e;--grid:#dde5f0}}+      :host([theme="dark"]){--bg:#0d1420;--panel:#121a26;--line:#26324a;--line2:#33425f;--text:#dbe2ec;--dim:#8b97ac;--dim2:#66738c;--accent:#00e6dc;--wing:rgba(0,214,204,.07);--board:#2b3a52;--boardln:#4c9dff;--mp:#ffb454;--grid:#2a3651}+      *{box-sizing:border-box}+      .card{background:var(--bg);border:1px solid var(--line);border-radius:14px;padding:16px;color:var(--text);font:14px/1.5 -apple-system,'Segoe UI',Roboto,Arial,sans-serif}+      .hd{display:flex;align-items:baseline;gap:10px;margin-bottom:12px}+      .hd .t{font:700 16px ui-monospace,'SF Mono',Menlo,monospace}+      .hd .badge{font:700 10px ui-monospace,monospace;letter-spacing:.06em;color:var(--accent);background:color-mix(in srgb,var(--accent) 12%,transparent);border:1px solid color-mix(in srgb,var(--accent) 30%,transparent);padding:2px 8px;border-radius:6px}+      .hd .sub{margin-left:auto;color:var(--dim2);font:12px ui-monospace,monospace}+      .body{display:grid;grid-template-columns:minmax(300px,1fr) minmax(240px,1fr);gap:18px;align-items:start}+      @media(max-width:640px){.body{grid-template-columns:1fr}}+      .diagram{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:8px}+      svg.dia{width:100%;height:auto;display:block}+      .wing{fill:var(--wing);stroke:var(--accent);stroke-width:1.4;stroke-dasharray:5 4}+      .grid{fill:var(--grid)}+      .board{fill:color-mix(in srgb,var(--board) 55%,transparent);stroke:var(--boardln);stroke-width:1.4}+      .boardlbl{fill:var(--boardln);font:600 9px ui-monospace,monospace;text-anchor:middle;dominant-baseline:middle;opacity:.9}+      .mppad{fill:color-mix(in srgb,var(--mp) 30%,transparent);stroke:var(--mp);stroke-width:1.4}+      .mpdrill{fill:var(--bg);stroke:var(--mp);stroke-width:.8}+      .mplbl{fill:var(--mp);font:700 8px ui-monospace,monospace;text-anchor:middle}+      .dim{fill:var(--dim);font:600 10px ui-monospace,monospace;text-anchor:middle}+      .legend{display:flex;flex-wrap:wrap;gap:12px;margin:8px 2px 2px;font-size:11px;color:var(--dim)}+      .legend i{display:inline-block;width:10px;height:10px;border-radius:2px;margin-right:5px;vertical-align:-1px}+      .l-board{background:var(--boardln)}.l-wing{background:transparent;border:1.4px dashed var(--accent)}.l-mp{border-radius:50%;background:var(--mp)}+      .panel table{width:100%;border-collapse:collapse;font-size:12.5px}+      .panel td{padding:4px 2px;border-bottom:1px solid color-mix(in srgb,var(--line) 55%,transparent);font-family:ui-monospace,monospace}+      .panel td:first-child{color:var(--dim)}.panel td:last-child{text-align:right}+      .ready{display:flex;align-items:center;gap:8px;margin:12px 0;font:600 12px ui-monospace,monospace;color:var(--accent)}+      .ready i{width:8px;height:8px;border-radius:50%;background:var(--accent);box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 18%,transparent)}+      .gen{border-top:1px solid var(--line);padding-top:12px}+      .genh{font-size:12px;color:var(--dim);margin-bottom:8px}.genh code{color:var(--text)}+      .prompt{display:flex;align-items:center;gap:8px;background:color-mix(in srgb,var(--accent) 6%,transparent);border:1px solid color-mix(in srgb,var(--accent) 26%,transparent);border-radius:9px;padding:8px 10px}+      .prompt span{flex:1;font:13px ui-monospace,monospace;color:var(--text)}+      .copy{background:var(--accent);color:#00171a;border:0;border-radius:6px;padding:4px 10px;font:600 11px ui-monospace,monospace;cursor:pointer}+      .cli{margin-top:8px;font:11px ui-monospace,monospace;color:var(--dim2)}.cli code{color:var(--dim)}+      .readout{min-height:22px;display:flex;align-items:center;gap:6px;font:600 12px ui-monospace,monospace;color:var(--dim);padding:1px 4px 7px}+      .readout b{color:var(--mp)}+      .mp{cursor:default}+      .mp:hover .mppad,.mp.hot .mppad{fill:color-mix(in srgb,var(--mp) 55%,transparent);stroke-width:2.2}+      .mp:hover .mplbl,.mp.hot .mplbl{fill:var(--text);font-weight:700}+      .genflow{font-size:12px;line-height:1.5;color:var(--dim);margin-bottom:10px}.genflow code{color:var(--text)}.genflow b{color:var(--accent);font-weight:600}+      .askai{display:block;width:100%;text-align:center;background:linear-gradient(100deg,#00e6dc,#39b8ff 60%,#58a6ff);color:#00171a;border:0;border-radius:9px;padding:9px 12px;font:700 13px ui-monospace,monospace;cursor:pointer;margin-bottom:10px}+      .askai:hover{filter:brightness(1.08)}.askai.ok{background:none;border:1px solid var(--accent);color:var(--accent);font-size:11.5px}+      .drop{border:1.5px dashed var(--line2);border-radius:12px;padding:34px 20px;text-align:center;cursor:pointer;background:color-mix(in srgb,var(--accent) 3%,transparent);transition:border-color .12s,background .12s}+      .drop:hover,.drop.over{border-color:var(--accent);background:color-mix(in srgb,var(--accent) 8%,transparent)}+      .drop.err{border-color:#ff6b6b}+      .dropbig{font:600 16px ui-monospace,monospace;color:var(--text)}.dropbig code{color:var(--accent);background:none;padding:0}+      .dropsub{margin-top:7px;font:12.5px ui-monospace,monospace;color:var(--dim)}.dropsub b{color:var(--text)}+      .droperr{margin-top:12px;font:600 12px ui-monospace,monospace;color:#ff8a8a}+      .swap{margin-left:8px;background:none;border:1px solid var(--line2);color:var(--dim);border-radius:6px;padding:1px 7px;font-size:12px;cursor:pointer;vertical-align:1px}+      .swap:hover{border-color:var(--accent);color:var(--accent)}+      .empty{color:var(--dim2);padding:26px;text-align:center;font-family:ui-monospace,monospace}`;+    }+  }+  if (!customElements.get('adom-moleculize')) customElements.define('adom-moleculize', AdomMoleculize);+})();
readme.html+282−1
@@ -182,6 +182,18 @@ footer .url{margin-top:14px;color:var(--teal);font-family:ui-monospace,monospace     </div>   </section> +  <!-- MOLECULIZE -->+  <section>+    <div class="eyebrow">Board page</div>+    <h2>Board → molecule <span class="tag">&lt;adom-moleculize&gt;</span> <span class="new">new</span></h2>+    <p class="sub"><b>Drop a <code>.kicad_pcb</code></b> and it reads the Edge.Cuts size <b>in your browser</b>, no backend, and shows the workcell molecule <code>kicad-to-molecule</code> would produce: wings + 4 grid-snapped machine pins. Only generating the file needs an AI thread.</p>+    <pre><code><span class="tok-tag">&lt;adom-moleculize&gt;&lt;/adom-moleculize&gt;</span>  <span class="tok-com">&lt;!-- drop a .kicad_pcb, or seed { width_mm, height_mm } --&gt;</span></code></pre>+    <div class="stage">+      <div class="live"><i></i>Live&nbsp;·&nbsp;drop a .kicad_pcb, or click &#8635; to load one</div>+      <adom-moleculize theme="dark"><script type="application/json">{ "name":"example board", "width_mm":35, "height_mm":85, "kicad_pcb":"board.kicad_pcb" }</script></adom-moleculize>+    </div>+  </section>+   <div class="callout">     <h3>Why these are live, not pictures</h3>     <p>This page is a <code>readme.html</code> — the wiki serves it byte-for-byte in a sandboxed iframe where scripts run. So the widgets above aren't renders of the widgets; they <b>are</b> the widgets. Drop the same <code>&lt;adom-*&gt;</code> tags into any component page and they behave identically.</p>@@ -208,7 +220,7 @@ footer .url{margin-top:14px;color:var(--teal);font-family:ui-monospace,monospace    <footer>     <div class="eyebrow" style="color:var(--dim2)">Attached in the Files tab</div>-    <div class="files">pinout-widget.js · heatsink-via-widget.js · molecule-widget.js · chip-thumbnail-widget.js<br>+ each widget's <span class="mono">*-viewer.html</span> (iframe-embeddable) and the example JSON sources</div>+    <div class="files">pinout-widget.js · heatsink-via-widget.js · molecule-widget.js · chip-thumbnail-widget.js · moleculize-widget.js<br>+ each widget's <span class="mono">*-viewer.html</span> (iframe-embeddable) and the example JSON sources</div>     <div class="url">wiki.adom.inc/adom/component-widgets</div>   </footer> @@ -793,6 +805,275 @@ footer .url{margin-top:14px;color:var(--teal);font-family:ui-monospace,monospace   if (!customElements.get('adom-chip-thumbnail')) customElements.define('adom-chip-thumbnail', AdomChipThumbnail); })(); </script>+<script>/*!+ * <adom-moleculize> — the "generate a molecule version of this board" widget.+ *+ * Drop it on any (non-molecule) board page. It previews exactly what+ * `kicad-to-molecule` would produce — the original board wrapped in workcell+ * wings with 4 corner machine pins, snapped to the grid — computed client-side+ * from the board's Edge.Cuts size, plus a one-click "generate the real file"+ * prompt (a wiki page can't run a container CLI, so the action is paste-to-Claude).+ *+ *   <adom-moleculize><script type="application/json">+ *     { "name":"Vimdrones ESC", "width_mm":35, "height_mm":85, "kicad_pcb":"board.kicad_pcb" }+ *   <\/script></adom-moleculize>+ *   // or: el.data = { name, width_mm, height_mm, kicad_pcb?, boardImg? }+ *+ * Sizing mirrors kicad-to-molecule/src/convert.py verbatim:+ *   max(w,h) > 60mm -> LARGE  (grid 16, offset 3, MachinePinLargeShort)+ *   else            -> MEDIUM (grid 8,  offset 1, MachinePinMediumShort)+ *   MC_span = ceil(orig/grid)*grid ; new = MC_span + 2*offset ; board centered ;+ *   4 corner MPs inset `offset` from the new corners.+ * Self-contained (Shadow DOM), theme-aware.+ */+(() => {+  const esc = s => String(s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));+  const ceil = (a, g) => Math.ceil((a - 1e-9) / g) * g;++  // The deterministic moleculization plan (matches convert.py).+  function plan(w, h) {+    const big = Math.max(w, h) > 60;+    const grid = big ? 16 : 8, offset = big ? 3 : 1;+    const mc = big+      ? { name: 'MachinePinLargeShort', pad: 4.4, drill: 2.62 }+      : { name: 'MachinePinMediumShort', pad: 1.3, drill: 0.78 };+    const spanW = ceil(w, grid), spanH = ceil(h, grid);+    const newW = spanW + 2 * offset, newH = spanH + 2 * offset;+    // corner MP centres in the new frame (origin at new top-left)+    const cx = [offset, newW - offset], cy = [offset, newH - offset];+    const mps = [];+    let n = 1;+    for (const y of cy) for (const x of cx) mps.push({ ref: 'MP' + (n++), x, y });+    return { big, grid, offset, mc, newW, newH, spanW, spanH,+             boardX: (newW - w) / 2, boardY: (newH - h) / 2, w, h, mps };+  }++  // Read a .kicad_pcb's Edge.Cuts bounding box entirely client-side (no backend).+  // Walks balanced (gr_line|gr_rect|gr_arc|gr_circle|gr_poly) blocks on the+  // Edge.Cuts layer and takes min/max of their coordinates. Verified against+  // kicad-to-molecule's samples (35x85 and 10.4x10.4, exact).+  function edgeCutsBBox(text) {+    let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity, found = false;+    const add = (x, y) => { if (isFinite(x) && isFinite(y)) { minX = Math.min(minX, x); minY = Math.min(minY, y); maxX = Math.max(maxX, x); maxY = Math.max(maxY, y); found = true; } };+    const KIND = /\((gr_line|gr_rect|gr_arc|gr_circle|gr_poly)\b/g; let m;+    while ((m = KIND.exec(text))) {+      const s = m.index; let depth = 0, i = s, end = -1;+      for (; i < text.length; i++) { const c = text[i]; if (c === '(') depth++; else if (c === ')') { depth--; if (depth === 0) { end = i + 1; break; } } }+      if (end < 0) break;+      const blk = text.slice(s, end); KIND.lastIndex = end;+      if (!/Edge\.Cuts/.test(blk)) continue;+      if (m[1] === 'gr_circle') {+        const cm = /\(center\s+(-?[\d.]+)\s+(-?[\d.]+)\)/.exec(blk), em = /\(end\s+(-?[\d.]+)\s+(-?[\d.]+)\)/.exec(blk);+        if (cm) { const cx = +cm[1], cy = +cm[2], r = em ? Math.hypot(+em[1] - cx, +em[2] - cy) : 0; add(cx - r, cy - r); add(cx + r, cy + r); }+      } else {+        const CM = /\((?:start|end|mid|xy|center)\s+(-?[\d.]+)\s+(-?[\d.]+)/g; let c;+        while ((c = CM.exec(blk))) add(+c[1], +c[2]);+      }+    }+    return found ? { w: +(maxX - minX).toFixed(3), h: +(maxY - minY).toFixed(3) } : null;+  }++  class AdomMoleculize extends HTMLElement {+    set data(d) { this._data = d; if (this.shadowRoot) this._render(); }+    get data() { return this._data; }+    connectedCallback() {+      if (this._init) return; this._init = true;+      this.attachShadow({ mode: 'open' });+      if (!this._data) {+        const inl = this.querySelector('script[type="application/json"]');+        if (inl) { try { this._data = JSON.parse(inl.textContent); } catch (e) { /* */ } }+        if (!this._data && this.getAttribute('width-mm')) this._data = {+          name: this.getAttribute('name') || 'board',+          width_mm: +this.getAttribute('width-mm'), height_mm: +this.getAttribute('height-mm'),+          kicad_pcb: this.getAttribute('kicad-pcb') || 'board.kicad_pcb',+        };+      }+      this._render();+    }++    _svg(p) {+      const S = 380, pad = 44;               // px canvas + margin for labels+      const sc = (S - 2 * pad) / Math.max(p.newW, p.newH);+      const ox = pad + (S - 2 * pad - p.newW * sc) / 2;+      const oy = pad + (S - 2 * pad - p.newH * sc) / 2;+      const X = mm => ox + mm * sc, Y = mm => oy + mm * sc;+      // grid dots anchored to the machine-pin lattice, so the 4 MPs sit ON grid+      // intersections (MC span is an integer multiple of grid). This is the whole+      // point of the snapping — show it.+      let dots = '';+      for (let gx = p.offset; gx <= p.newW - p.offset + 0.01; gx += p.grid)+        for (let gy = p.offset; gy <= p.newH - p.offset + 0.01; gy += p.grid)+          dots += `<circle cx="${X(gx).toFixed(1)}" cy="${Y(gy).toFixed(1)}" r="1.1" class="grid"/>`;+      // machine pins (hoverable)+      const pins = p.mps.map(m => {+        const px = X(m.x), py = Y(m.y);+        return `<g class="mp" data-ref="${m.ref}" data-x="${m.x.toFixed(1)}" data-y="${m.y.toFixed(1)}"><circle cx="${px.toFixed(1)}" cy="${py.toFixed(1)}" r="${(p.mc.pad / 2 * sc).toFixed(1)}" class="mppad"/>`+          + `<circle cx="${px.toFixed(1)}" cy="${py.toFixed(1)}" r="${(p.mc.drill / 2 * sc).toFixed(1)}" class="mpdrill"/>`+          + `<text x="${px.toFixed(1)}" y="${(py - p.mc.pad / 2 * sc - 3).toFixed(1)}" class="mplbl">${m.ref}</text></g>`;+      }).join('');+      return `<svg viewBox="0 0 ${S} ${S}" class="dia">+        <rect x="${X(0).toFixed(1)}" y="${Y(0).toFixed(1)}" width="${(p.newW * sc).toFixed(1)}" height="${(p.newH * sc).toFixed(1)}" rx="3" class="wing"/>+        ${dots}+        <rect x="${X(p.boardX).toFixed(1)}" y="${Y(p.boardY).toFixed(1)}" width="${(p.w * sc).toFixed(1)}" height="${(p.h * sc).toFixed(1)}" rx="2" class="board"/>+        <text x="${X(p.boardX + p.w / 2).toFixed(1)}" y="${Y(p.boardY + p.h / 2).toFixed(1)}" class="boardlbl">original<tspan x="${X(p.boardX + p.w / 2).toFixed(1)}" dy="13">board</tspan></text>+        ${pins}+        <text x="${X(p.newW / 2).toFixed(1)}" y="${(Y(0) - 12).toFixed(1)}" class="dim">${p.newW} × ${p.newH} mm molecule</text>+        <text x="${(X(0) - 8).toFixed(1)}" y="${Y(p.newH / 2).toFixed(1)}" class="dim vert" transform="rotate(-90 ${(X(0) - 8).toFixed(1)} ${Y(p.newH / 2).toFixed(1)})">${p.w} × ${p.h} mm board</text>+      </svg>`;+    }++    _render() {+      const root = this.shadowRoot;+      const d = this._data;+      if (!d || !(d.width_mm > 0) || !(d.height_mm > 0)) {+        root.innerHTML = `<style>${this._css()}</style><div class="card">+          <div class="drop${this._err ? ' err' : ''}" data-drop>+            <input type="file" accept=".kicad_pcb" hidden data-file>+            <div class="dropbig">Drop a <code>.kicad_pcb</code> here</div>+            <div class="dropsub">or <b>click to choose</b>&nbsp;·&nbsp;reads the board size locally, no upload, no compute</div>+            ${this._err ? `<div class="droperr">${esc(this._err)}</div>` : ''}+          </div></div>`;+        this._wireFile();+        return;+      }+      const p = plan(d.width_mm, d.height_mm);+      const pcb = d.kicad_pcb || 'board.kicad_pcb';+      const prompt = `moleculize ${pcb} with kicad-to-molecule and show me the result`;+      root.innerHTML = `<style>${this._css()}</style>+      <div class="card">+        <div class="hd"><span class="t">Molecule version</span><span class="badge">${p.big ? 'LARGE grid' : 'MEDIUM grid'}</span><span class="sub">${esc(d.name || 'board')}<button class="swap" data-swap title="load a different .kicad_pcb">&#8635;</button><input type="file" accept=".kicad_pcb" hidden data-file></span></div>+        <div class="body">+          <div class="diagram">+            <div class="readout" data-readout>4 machine pins on an ${p.grid} mm grid&nbsp;·&nbsp;hover one</div>+            ${this._svg(p)}+            <div class="legend"><span><i class="l-board"></i>original board (unchanged)</span><span><i class="l-wing"></i>workcell wings</span><span><i class="l-mp"></i>machine pins</span></div>+          </div>+          <div class="panel">+            <table>+              <tr><td>Original</td><td>${p.w} × ${p.h} mm</td></tr>+              <tr><td>Molecule</td><td><b>${p.newW} × ${p.newH} mm</b></td></tr>+              <tr><td>Grid</td><td>${p.grid} mm</td></tr>+              <tr><td>Edge offset</td><td>${p.offset} mm</td></tr>+              <tr><td>Contacts</td><td>4 × ${p.mc.name}</td></tr>+              <tr><td>Pad · drill</td><td>${p.mc.pad} · ${p.mc.drill} mm</td></tr>+            </table>+            <div class="ready"><i></i>workcell-ready: pick · place · probe</div>+            <div class="gen">+              <div class="genh">Build the real molecule</div>+              <div class="genflow">The preview is instant. Generating the actual <code>.molecule.kicad_pcb</code> runs <code>kicad-to-molecule</code>, so it happens in an <b>Adom AI thread</b>, not on this static page.</div>+              <button class="askai" data-prompt="${esc(prompt)}">Ask the Adom AI to build it &rarr;</button>+              <div class="prompt"><span>"${esc(prompt)}"</span><button class="copy" data-prompt="${esc(prompt)}">copy</button></div>+              <div class="cli">CLI: <code>kicad-to-molecule convert ${esc(pcb)}</code></div>+            </div>+          </div>+        </div>+      </div>`;+      const copyTo = (el, done) => { try { navigator.clipboard.writeText(el.dataset.prompt); } catch (e) { /* */ } done(); };+      const cbtn = root.querySelector('.copy');+      if (cbtn) cbtn.onclick = () => copyTo(cbtn, () => { cbtn.textContent = 'copied'; setTimeout(() => cbtn.textContent = 'copy', 1200); });+      const ask = root.querySelector('.askai');+      if (ask) ask.onclick = () => copyTo(ask, () => { const t = ask.textContent; ask.textContent = 'copied — paste into an Adom AI thread'; ask.classList.add('ok'); setTimeout(() => { ask.textContent = t; ask.classList.remove('ok'); }, 1600); });+      // machine-pin hover → readout + highlight+      const ro = root.querySelector('[data-readout]'), roDefault = ro ? ro.innerHTML : '';+      root.querySelectorAll('svg .mp').forEach(g => {+        g.onmouseenter = () => { g.classList.add('hot'); if (ro) ro.innerHTML = `<b>${g.dataset.ref}</b>&nbsp;·&nbsp;(${g.dataset.x}, ${g.dataset.y}) mm&nbsp;·&nbsp;${esc(p.mc.name)}`; };+        g.onmouseleave = () => { g.classList.remove('hot'); if (ro) ro.innerHTML = roDefault; };+      });+      this._wireFile();+    }++    // Read a dropped/selected .kicad_pcb, parse its Edge.Cuts bbox, render the plan.+    _loadFile(file) {+      if (!file) return;+      const rdr = new FileReader();+      rdr.onload = () => {+        const bb = edgeCutsBBox(String(rdr.result || ''));+        if (!bb || !(bb.w > 0) || !(bb.h > 0)) { this._err = 'No Edge.Cuts outline found in ' + file.name; this._data = null; this._render(); return; }+        this._err = null;+        this._data = { name: file.name.replace(/\.kicad_pcb$/i, ''), width_mm: bb.w, height_mm: bb.h, kicad_pcb: file.name };+        this._render();+      };+      rdr.onerror = () => { this._err = 'could not read ' + file.name; this._render(); };+      rdr.readAsText(file);+    }++    _wireFile() {+      const root = this.shadowRoot;+      const input = root.querySelector('[data-file]');+      if (!input) return;+      input.onchange = () => { if (input.files && input.files[0]) this._loadFile(input.files[0]); };+      const drop = root.querySelector('[data-drop]');+      if (drop) {+        drop.onclick = () => input.click();+        drop.ondragover = e => { e.preventDefault(); drop.classList.add('over'); };+        drop.ondragleave = () => drop.classList.remove('over');+        drop.ondrop = e => { e.preventDefault(); drop.classList.remove('over'); const f = e.dataTransfer && e.dataTransfer.files[0]; if (f) this._loadFile(f); };+      }+      const swap = root.querySelector('[data-swap]');+      if (swap) swap.onclick = e => { e.stopPropagation(); input.click(); };+    }++    _css() {+      return `+      :host{display:block;+        --bg:#0d1420;--panel:#121a26;--line:#26324a;--line2:#33425f;--text:#dbe2ec;--dim:#8b97ac;--dim2:#66738c;+        --accent:#00e6dc;--wing:rgba(0,214,204,.07);--board:#2b3a52;--boardln:#4c9dff;--mp:#ffb454;--grid:#2a3651}+      @media(prefers-color-scheme:light){:host{--bg:#f6f8fc;--panel:#fff;--line:#dbe2ee;--line2:#c3cede;--text:#1a2230;--dim:#5a6678;--dim2:#8592a6;--accent:#0d8f88;--wing:rgba(13,143,136,.06);--board:#dbe6f5;--boardln:#2f6fd6;--mp:#e08a1e;--grid:#dde5f0}}+      :host([theme="dark"]){--bg:#0d1420;--panel:#121a26;--line:#26324a;--line2:#33425f;--text:#dbe2ec;--dim:#8b97ac;--dim2:#66738c;--accent:#00e6dc;--wing:rgba(0,214,204,.07);--board:#2b3a52;--boardln:#4c9dff;--mp:#ffb454;--grid:#2a3651}+      *{box-sizing:border-box}+      .card{background:var(--bg);border:1px solid var(--line);border-radius:14px;padding:16px;color:var(--text);font:14px/1.5 -apple-system,'Segoe UI',Roboto,Arial,sans-serif}+      .hd{display:flex;align-items:baseline;gap:10px;margin-bottom:12px}+      .hd .t{font:700 16px ui-monospace,'SF Mono',Menlo,monospace}+      .hd .badge{font:700 10px ui-monospace,monospace;letter-spacing:.06em;color:var(--accent);background:color-mix(in srgb,var(--accent) 12%,transparent);border:1px solid color-mix(in srgb,var(--accent) 30%,transparent);padding:2px 8px;border-radius:6px}+      .hd .sub{margin-left:auto;color:var(--dim2);font:12px ui-monospace,monospace}+      .body{display:grid;grid-template-columns:minmax(300px,1fr) minmax(240px,1fr);gap:18px;align-items:start}+      @media(max-width:640px){.body{grid-template-columns:1fr}}+      .diagram{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:8px}+      svg.dia{width:100%;height:auto;display:block}+      .wing{fill:var(--wing);stroke:var(--accent);stroke-width:1.4;stroke-dasharray:5 4}+      .grid{fill:var(--grid)}+      .board{fill:color-mix(in srgb,var(--board) 55%,transparent);stroke:var(--boardln);stroke-width:1.4}+      .boardlbl{fill:var(--boardln);font:600 9px ui-monospace,monospace;text-anchor:middle;dominant-baseline:middle;opacity:.9}+      .mppad{fill:color-mix(in srgb,var(--mp) 30%,transparent);stroke:var(--mp);stroke-width:1.4}+      .mpdrill{fill:var(--bg);stroke:var(--mp);stroke-width:.8}+      .mplbl{fill:var(--mp);font:700 8px ui-monospace,monospace;text-anchor:middle}+      .dim{fill:var(--dim);font:600 10px ui-monospace,monospace;text-anchor:middle}+      .legend{display:flex;flex-wrap:wrap;gap:12px;margin:8px 2px 2px;font-size:11px;color:var(--dim)}+      .legend i{display:inline-block;width:10px;height:10px;border-radius:2px;margin-right:5px;vertical-align:-1px}+      .l-board{background:var(--boardln)}.l-wing{background:transparent;border:1.4px dashed var(--accent)}.l-mp{border-radius:50%;background:var(--mp)}+      .panel table{width:100%;border-collapse:collapse;font-size:12.5px}+      .panel td{padding:4px 2px;border-bottom:1px solid color-mix(in srgb,var(--line) 55%,transparent);font-family:ui-monospace,monospace}+      .panel td:first-child{color:var(--dim)}.panel td:last-child{text-align:right}+      .ready{display:flex;align-items:center;gap:8px;margin:12px 0;font:600 12px ui-monospace,monospace;color:var(--accent)}+      .ready i{width:8px;height:8px;border-radius:50%;background:var(--accent);box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 18%,transparent)}+      .gen{border-top:1px solid var(--line);padding-top:12px}+      .genh{font-size:12px;color:var(--dim);margin-bottom:8px}.genh code{color:var(--text)}+      .prompt{display:flex;align-items:center;gap:8px;background:color-mix(in srgb,var(--accent) 6%,transparent);border:1px solid color-mix(in srgb,var(--accent) 26%,transparent);border-radius:9px;padding:8px 10px}+      .prompt span{flex:1;font:13px ui-monospace,monospace;color:var(--text)}+      .copy{background:var(--accent);color:#00171a;border:0;border-radius:6px;padding:4px 10px;font:600 11px ui-monospace,monospace;cursor:pointer}+      .cli{margin-top:8px;font:11px ui-monospace,monospace;color:var(--dim2)}.cli code{color:var(--dim)}+      .readout{min-height:22px;display:flex;align-items:center;gap:6px;font:600 12px ui-monospace,monospace;color:var(--dim);padding:1px 4px 7px}+      .readout b{color:var(--mp)}+      .mp{cursor:default}+      .mp:hover .mppad,.mp.hot .mppad{fill:color-mix(in srgb,var(--mp) 55%,transparent);stroke-width:2.2}+      .mp:hover .mplbl,.mp.hot .mplbl{fill:var(--text);font-weight:700}+      .genflow{font-size:12px;line-height:1.5;color:var(--dim);margin-bottom:10px}.genflow code{color:var(--text)}.genflow b{color:var(--accent);font-weight:600}+      .askai{display:block;width:100%;text-align:center;background:linear-gradient(100deg,#00e6dc,#39b8ff 60%,#58a6ff);color:#00171a;border:0;border-radius:9px;padding:9px 12px;font:700 13px ui-monospace,monospace;cursor:pointer;margin-bottom:10px}+      .askai:hover{filter:brightness(1.08)}.askai.ok{background:none;border:1px solid var(--accent);color:var(--accent);font-size:11.5px}+      .drop{border:1.5px dashed var(--line2);border-radius:12px;padding:34px 20px;text-align:center;cursor:pointer;background:color-mix(in srgb,var(--accent) 3%,transparent);transition:border-color .12s,background .12s}+      .drop:hover,.drop.over{border-color:var(--accent);background:color-mix(in srgb,var(--accent) 8%,transparent)}+      .drop.err{border-color:#ff6b6b}+      .dropbig{font:600 16px ui-monospace,monospace;color:var(--text)}.dropbig code{color:var(--accent);background:none;padding:0}+      .dropsub{margin-top:7px;font:12.5px ui-monospace,monospace;color:var(--dim)}.dropsub b{color:var(--text)}+      .droperr{margin-top:12px;font:600 12px ui-monospace,monospace;color:#ff8a8a}+      .swap{margin-left:8px;background:none;border:1px solid var(--line2);color:var(--dim);border-radius:6px;padding:1px 7px;font-size:12px;cursor:pointer;vertical-align:1px}+      .swap:hover{border-color:var(--accent);color:var(--accent)}+      .empty{color:var(--dim2);padding:26px;text-align:center;font-family:ui-monospace,monospace}`;+    }+  }+  if (!customElements.get('adom-moleculize')) customElements.define('adom-moleculize', AdomMoleculize);+})();+</script>  </body> </html>