app
Component Widgets
Public Made by Adomby adom
Reusable component widgets for pinouts, thermal vias, molecule relationships, reviewed chip outlines and lightweight four-level LOD cache listings.
← Commit history
add <adom-moleculize>: board->molecule widget (reads .kicad_pcb client-side) live on the page + attached
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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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> · 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">↻</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 · 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 →</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> · (${g.dataset.x}, ${g.dataset.y}) mm · ${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"><adom-moleculize></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"><adom-moleculize></adom-moleculize></span> <span class="tok-com"><!-- drop a .kicad_pcb, or seed { width_mm, height_mm } --></span></code></pre>+ <div class="stage">+ <div class="live"><i></i>Live · drop a .kicad_pcb, or click ↻ 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><adom-*></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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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> · 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">↻</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 · 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 →</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> · (${g.dataset.x}, ${g.dataset.y}) mm · ${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>