main
Colby Knox 1.1.0: every molecule gets large machine pins on the 32 mm grid; gallery rebuilt; corrected notes on kicad-to-molecule c794385 4d ago
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Board to Molecule</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root {
  --bg: #0d1117; --panel: #161b22; --panel2: #1c2330; --line: #2a3340; --text: #e6edf3; --muted: #8b98a8;
  --accent: #00b8b1; --gold: #f5c542; --orange: #f59e0b; --ok: #3fb950; --bad: #f85149;
  --mono: "JetBrains Mono", ui-monospace, monospace;
}
:root[data-theme="light"] { --bg: #f6f8fa; --panel: #ffffff; --panel2: #eef2f6; --line: #d0d7de; --text: #1f2328; --muted: #57606a; }
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; background: var(--bg); color: var(--text); font: 14px/1.45 Inter, system-ui, sans-serif; }
header { height: 44px; display: flex; align-items: center; gap: 12px; padding: 0 14px; background: var(--panel); border-bottom: 1px solid var(--line); }
header .mark { width: 20px; height: 20px; }
header h1 { font-size: 15px; font-weight: 600; margin: 0; }
header .ver { font: 12px var(--mono); color: var(--muted); }
header .spacer { flex: 1; }
header .chip { font-size: 12px; color: var(--muted); border: 1px solid var(--line); border-radius: 999px; padding: 2px 10px; }
.app { display: grid; grid-template-columns: 250px 1fr; height: calc(100% - 44px); }
nav { border-right: 1px solid var(--line); background: var(--panel); overflow: auto; padding: 10px 8px; }
nav h2 { font-size: 11px; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); margin: 14px 8px 6px; }
nav button { display: block; width: 100%; text-align: left; background: none; border: 1px solid transparent; color: var(--text); padding: 8px 10px; border-radius: 8px; cursor: pointer; font: inherit; min-height: 44px; }
nav button small { display: block; color: var(--muted); font-size: 12px; }
nav button:hover { background: var(--panel2); }
nav button.on { background: var(--panel2); border-color: var(--accent); }
main { overflow: auto; padding: 16px 18px 40px; }
.title { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
.title h2 { margin: 0; font-size: 20px; }
.badge { font-size: 12px; padding: 2px 8px; border-radius: 6px; border: 1px solid var(--line); color: var(--muted); }
.badge.ok { color: var(--ok); border-color: color-mix(in srgb, var(--ok) 50%, transparent); }
.badge.bad { color: var(--bad); border-color: color-mix(in srgb, var(--bad) 50%, transparent); }
.badge.eda { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 50%, transparent); }
.stats { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 8px; margin: 14px 0; }
.stat { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px; }
.stat b { display: block; font-size: 17px; font-weight: 600; }
.stat span { color: var(--muted); font-size: 12px; }
.pair { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.triple { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
.card { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; overflow: hidden; position: relative; }
.card .cap { position: absolute; left: 10px; top: 8px; font-size: 12px; color: var(--muted); background: color-mix(in srgb, var(--panel) 80%, transparent); padding: 2px 8px; border-radius: 6px; z-index: 5; pointer-events: none; }
.card .cap b { color: var(--text); font-weight: 600; }
.view3d { height: 420px; display: block; width: 100%; border: 0; background: #0b0f14; }
.card img { width: 100%; display: block; background: #0b0f14; aspect-ratio: 1 / 1; object-fit: contain; }
section { margin-top: 18px; }
section > h3 { font-size: 13px; color: var(--muted); font-weight: 600; margin: 0 0 8px; text-transform: uppercase; letter-spacing: .06em; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
td, th { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--line); }
th { color: var(--muted); font-weight: 500; }
td.mono, .mono { font-family: var(--mono); font-size: 12px; }
.files a { color: var(--accent); text-decoration: none; margin-right: 14px; display: inline-block; padding: 6px 0; }
.files a:hover { text-decoration: underline; }
.toolbar { display: flex; gap: 8px; margin: 0 0 8px; flex-wrap: wrap; }
.toolbar button { background: var(--panel); border: 1px solid var(--line); color: var(--text); border-radius: 8px; padding: 6px 12px; cursor: pointer; font: inherit; font-size: 13px; min-height: 36px; }
.toolbar button:hover { border-color: var(--accent); }
.err { color: var(--bad); font-size: 13px; padding: 40px 16px; }
#toast { position: fixed; right: 16px; bottom: 16px; background: var(--panel2); border: 1px solid var(--line); border-left: 3px solid var(--accent); padding: 10px 14px; border-radius: 8px; opacity: 0; transition: opacity .2s; pointer-events: none; max-width: 360px; }
#toast.show { opacity: 1; }
.note { color: var(--muted); font-size: 13px; max-width: 900px; }
@media (max-width: 860px) {
  .app { grid-template-columns: 1fr; height: auto; }
  nav { display: flex; overflow-x: auto; gap: 6px; border-right: 0; border-bottom: 1px solid var(--line); }
  nav h2 { display: none; }
  nav button { white-space: nowrap; width: auto; }
  .pair, .triple { grid-template-columns: 1fr; }
  .view3d { height: 320px; }
}
</style>
<script>
  // Node-style globals the viewer bundle's inspector deps expect, set before loading it.
  window.process = window.process || { env: { NODE_ENV: "production" } };
  window.global = window.global || window;
  // Relay console errors and warnings to the server log (GET-free, best effort).
  (function () {
    const send = (level, args) => { try { fetch('api/console', { method: 'POST', body: JSON.stringify({ level, msg: args.map(a => a && a.stack ? a.stack : String(a)).join(' ') }) }); } catch (e) {} };
    for (const level of ['error', 'warn']) { const o = console[level]; console[level] = (...a) => { send(level, a); o.apply(console, a); }; }
    addEventListener('error', e => send('error', [e.message + ' @' + e.filename + ':' + e.lineno]));
    addEventListener('unhandledrejection', e => send('error', ['unhandled: ', e.reason]));
  })();
</script>
</head>
<body>
<header>
  <svg class="mark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><rect x="3" y="3" width="18" height="18" rx="2"/><rect x="8" y="8" width="8" height="8" rx="1"/><circle cx="6" cy="6" r="1.2" fill="currentColor"/><circle cx="18" cy="6" r="1.2" fill="currentColor"/><circle cx="6" cy="18" r="1.2" fill="currentColor"/><circle cx="18" cy="18" r="1.2" fill="currentColor"/></svg>
  <h1>Board to Molecule</h1>
  <span class="ver" id="ver"></span>
  <span class="spacer"></span>
  <span class="chip" title="Every lane reads and writes its own native format">KiCad / Fusion / Altium</span>
</header>
<div class="app">
  <nav id="nav"></nav>
  <main id="main"><div class="note">Loading examples...</div></main>
</div>
<div id="toast"></div>
<script type="module">
const EDA = { kicad: 'KiCad', eagle: 'Fusion / EAGLE', altium: 'Altium' };
const NATIVE = { kicad: '.kicad_pcb', eagle: '.brd', altium: '.PcbDoc' };
const state = { examples: [], current: null, views: [] };
window.b2m = state;

function toast(msg) {
  const t = document.getElementById('toast');
  t.textContent = msg; t.classList.add('show');
  clearTimeout(toast.h); toast.h = setTimeout(() => t.classList.remove('show'), 2600);
}
const fmt = (v, d = 1) => (v == null ? '-' : Number(v).toFixed(d));
const esc = s => String(s ?? '').replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));

const log = msg => { try { fetch('api/console', { method: 'POST', body: JSON.stringify({ level: 'log', msg }) }); } catch (e) {} };
// Each model gets its own frame (the Adom viewer is one per document); the parent
// relays camera moves so both boards stay on the same view at true scale.
const frames = { v0: null, v1: null };
let afterPose = null;
const send = (id, msg) => frames[id]?.contentWindow?.postMessage({ source: 'b2m-parent', ...msg }, '*');
addEventListener('message', (e) => {
  const m = e.data;
  if (!m || m.source !== 'b2m-3d') return;
  const other = m.id === 'v0' ? 'v1' : 'v0';
  if (m.type === 'error') { toast('3D viewer: ' + m.message); log('3d error ' + m.id + ' ' + m.message); return; }
  if (m.type === 'ready') {
    log('3d ready ' + m.id);
    if (m.id === 'v1') { afterPose = m.pose; send('v0', { type: 'camera', pose: m.pose }); }
    else if (afterPose) send('v0', { type: 'camera', pose: afterPose });
    return;
  }
  if (m.type === 'camera') {
    if (m.id === 'v1') afterPose = m.pose;
    send(other, { type: 'camera', pose: m.pose });
  }
});

function disposeViews() { frames.v0 = frames.v1 = null; afterPose = null; }

function nav() {
  const el = document.getElementById('nav');
  const groups = {};
  for (const ex of state.examples) (groups[ex.manifest.eda] ??= []).push(ex);
  el.innerHTML = Object.keys(EDA).filter(k => groups[k]).map(k => `<h2>${EDA[k]}</h2>` + groups[k].map(ex => {
    const p = ex.manifest.plan;
    return `<button data-id="${esc(ex.id)}" title="Show ${esc(ex.title)}">${esc(ex.title)}<small>${fmt(p.orig_bbox.w)} x ${fmt(p.orig_bbox.h)} mm to ${fmt(p.new_bbox.w)} x ${fmt(p.new_bbox.h)} mm</small></button>`;
  }).join('')).join('');
  el.querySelectorAll('button').forEach(b => b.onclick = () => show(b.dataset.id));
}

async function show(id) {
  const ex = state.examples.find(e => e.id === id) ?? state.examples[0];
  if (!ex) return;
  state.current = ex.id;
  history.replaceState(null, '', `?example=${encodeURIComponent(ex.id)}`);
  document.querySelectorAll('nav button').forEach(b => b.classList.toggle('on', b.dataset.id === ex.id));
  const m = ex.manifest, p = m.plan, a = m.after ?? {}, bf = m.before ?? {};
  const base = `examples/${encodeURIComponent(ex.id)}/`;
  const anchored = a.molecule_anchored === true;
  const credit = ex.credit ? `<div class="note">Source: ${ex.credit}</div>` : '';
  disposeViews();
  document.getElementById('main').innerHTML = `
    <div class="title"><h2>${esc(ex.title)}</h2>
      <span class="badge eda">${EDA[m.eda]} ${NATIVE[m.eda]}</span>
      <span class="badge ${anchored ? 'ok' : 'bad'}" title="step2glb molecule mode: MP1-MP4 found and the board anchored Z-up at MP1">${anchored ? 'anchored by step2glb' : 'not anchored'}</span>
      <span class="badge" title="Time to read the board, plan, and write the molecule in its own format">${fmt(m.convert_ms, 0)} ms convert</span>
    </div>
    ${credit}
    <div class="stats">
      <div class="stat"><b>${fmt(p.orig_bbox.w)} x ${fmt(p.orig_bbox.h)}</b><span>original, mm</span></div>
      <div class="stat"><b>${fmt(p.new_bbox.w)} x ${fmt(p.new_bbox.h)}</b><span>molecule, mm</span></div>
      <div class="stat"><b>${Math.round((p.pins[3].x - p.pins[0].x) / p.grid_mm)} x ${Math.round((p.pins[1].y - p.pins[0].y) / p.grid_mm)}</b><span>32 mm grid steps, ${p.pin.eagle_package}</span></div>
      <div class="stat"><b>${p.grid_mm} mm</b><span>grid, ${p.edge_offset_mm} mm edge offset</span></div>
      <div class="stat"><b>${m.board.components}</b><span>components, ${m.board.pads} pads</span></div>
      <div class="stat"><b>${m.board.track_ends_on_copper[0]}/${m.board.track_ends_on_copper[1]}</b><span>track ends on copper (reader check)</span></div>
    </div>
    <section><h3>3D, synced cameras</h3>
      <div class="toolbar">
        <button id="b-iso" title="Reset both cameras to the three-quarter view">Iso view</button>
        <button id="b-top" title="Look straight down at both boards">Top view</button>
        <button id="b-bottom" title="Look up at both boards from underneath">Bottom view</button>
      </div>
      <div class="pair">
        <div class="card"><div class="cap"><b>Before</b> original board</div><iframe class="view3d" id="v0" title="Original board in the Adom 3D viewer"></iframe></div>
        <div class="card"><div class="cap"><b>After</b> molecule, ${a.footprint_pins ?? '?'} machine pins</div><iframe class="view3d" id="v1" title="Molecule board in the Adom 3D viewer"></iframe></div>
      </div>
    </section>
    <section><h3>2D</h3>
      <div class="triple">
        <div class="card"><div class="cap"><b>Before</b></div><img src="${base}before.svg" alt="original board, top view"></div>
        <div class="card"><div class="cap"><b>Plan</b> wings, pins, original outline dashed</div><img src="${base}overlay.svg" alt="molecule plan over the original board"></div>
        <div class="card"><div class="cap"><b>After</b> read back from the written file</div><img src="${base}after.svg" alt="molecule board read back from the output file"></div>
      </div>
    </section>
    <section><h3>Machine pins</h3>
      <table><tr><th>Ref</th><th>Role</th><th>X (mm)</th><th>Y (mm)</th></tr>
      ${p.pins.map(q => `<tr><td>${q.ref}</td><td>${q.role}</td><td class="mono">${fmt(q.x, 3)}</td><td class="mono">${fmt(q.y, 3)}</td></tr>`).join('')}</table>
      <div class="note">Coordinates are in the hub frame: millimeters, Y up, the source board's own origin. KiCad files store Y down, so their Y values appear negated in the file.</div>
    </section>
    <section><h3>step2glb</h3>
      <table>
        <tr><th></th><th>GLB</th><th>Anchored</th><th>Pins</th><th>contact_z</th><th>Overlay</th><th>STEP export</th></tr>
        <tr><td>Before</td><td class="mono">${fmt((bf.glb_bytes ?? 0) / 1024, 0)} KB</td><td>-</td><td>-</td><td>-</td><td>-</td><td class="mono">${fmt(bf.step_s, 1)} s</td></tr>
        <tr><td>After</td><td class="mono">${fmt((a.glb_bytes ?? 0) / 1024, 0)} KB</td><td>${a.molecule_anchored ?? '-'}</td><td>${a.footprint_pins ?? '-'}</td><td class="mono">${a.contact_z ?? '-'}</td><td>${a.silkscreen_applied ?? '-'}</td><td class="mono">${fmt(a.step_s, 1)} s</td></tr>
      </table>
    </section>
    <section><h3>Files</h3><div class="files">
      <a href="${base}${m.files.source}" download title="The board as downloaded">source${NATIVE[m.eda]}</a>
      <a href="${base}${m.files.molecule}" download title="The molecule, written in the same format">molecule${NATIVE[m.eda]}</a>
      <a href="${base}plan.json" download title="The EDA-neutral molecule plan">plan.json</a>
      <a href="${base}before.glb" download>before.glb</a>
      <a href="${base}after.glb" download>after.glb</a>
    </div></section>`;
  for (const [id, file] of [['v1', 'after.glb'], ['v0', 'before.glb']]) {
    const f = document.getElementById(id);
    frames[id] = f;
    f.src = `viewer3d.html?id=${id}&model=${encodeURIComponent(base + file)}`;
  }
  document.getElementById('b-iso').onclick = () => { send('v1', { type: 'home' }); toast('Iso view on both boards'); };
  document.getElementById('b-top').onclick = () => { send('v1', { type: 'view', pose: { beta: 0.0001 } }); toast('Top view'); };
  document.getElementById('b-bottom').onclick = () => { send('v1', { type: 'view', pose: { beta: Math.PI - 0.0001 } }); toast('Bottom view'); };
  state.loaded = ex.id;
}

async function boot() {
  const r = await fetch('api/examples');
  const j = await r.json();
  state.examples = j.examples;
  document.getElementById('ver').textContent = 'v' + j.version;
  if (!state.examples.length) { document.getElementById('main').innerHTML = '<div class="err">No examples yet. Run <span class="mono">board-to-molecule showcase &lt;board&gt; -o &lt;gallery&gt;/&lt;name&gt;</span>, then reload.</div>'; return; }
  nav();
  const want = new URLSearchParams(location.search).get('example');
  show(want);
}
boot();
</script>
</body>
</html>