main
Colby Knox adom-fields 0.10.6: grid covers the board (issue 27); one board thickness and solid-uncoupled (issue 28); decoupling co… cb236bf
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623
// Adom Fields: the board in 3D with the fields on its copper, driven by the AI over the live channel.
(async () => {
  const $ = (s) => document.querySelector(s);
  const state = { view: { quantity: 'current', net: null, layer: 'F.Cu', camera: 'iso', air: 'still', issue: null, chip: null, drift: true, focus: null, parts: 'auto', explode: 0, speed: 1 }, fields: null, raw: {}, tex: {}, layerMeshes: {}, light: false };
  // every rotation runs at half the speed it did in 0.4: the flow plays these clips ten times faster,
  // and a sway that reads as calm live turned frenetic on the sped-up video
  const SLOW = 2;
  const LAYERS = ['F.Cu', 'In1.Cu', 'In2.Cu', 'B.Cu'];
  const Z = { 'F.Cu': 0.8, 'In1.Cu': 0.6, 'In2.Cu': -0.6, 'B.Cu': -0.8 };
  const slug = (s) => s.replace(/[^A-Za-z0-9]/g, '_');
  // console to the server, so the AI reads what the UI does
  const post = (p, body) => fetch(p, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}) }).then(r => r.json()).catch(() => null);
  for (const level of ['log', 'warn', 'error']) { const orig = console[level].bind(console); console[level] = (...a) => { orig(...a); post('console', { level, text: a.map(x => typeof x === 'string' ? x : JSON.stringify(x)).join(' ') }); }; }
  window.addEventListener('error', e => post('console', { level: 'error', text: e.message }));
  window.addEventListener('unhandledrejection', e => { post('console', { level: 'error', text: 'unhandled: ' + (e.reason && e.reason.stack || String(e.reason)) }); const b = $('#boardName'); if (b && b.textContent === 'loading') b.textContent = 'failed: ' + String(e.reason); });
  // toasts
  function toast(text, kind = 'info', sticky = false) { const t = document.createElement('div'); t.className = 'toast ' + kind; t.textContent = text; $('#toasts').appendChild(t); const kill = () => t.remove(); t.addEventListener('click', kill); if (!sticky) setTimeout(kill, 4200); }
  // tooltips (fixed div, clamped to the viewport)
  const tip = $('#tip'); let tipTimer = null;
  document.addEventListener('mouseover', e => { const el = e.target.closest('[data-tip]'); if (!el) return; clearTimeout(tipTimer); tipTimer = setTimeout(() => { tip.textContent = el.dataset.tip; tip.classList.add('show'); const r = el.getBoundingClientRect(); const M = 8; let x = r.left + r.width / 2 - tip.offsetWidth / 2, y = r.bottom + 10; if (y + tip.offsetHeight > innerHeight - M) y = r.top - tip.offsetHeight - 10; x = Math.max(M, Math.min(innerWidth - tip.offsetWidth - M, x)); y = Math.max(M, y); tip.style.left = x + 'px'; tip.style.top = y + 'px'; }, 600); });
  document.addEventListener('mouseout', e => { if (e.target.closest && e.target.closest('[data-tip]')) { clearTimeout(tipTimer); tip.classList.remove('show'); } });

  // ---- the canonical Adom 3D viewer (adom/adom-3d-viewer), the board as its content ----------
  // rule 2 of 3d-viewer-design: start from the canonical viewer, extend it, never a fresh scene
  await import('./vendor/3d-viewer/adom-3d-viewer-babylon9.esm.js');
  const VLIB = window.Adom3DViewerBabylon9;
  const BAB = VLIB.BABYLON;
  const host = $('#host');
  const V = VLIB.init(host, { zUp: true, showViewCube: true, showGround: false, environment: 'studio', onMeshHover: () => null });
  await V.whenReady();
  const scene = V.getScene();
  const camera = V.getCamera();
  window.fieldsViewer = V; window.fieldsCamera = camera; window.fieldsScene = scene;
  console.log('viewer', VLIB.version, 'ready');
  const fpsTimer = setInterval(() => { const e = V.getEngine(); if (e) $('#fps').textContent = e.getFps().toFixed(0) + ' fps'; }, 1000);
  void fpsTimer;

  let board = null, root = null, viaRoot = null, labels = [];
  // z up: x to the right, y up the board (KiCad's y runs down), z out of the top copper
  // fan levels (spec fanLevels): the air control becomes Fan off / 25 / 50 / 75 / 100 %, each its own solve
  function fanKey(air) { const fs = state.fields && state.fields.airStates; if (!fs) return null; if (air === 'still') return 'fan0'; if (air === 'moving') return 'fan100'; return fs.levels.some(l => l.name === air) ? air : null; }
  function riseOf(c) { const k = fanKey(state.view.air); if (k) { const l = state.fields.airStates.levels.find(x => x.name === k); const r = l && l.chips.find(x => x.reference === c.reference); if (r) return r.riseC; } return state.view.air === 'moving' ? c.riseMovingC : c.riseC; }
  function airLabel() { const k = fanKey(state.view.air); return k ? (k === 'fan0' ? 'fan off' : 'fan ' + k.slice(3) + ' %') : state.view.air + ' air'; }
  function boardSpace(x, y, z = 0) { return new BAB.Vector3(x - board.x0 - board.widthMm / 2, board.y0 + board.heightMm / 2 - y, z); }
  function buildBoard(f) {
    board = f.board;
    // the copper slots from the solve (six since 0.7.16; older solves four), the board's real ones drawn
    if (Array.isArray(board.layers) && board.layers.length) LAYERS.splice(0, LAYERS.length, ...board.layers);
    const realCu = board.copperLayers || LAYERS;
    const cu = board.copperUmByLayer || Object.fromEntries(LAYERS.map(l=>[l,board.copperUm]));
    const cuT = (L) => realCu.includes(L) ? (cu[L] || board.copperUm) : 0;
    const dielectric = (board.slotDielectricMm && board.slotDielectricMm.length === LAYERS.length - 1) ? board.slotDielectricMm : (board.dielectricMm || [0.2,1.065,0.2]);
    Z['F.Cu']=board.thicknessMm/2-cuT('F.Cu')/2000;
    for (let k=1;k<LAYERS.length;k++) Z[LAYERS[k]]=Z[LAYERS[k-1]]-dielectric[k-1]-(cuT(LAYERS[k-1])+cuT(LAYERS[k]))/2000;
    // the layer buttons: the board's own copper
    const seg = document.getElementById('layer'); if (seg) seg.innerHTML = realCu.map(L => `<button data-l="${L}">${L === 'F.Cu' || L === 'B.Cu' ? L : L.replace('.Cu', '')}</button>`).join('');

    if (f.airStates) { const seg = document.getElementById('air'); const h3 = document.querySelector('#airSec h3'); if (h3) h3.textContent = 'Fan';
      if (seg) seg.innerHTML = f.airStates.levels.map(l => `<button data-a="${l.name}" data-tip="${l.level === 0 ? 'Fan off: still air' : 'Fan at ' + Math.round(l.level * 100) + ' %'}, h ${l.hBoardWM2k} W/m²K on the board">${l.level === 0 ? 'Off' : Math.round(l.level * 100) + '%'}</button>`).join(''); }
    const W = board.widthMm, H = board.heightMm, T = board.thicknessMm;
    root = new BAB.TransformNode('fields-board', scene);
    const box = BAB.MeshBuilder.CreateBox('fr4_board', { width: W, height: H, depth: T }, scene);
    const bm = new BAB.PBRMaterial('fr4', scene); bm.albedoColor = new BAB.Color3(0.05, 0.16, 0.14); bm.metallic = 0; bm.roughness = 0.9; bm.alpha = 0.94; box.material = bm; box.isPickable = false; box.parent = root;
    for (const L of LAYERS) {
      const plane = BAB.MeshBuilder.CreatePlane('L-' + L, { width: W, height: H, sideOrientation: BAB.Mesh.DOUBLESIDE }, scene);
      plane.position.z = Z[L];
      const m = new BAB.StandardMaterial('M-' + L, scene); m.disableLighting = true; m.backFaceCulling = false; m.emissiveColor = new BAB.Color3(1, 1, 1); m.useAlphaFromDiffuseTexture = true;
      plane.material = m; plane.isPickable = L === 'F.Cu' || L === 'B.Cu'; plane.parent = root;
      plane.setEnabled(realCu.includes(L));   // an empty slot (a two-layer board's inner ones) draws nothing
      state.layerMeshes[L] = plane;
    }
    for (const hs of f.heatsinks || []) {
      const b = hs.definition, [x0,y0,x1,y1] = b.boundsMm;
      const mesh = BAB.MeshBuilder.CreateBox('heatsink-' + b.name, {width:x1-x0,height:y1-y0,depth:b.thicknessMm},scene);
      mesh.position = boardSpace((x0+x1)/2,(y0+y1)/2,(b.layer === 'F.Cu' ? 1 : -1)*(T/2+b.thicknessMm/2+0.01));
      const mat = new BAB.PBRMaterial('heatsink-material-' + b.name,scene);
      mat.albedoColor = new BAB.Color3(0.55,0.60,0.64); mat.metallic=0.85; mat.roughness=0.45; mat.alpha=0.65;
      mesh.material=mat; mesh.parent=root; mesh.isPickable=false;
    }
    // the vias: one merged mesh (the viewer's shadow setup has nothing to say to a merged mesh)
    const vm = new BAB.StandardMaterial('vm', scene); vm.emissiveColor = new BAB.Color3(0.5, 0.83, 0.94); vm.disableLighting = true;
    const cyls = f.vias.map(v => { const c = BAB.MeshBuilder.CreateCylinder('via', { diameter: v.size, height: T + 0.05, tessellation: 8 }, scene); c.rotation.x = Math.PI / 2; const p = boardSpace(v.x, v.y); c.position.set(p.x, p.y, 0); return c; });
    viaRoot = cyls.length ? BAB.Mesh.MergeMeshes(cyls, true, true, undefined, false, false) : new BAB.Mesh('barrel', scene);
    viaRoot.name = 'barrel'; viaRoot.material = vm; viaRoot.isPickable = false; viaRoot.parent = root;
    // the hot parts: HTML labels that follow their tab on screen
    const lab = $('#labels'); lab.innerHTML = '';
    for (const c of f.chips) { const el = document.createElement('div'); el.className = 'lbl'; lab.appendChild(el); labels.push({ el, c, at: boardSpace(c.x, c.y, T / 2 + 0.3) }); }
    scene.onAfterRenderObservable.add(() => {
      const e = V.getEngine(); const w = e.getRenderWidth(), h = e.getRenderHeight();
      for (const { el, at } of labels) { const p = BAB.Vector3.Project(at, BAB.Matrix.Identity(), scene.getTransformMatrix(), camera.viewport.toGlobal(w, h)); el.style.left = (p.x / (w / host.clientWidth)) + 'px'; el.style.top = (p.y / (h / host.clientHeight)) + 'px'; el.style.display = (p.z > 1 || p.z < 0) ? 'none' : ''; }
    });
    V.addContentRoot(root);
    V.frameModel(0.85);
    loadParts(f).catch(e => console.warn('parts: ' + (e && e.message || e)));
    loadSilk(f).catch(e => console.warn('silk: ' + (e && e.message || e)));
  }
  // ---- the silkscreen: KiCad's own SVG of each silk layer, drawn once into a texture on one plane per side,
  // so the board's printing (J1, VIN, the logo) reads over the fields for the cost of two quads
  const silk = { meshes: [] };
  async function loadSilk(f) {
    const s = f.silk; if (!s) { console.log('silk: none in this solve (`adom-fields silk --board <pcb> --fields <dir>` adds it)'); return; }
    for (const [side, info] of Object.entries(s)) {
      if (!info || !info.file) continue;
      const img = new Image(); img.src = info.file;
      try { await img.decode(); } catch (e) { console.warn('silk: ' + info.file + ' did not load'); continue; }
      const pxmm = Math.min(48, 4096 / Math.max(info.widthMm, info.heightMm)), cw = Math.round(info.widthMm * pxmm), ch = Math.round(info.heightMm * pxmm);
      const dt = new BAB.DynamicTexture('silk-' + side, { width: cw, height: ch }, scene, true);
      const g = dt.getContext(); g.clearRect(0, 0, cw, ch); g.drawImage(img, 0, 0, cw, ch);
      // KiCad plots it black: keep the shapes, paint them silkscreen white
      g.globalCompositeOperation = 'source-in'; g.fillStyle = '#f4f6f7'; g.fillRect(0, 0, cw, ch); g.globalCompositeOperation = 'source-over';
      dt.hasAlpha = true; dt.update(true);
      const plane = BAB.MeshBuilder.CreatePlane('silk-' + side, { width: info.widthMm, height: info.heightMm, sideOrientation: BAB.Mesh.DOUBLESIDE }, scene);
      const m = new BAB.StandardMaterial('silk-m-' + side, scene); m.diffuseTexture = dt; m.emissiveColor = new BAB.Color3(1, 1, 1); m.disableLighting = true; m.useAlphaFromDiffuseTexture = true; m.backFaceCulling = false; m.alpha = 0.92;
      plane.material = m; plane.parent = root; plane.isPickable = false; plane.renderingGroupId = 1;
      silk.meshes.push({ side, plane });
    }
    console.log(`silk: ${silk.meshes.map(x => x.side).join(' and ')} silkscreen on ${silk.meshes.length} plane(s)`);
  }
  scene.onBeforeRenderObservable.add(() => {
    if (!silk.meshes.length || !root) return;
    const on = state.view.silk !== false && state.view.quantity !== 'river';
    // each side shows only from its own side of the board, riding its copper layer (the exploded stack too)
    const inv = root.getWorldMatrix().clone().invert(), cam = BAB.Vector3.TransformCoordinates(camera.globalPosition || camera.position, inv);
    for (const { side, plane } of silk.meshes) {
      const L = state.layerMeshes[side === 'front' ? 'F.Cu' : 'B.Cu']; const z = (L ? L.position.z : 0) + (side === 'front' ? 0.06 : -0.06);
      plane.position.z = z; plane.setEnabled(on && (side === 'front' ? cam.z > z : cam.z < z));
    }
    const b = $('#btnSilk'); if (b) { b.hidden = !silk.meshes.length; b.classList.toggle('is-on', state.view.silk !== false); }
  });
  // ---- the parts: each footprint's own 3D model, placed the way KiCad places it ----------------
  // the hot zones say where the heat is; the chip standing in them says why
  const parts = { root: null, mats: new Set(), meshes: [], boxes: [], byRef: {}, ready: false };
  const deg = (d) => d * Math.PI / 180;
  function kicadPlacement(p, m) {
    // KiCad: fp = T(x, -y, surface) Rz(rot) [Ry(pi) Rz(pi) on the bottom]; model = T(offset) Rz(-rz) Ry(-ry) Rx(-rx) S(scale)
    // Babylon matrices compose left to right in the order they apply
    const T = board.thicknessMm, M = BAB.Matrix;
    let mx = M.Identity();
    if (m) {
      mx = M.Scaling(1000, 1000, 1000)   // the GLB is in metres
        .multiply(M.Scaling(m.scale[0], m.scale[1], m.scale[2]))
        .multiply(M.RotationX(deg(-m.rotate[0]))).multiply(M.RotationY(deg(-m.rotate[1]))).multiply(M.RotationZ(deg(-m.rotate[2])))
        .multiply(M.Translation(m.offset[0], m.offset[1], m.offset[2]));
    }
    if (p.side === 'bottom') mx = mx.multiply(M.RotationY(Math.PI)).multiply(M.RotationZ(Math.PI));
    const at = boardSpace(p.x, p.y, p.side === 'bottom' ? -T / 2 : T / 2);
    return mx.multiply(M.RotationZ(deg(p.rotation || 0))).multiply(M.Translation(at.x, at.y, at.z));
  }
  function place(node, mx) { const s = new BAB.Vector3(), q = new BAB.Quaternion(), t = new BAB.Vector3(); mx.decompose(s, q, t); node.scaling = s; node.rotationQuaternion = q; node.position = t; }
  async function loadParts(f) {
    const items = (f.parts && f.parts.items) || [];
    if (!items.length) { console.log('parts: none in fields.json (run `adom-fields parts --board <pcb> --fields <dir>`)'); return; }
    parts.root = new BAB.TransformNode('fields-parts', scene); parts.root.parent = root;
    const containers = {};
    const load = (url) => containers[url] || (containers[url] = BAB.LoadAssetContainerAsync(url, scene, { pluginExtension: '.glb' }).catch(e => { console.warn('parts: ' + url + ' did not load: ' + (e && e.message || e)); return null; }));
    let n = 0;
    for (const p of items) {
      if (p.kind === 'model') {
        for (const m of p.models) {
          const c = await load(m.glb); if (!c) continue;
          const inst = c.instantiateModelsToScene((name) => `${p.ref}:${name}`, false, { doNotInstantiate: true });
          const holder = new BAB.TransformNode('part-' + p.ref, scene); holder.parent = parts.root; place(holder, kicadPlacement(p, m));
          for (const r of inst.rootNodes) {
            // the model's own frame is KiCad's (Z up, mm after the scale): undo the loader's handedness flip
            r.parent = holder; r.position = BAB.Vector3.Zero(); r.rotationQuaternion = BAB.Quaternion.Identity(); r.scaling = new BAB.Vector3(1, 1, 1);
          }
          // a CAD assembly arrives as thousands of small meshes, one draw call each, and the field animation crawls: merge
          // each part's meshes by material (a few dozen draw calls), keeping the part's own transform
          // an assembly model often carries its own copy of the PCB (substrate, mask, silk, copper): Fields draws the board
          // itself, so a board-sized thin slab inside a part would cover the solved copper. Hide those, keep the parts.
          let hid = 0;
          for (const x of holder.getChildMeshes(false)) {
            if (x.getTotalVertices() === 0) continue;
            x.computeWorldMatrix(true); const bb = x.getBoundingInfo().boundingBox, ext = bb.maximumWorld.subtract(bb.minimumWorld);
            if (ext.x >= 0.85 * board.widthMm && ext.y >= 0.85 * board.heightMm && ext.z < 2.5) { x.setEnabled(false); x.dispose(); hid++; }
          }
          if (hid) console.log(`parts: ${p.ref} carries its own board (${hid} board-sized slab mesh(es)); hidden so the solved copper shows`);
          let meshes = holder.getChildMeshes(false).filter(x => x.getTotalVertices() > 0);
          if (meshes.length > 24) {
            const byMat = new Map(); for (const x of meshes) { const k = x.material || null; if (!byMat.has(k)) byMat.set(k, []); byMat.get(k).push(x); }
            const merged = [];
            for (const [, group] of byMat) {
              if (group.length === 1) { merged.push(group[0]); continue; }
              const mm = BAB.Mesh.MergeMeshes(group, true, true, undefined, false, false);
              if (mm) { mm.setParent(holder); merged.push(mm); } else merged.push(...group);
            }
            console.log(`parts: ${p.ref} merged ${meshes.length} meshes into ${merged.length} by material`);
            meshes = merged;
          }
          for (const mesh of meshes) { mesh.isPickable = false; parts.meshes.push(mesh); (parts.byRef[p.ref] = parts.byRef[p.ref] || []).push(mesh); if (mesh.material) parts.mats.add(mesh.material); }
          n++;
        }
      } else if (p.kind === 'box' && p.bbox) {
        // no model on the footprint: a low box from its courtyard, so the part is still there
        const [x0, y0, x1, y1] = p.bbox, h = 1.0;
        const b = BAB.MeshBuilder.CreateBox('partbox-' + p.ref, { width: x1 - x0, height: y1 - y0, depth: h }, scene);
        const holder = new BAB.TransformNode('part-' + p.ref, scene); holder.parent = parts.root; place(holder, kicadPlacement(p, null));
        b.parent = holder; b.position.set((x0 + x1) / 2, -(y0 + y1) / 2, h / 2); b.isPickable = false;
        const bm2 = new BAB.PBRMaterial('partbox-m-' + p.ref, scene); bm2.albedoColor = new BAB.Color3(0.45, 0.47, 0.5); bm2.metallic = 0; bm2.roughness = 0.8; b.material = bm2;
        parts.mats.add(bm2); parts.meshes.push(b); parts.boxes.push(p.ref); (parts.byRef[p.ref] = parts.byRef[p.ref] || []).push(b);
      }
    }
    for (const mat of parts.mats) { mat.backFaceCulling = false; mat._partAlpha0 = mat.alpha; }
    scene.setRenderingAutoClearDepthStencil(1, false, false, false);
    // hot-part labels ride on top of their part, not inside it
    for (const l of labels) { const ms = parts.byRef[l.c.reference]; if (!ms || !ms.length) continue; let top = -1e9; for (const m of ms) { m.computeWorldMatrix(true); top = Math.max(top, m.getBoundingInfo().boundingBox.maximumWorld.z); } l.at.z = Math.max(l.at.z, top + 0.4); }
    parts.ready = true;
    // the parts change what the scene holds: re-frame the default view around them
    if (state.view.camera === 'iso' && !tweening) setTimeout(() => fitCamera('iso'), 0);
    console.log(`parts: ${n} models placed, ${parts.boxes.length} courtyard boxes${parts.boxes.length ? ' (' + parts.boxes.join(', ') + ')' : ''}`);
    applyParts();
  }
  // how see-through a ghosted part is: the view's own value (the AI sets it), else this viewer's slider, else 0.33
  function ghostAlpha() { const v = +state.view.ghost; if (v > 0) return Math.min(1, v); let s = NaN; try { s = parseFloat(localStorage.getItem('fields.ghostAlpha')); } catch (e) { void e; } return s > 0 ? s : 0.33; }
  function syncGhostCtl() { const c = $('#ghostCtl'); if (!c) return; c.hidden = partsMode() !== 'ghost'; const a = ghostAlpha(); $('#ghostAlpha').value = a; $('#ghostVal').textContent = Math.round(a * 100) + '%'; }
  document.addEventListener('input', (e) => { if (e.target.id !== 'ghostAlpha') return; const a = +e.target.value; try { localStorage.setItem('fields.ghostAlpha', String(a)); } catch (err) { void err; } state.view.ghost = null; applyParts(); });
  document.addEventListener('change', (e) => { if (e.target.id === 'probeH') setView({ probe: +e.target.value }); });
  document.addEventListener('input', (e) => { if (e.target.id === 'probeH') { const l = $('#probeVal'); if (l) l.textContent = (+e.target.value).toFixed(1) + ' mm'; } });
  document.addEventListener('change', (e) => { if (e.target.id === 'ghostAlpha') console.log('parts: ghost opacity set to ' + Math.round(e.target.value * 100) + '% by the viewer'); });
  // ---- narration: each view explained out loud ------------------------------------------------------
  // The text is the page's own template with this board's numbers (no AI writes it at view time); the
  // server voices it with adom-tts, cached by its text. "first" (the default) speaks each kind of view the
  // first time this viewer opens it; "always" every time; "ask" only on the Explain button.
  const narr = { audio: null, mode: 'first', heard: {}, busy: false, view: null };
  try { narr.mode = localStorage.getItem('fields.narrate') || 'first'; narr.heard = JSON.parse(localStorage.getItem('fields.narrated') || '{}'); } catch (e) { void e; }
  function narrationText(q) {
    const f = state.fields; if (!f) return '';
    if (q === 'cycle') return window.fieldsCycle ? window.fieldsCycle.narration() : '';
    if (q === 'magnetic') return window.fieldsMagnetic ? window.fieldsMagnetic.narration() : '';
    if (q === 'emc') return window.fieldsEmc ? window.fieldsEmc.narration() : '';
    if (q === 'ground') return window.fieldsGround ? window.fieldsGround.narration() : '';
    if (q === 'noise') return window.fieldsNoise ? window.fieldsNoise.narration() : '';
    if (q === 'pdn') return window.fieldsPdn ? window.fieldsPdn.narration() : '';
    if (window.fieldsFlow && ['flow', 'river', 'energy', 'light', 'charge'].includes(q)) return window.fieldsFlow.narration(q);
    const n0 = (f.nets || [])[0], c0 = [...(f.chips || [])].sort((a,b) => state.view.air === 'moving' ? b.riseMovingC-a.riseMovingC : b.riseC-a.riseC)[0];
    if (q === 'current') return `This is current density: how many amps flow through each square millimetre of copper. Dark is quiet copper, yellow is where the current crowds.${n0 ? ` The busiest spot is on ${n0.net}, at about ${Math.round(n0.peakJ)} amps per square millimetre, on the ${n0.peakLayer === 'F.Cu' ? 'top' : n0.peakLayer === 'B.Cu' ? 'bottom' : 'inner'} copper.` : ''} Above about sixty amps per square millimetre, a narrow neck starts to heat up, and the issues list calls it out. Click a net on the left to see it alone.`;
    if (q === 'heat') return `This is heat flow in the copper: where the watts travel away from the hot parts. The brighter the copper, the more heat is moving through it.${c0 ? ` ${c0.reference} puts out ${c0.watts} watts: about ${Math.round(c0.planeW / c0.watts * 100)} percent spreads along its copper, and ${Math.round(c0.viaW / c0.watts * 100)} percent goes down its ${c0.vias} vias to the other side.` : ''} More copper and more vias under a hot part give its heat more ways out.`;
    if (q === 'temperature') return `This is the temperature rise over the whole board, in ${state.view.air === 'moving' ? 'moving' : 'still'} air: how many degrees above the room each spot sits.${c0 ? ` The hottest part is ${c0.reference}, at ${Math.round(state.view.air === 'moving' ? c0.riseMovingC : c0.riseC)} degrees above ambient, against a budget of ${f.heat.budgetC}.` : ''} Switch between still and moving air on the left to see what a fan does.`;
    if (q === 'signals' && window.fieldsSignals) return window.fieldsSignals.narration();
    if (q === 'signals') return `This is the signals view: the capacitance where copper overlaps between layers, and the checks on differential pairs, their skew and their impedance. These are screening estimates; the panel on the right lists each result and what it assumed.`;
    return '';
  }
  // subtitles: one sentence at a time, stepped by how far the voice is through the clip, sitting just above
  // the legend and readout row so neither is covered (John, 2026-10-10: a paragraph overlay hid the board,
  // one line over the legend hid the legend)
  function caption(text) {
    const c = $('#caption'); if (!c) return; c.textContent = text || ''; c.hidden = !text; if (!text) return;
    // the bottom edge, in the gap between the readout and the legend, so it covers neither them nor the
    // field; only a pane too narrow for that gap lifts it above that row
    const stage = c.parentElement.getBoundingClientRect(), leg = $('#legend'), ro = $('#readout');
    const left = ro && !ro.hidden ? ro.getBoundingClientRect().right - stage.left + 12 : 16;
    const right = leg && !leg.hidden ? stage.right - leg.getBoundingClientRect().left + 12 : 16;
    const room = stage.width - left - right;
    if (room >= 280) { c.style.left = left + 'px'; c.style.right = right + 'px'; c.style.transform = 'none'; c.style.width = 'auto'; c.style.maxWidth = 'none'; c.style.bottom = '16px'; }
    else { const hh = Math.max(leg && !leg.hidden ? leg.offsetHeight : 0, ro && !ro.hidden ? ro.offsetHeight : 0); c.style.left = '50%'; c.style.right = 'auto'; c.style.transform = 'translateX(-50%)'; c.style.width = 'max-content'; c.style.maxWidth = 'min(640px, calc(100% - 32px))'; c.style.bottom = (16 + hh + 10) + 'px'; }
  }
  // a sentence ends at . ! or ? followed by a space: '1.6 million' stays whole
  const sentences = (t) => t.split(/(?<=[.!?])\s+/).map(x => x.trim()).filter(Boolean);
  function subtitles(a, text) {
    const ss = sentences(text), total = ss.reduce((n, x) => n + x.length, 0) || 1; let shown = -1;
    a.ontimeupdate = () => { if (!a.duration) return; const at = a.currentTime / a.duration * total; let acc = 0, k = 0; for (; k < ss.length - 1; k++) { acc += ss[k].length; if (acc > at) break; } if (k !== shown) { shown = k; caption(ss[k]); } };
    caption(ss[0]);
  }
  function stopNarration() { narr.seq = (narr.seq || 0) + 1; if (narr.audio) { narr.audio.pause(); narr.audio = null; } caption(''); const b = $('#btnExplain'); if (b) { b.classList.remove('is-on'); b.setAttribute('aria-pressed', false); } }
  async function explain(q, auto) {
    q = q || state.view.quantity; const text = narrationText(q); if (!text) return;
    stopNarration(); narr.busy = true; const b = $('#btnExplain'); if (b) { b.classList.add('is-on'); b.setAttribute('aria-pressed', true); }
    caption(sentences(text)[0]);
    // only the latest request plays: an automatic first-run explain still fetching its voice must not start a
    // second voice over this one (the two fought over the subtitles)
    const my = narr.seq = (narr.seq || 0) + 1;
    const r = await post('narrate', { view: q, text });
    if (my !== narr.seq) return;
    narr.busy = false;
    narr.heard[q] = true; try { localStorage.setItem('fields.narrated', JSON.stringify(narr.heard)); } catch (e) { void e; }
    if (!r || r.status !== 'ok') { caption(''); toast(((r && r.error) || 'No voice right now') + ' (the Key button has the written version)', 'info'); if (b) b.classList.remove('is-on'); return; }
    const a = new Audio(r.url); narr.audio = a; subtitles(a, text);
    a.onended = () => { if (narr.audio === a) stopNarration(); };
    try { await a.play(); console.log(`narration: ${q} (${auto ? 'first time' : 'asked'}), ${r.cache}`); }
    catch (e) { stopNarration(); if (auto) toast('Click the speaker to hear what this view shows', 'info'); else toast('The browser blocked the audio: click the speaker again', 'info'); }
  }
  function maybeExplain(q) {
    if (narr.mode === 'ask' || !narrationText(q)) return;
    if (narr.mode === 'first' && narr.heard[q]) return;
    explain(q, true);
  }
  window.fieldsNarrate = { explain, stop: stopNarration, text: narrationText, mode: () => narr.mode, caption, sentences };
  // the sidebar buttons light while their panel is on screen (a wide window shows both panels all the time)
  function syncSideBtns() { for (const [btn, side] of [['#btnNets', '#left'], ['#btnIssues', '#right']]) { const b = $(btn), p = $(side); if (!b || !p) continue; const on = getComputedStyle(p).display !== 'none'; b.classList.toggle('is-on', on); b.setAttribute('aria-pressed', on); } }
  window.addEventListener('resize', syncSideBtns); setTimeout(syncSideBtns, 0);
  function syncNarrSeg() { for (const b of document.querySelectorAll('#narrMode button')) { const on = b.dataset.n === narr.mode; b.classList.toggle('is-on', on); b.setAttribute('aria-pressed', on); } }
  function partsMode() { const m = state.view.parts || 'auto'; if (m !== 'auto') return m; return state.view.quantity === 'signals' ? 'off' : 'ghost'; }
  function applyParts() {
    const mode = partsMode();
    syncGhostCtl();
    const b = $('#btnParts'); if (b) { b.classList.toggle('is-on', mode !== 'off'); b.setAttribute('aria-pressed', mode !== 'off'); b.dataset.mode = mode; }
    if (!parts.ready) return;
    parts.root.setEnabled(mode !== 'off');
    for (const mat of parts.mats) {
      // ghost: the bodies stay readable while the field glows through them
      mat.alpha = mode === 'ghost' ? ghostAlpha() : (mat._partAlpha0 ?? 1);
      mat.transparencyMode = mode === 'ghost' ? BAB.Material.MATERIAL_ALPHABLEND : (mat._partAlpha0 < 1 ? BAB.Material.MATERIAL_ALPHABLEND : BAB.Material.MATERIAL_OPAQUE);
    }
    // the parts draw after the board and its field (group 1, the depth kept), so a see-through body
    // is never painted over by the field plane under it
    for (const m of parts.meshes) { m.renderingGroupId = 1; }
  }
  // views: the viewer's ViewCube does this by hand; the drivers do it by command. One tween, held.
  // every camera move is a slow eased tween on the page's own clock: a snap looks fine live and turns
  // frenetic when the flow plays the clip at ten times speed. The clock is the engine's, or the
  // capture's fixed step (window.fieldsCapture) for frame-exact video.
  const clock = { capture: false, dt: 1 / 30 };
  const frameDt = () => clock.capture ? clock.dt : Math.min(0.1, (V.getEngine().getDeltaTime() || 16) / 1000);
  let tweens = [];
  let tweening = 0;
  const ease = (k) => k < 0.5 ? 4 * k * k * k : 1 - Math.pow(-2 * k + 2, 3) / 2;
  function tween(prop, to, seconds) {
    const from = camera[prop] instanceof BAB.Vector3 ? camera[prop].clone() : camera[prop];
    tweens.push({ prop, from, to: to instanceof BAB.Vector3 ? to.clone() : to, t: 0, s: Math.max(0.05, seconds) });
    tweening = tweens.length;
  }
  function goto(alpha, beta, radius, target, seconds) {
    const s = (seconds || 9.0) * SLOW;   // slow on purpose: the flow plays this clip at ten times speed
    scene.stopAnimation(camera); tweens = []; tweening = 0; drift.base = null;
    if (target) tween('target', target, s);
    tween('alpha', alpha, s); tween('beta', beta, s); tween('radius', radius, s);
  }
  scene.onBeforeRenderObservable.add(() => {
    if (!tweens.length) return;
    const dt = frameDt();
    for (const tw of tweens) {
      tw.t = Math.min(tw.s, tw.t + dt); const k = ease(tw.t / tw.s);
      if (tw.to instanceof BAB.Vector3) camera[tw.prop] = BAB.Vector3.Lerp(tw.from, tw.to, k); else camera[tw.prop] = tw.from + (tw.to - tw.from) * k;
    }
    tweens = tweens.filter(tw => tw.t < tw.s); tweening = tweens.length;
    if (!tweening) drift.base = null;
  });
  // the drift: about six degrees of sway and a breath of tilt, over forty seconds (twenty before 0.5),
  // so a held view is never a frozen frame and never a spin
  const drift = { t: 0, base: null, last: null };
  const user = { until: 0 };   // set when the user grabs the view (below)
  scene.onBeforeRenderObservable.add(() => {
    if (!state.view.drift || tweening > 0 || !board || performance.now() < user.until) { drift.base = null; return; }
    const dt = frameDt();
    if (!drift.base || (drift.last !== null && Math.abs(camera.alpha - drift.last) > 1e-4)) { drift.base = { a: camera.alpha, b: camera.beta }; drift.t = 0; }
    drift.t += dt;
    const a = drift.base.a + 0.10 * Math.sin(drift.t / (3.2 * SLOW));
    const b = drift.base.b + 0.030 * Math.sin(drift.t / (4.7 * SLOW));
    camera.alpha = a; camera.beta = Math.max(0.02, Math.min(Math.PI - 0.02, b));
    drift.last = camera.alpha;
  });
  // presets for the Z-up frame, measured against KiCad's own top view: alpha +90 degrees puts the
  // board's north at the top of the screen
  const NORTH = Math.PI / 2;
  function fitCamera(which) {
    if (!board) return;
    // fit the board to the pane's own shape: a tall narrow pane is limited by its width, a wide one by its height
    const vf = camera.fov || 0.8, aspect = scene.getEngine().getAspectRatio(camera) || 1;
    const hf = 2 * Math.atan(Math.tan(vf / 2) * aspect);
    // an exploded stack is taller than the board is wide: back off so the whole stack stays in frame
    const R = Math.max((board.heightMm / 2) / Math.tan(vf / 2), (board.widthMm / 2) / Math.tan(hf / 2)) * 1.06 * (1 + 0.55 * Math.max(0, Math.min(1, +state.view.explode || 0)));
    const home = BAB.Vector3.Zero();
    // everything in the scene, not just the board: the solved bodies (a heatsink stands 40 mm tall), the parts, an
    // exploded stack. The iso view and Fit frame its bounding sphere, so the drift's sway never turns any of it out of frame
    const lo = new BAB.Vector3(Infinity, Infinity, Infinity), hi = new BAB.Vector3(-Infinity, -Infinity, -Infinity);
    if (root) for (const m of root.getChildMeshes(false)) {
      if (!m.isEnabled() || !m.isVisible || !m.getTotalVertices || m.getTotalVertices() === 0) continue;
      m.computeWorldMatrix(true); const bb = m.getBoundingInfo().boundingBox;
      lo.minimizeInPlace(bb.minimumWorld); hi.maximizeInPlace(bb.maximumWorld);
    }
    const sphere = isFinite(lo.x) ? { c: lo.add(hi).scale(0.5), r: hi.subtract(lo).length() / 2 } : null;
    const Rs = sphere ? sphere.r / Math.sin(Math.min(vf, hf) / 2) * 1.04 : 0;
    const flipping = (which === 'bottom' && camera.beta < Math.PI / 2) || (which === 'top' && camera.beta > Math.PI / 2);
    const s = flipping ? 2.6 : 9.0;   // the turn-over is quick, everything else is slow
    if (which === 'top') goto(NORTH, 0.02, R, home, s);
    else if (which === 'bottom') goto(NORTH, Math.PI - 0.02, R, home, s);
    // low and from the front: the layers edge-on, so what runs between them (the Energy view's fields) is in sight
    // low and to the side: what sits between the layers or just above the copper reads, the board still in view
    else if (which === 'low') goto(NORTH - 0.6, 1.18, (sphere ? Rs : R * 1.08) * 0.95, sphere ? sphere.c : home, s);
    else if (which === 'front') goto(NORTH, 1.32, sphere ? Rs : R * 1.08, sphere ? sphere.c : home, s);
    else if (which === 'iso') { if (sphere && sphere.r > Math.hypot(board.widthMm, board.heightMm, board.thicknessMm) / 2 * 1.02) goto(NORTH - 0.55, 0.95, Rs, sphere.c, s); else goto(NORTH - 0.55, 0.95, R * 1.08, home, s); }
    else if (sphere) goto(camera.alpha, camera.beta, Rs, sphere.c, s);
    else { tweens = []; tweening = 0; V.goHome(); }
  }
  const energy = { tok: 0 };
  function energyTour() {
    const tok = ++energy.tok;
    fitCamera('front');
    // the turn takes 9 s x SLOW; hold the front view about as long again, then return
    setTimeout(() => { if (tok === energy.tok && state.view.quantity === 'energy') fitCamera(state.view.camera === 'zoom' ? 'iso' : (state.view.camera || 'iso')); }, 9000 * SLOW + 12000);
  }
  function zoomTo(x, y, fromBelow) {
    // gentle: about half the board in frame, from near where the camera already is, over four seconds
    const t = boardSpace(x, y, 0);
    const R = Math.max(board.widthMm, board.heightMm);
    const beta = fromBelow ? Math.max(Math.PI - 0.75, Math.min(Math.PI - 0.35, camera.beta)) : Math.max(0.35, Math.min(0.75, camera.beta));
    goto(NORTH - 0.35, beta, R * 0.5, t, 5.5);
  }
  // textures
  function texName(L) {
    const q = state.view.quantity, ls = slug(L);
    if (q === 'signals') return `copper-${ls}.png`;
    if (q === 'river' || q === 'energy' || q === 'light' || q === 'cycle' || q === 'charge' || q === 'magnetic' || q === 'emc' || q === 'ground' || q === 'noise' || q === 'pdn') return `copper-${ls}.png`;
    if (q === 'current' || q === 'flow') return state.view.net ? `J-${slug(state.view.net)}-${ls}.png` : `J-${ls}.png`;
    const fk = fanKey(state.view.air);
    if (q === 'heat') { const iso = state.view.net && (state.fields.pourNets || []).some(p => p.net === state.view.net); return iso ? `q-${slug(state.view.net)}-${ls}.png` : fk ? `q-${fk}-${ls}.png` : `q-${ls}.png`; }
    if (fk) return `T-${fk}-${ls}.png`;
    return (state.view.air === 'moving' ? 'Tm-' : 'T-') + ls + '.png';
  }
  function applyTextures() {
    for (const L of LAYERS) {
      const name = texName(L); const key = name;
      if (!state.tex[key]) { const t = new BAB.Texture('tex/' + name, scene, false, true, BAB.Texture.NEAREST_SAMPLINGMODE); t.hasAlpha = true;
        // the grid can reach past the outline by up to a cell: show the part on the board, cells where they are (row 0 is the board's y0, the top of the image)
        t.uScale = board.widthMm / (board.cols * board.cellMm); t.vScale = board.heightMm / (board.rows * board.cellMm); t.vOffset = 1 - t.vScale;
        state.tex[key] = t; }
      const m = state.layerMeshes[L].material; m.diffuseTexture = state.tex[key];
      const selected = state.view.layer || 'F.Cu';
      m.alpha = (L === selected) ? 1.0 : (state.view.quantity === 'temperature' ? 0.0 : 0.35);
      if (L !== 'F.Cu' && L !== 'B.Cu') m.alpha = (L === selected) ? 1.0 : 0.0;
    }
    if (viaRoot) viaRoot.setEnabled(!['temperature', 'river', 'signals'].includes(state.view.quantity));
    for (const { el, c } of labels) { el.hidden = ['current','signals','flow','river','energy','light','cycle','charge','magnetic','emc','ground','noise','pdn'].includes(state.view.quantity); el.textContent = c.reference + '  +' + riseOf(c).toFixed(0) + ' °C'; }
    // each view's tags and panel show only while it is the view, even if its animation loop is paused (a background tab)
    document.body.dataset.view = state.view.quantity;
    $('#signalsPanel').hidden = state.view.quantity !== 'signals';
    $('#legend').hidden = state.view.quantity === 'signals';
    $('#readout').hidden = state.view.quantity === 'signals';
    const sc = state.fields.scales, leg = $('#legend'); leg.classList.toggle('magma', state.view.quantity === 'temperature');
    if (state.view.quantity === 'current' || state.view.quantity === 'flow') { $('#legendTitle').textContent = 'Current density, A/mm² (log)'; $('#legLo').textContent = (sc.jHi / 300).toFixed(1); $('#legHi').textContent = sc.jHi.toFixed(0); }
    else if (state.view.quantity === 'heat') { $('#legendTitle').textContent = 'Heat flow in the copper, W/mm (log' + (fanKey(state.view.air) ? ', ' + airLabel() : '') + ')'; $('#legLo').textContent = (sc.qHi / 300).toExponential(1); $('#legHi').textContent = sc.qHi.toFixed(3); }
    else { $('#legendTitle').textContent = 'Rise above ambient, °C (' + airLabel() + ')'; $('#legLo').textContent = '0'; $('#legHi').textContent = sc.tHi.toFixed(0); }
    $('#airSec').style.display = state.view.quantity === 'temperature' || (state.view.quantity === 'heat' && state.fields.airStates) ? '' : 'none';
    applyParts();
    loadRaw();
    for (const b of document.querySelectorAll('#btnExplode')) { const on = (+state.view.explode || 0) > 0.3; b.classList.toggle('is-on', on); b.setAttribute('aria-pressed', on); }
    if (window.fieldsSolids) window.fieldsSolids.apply(state.view);
    if (window.fieldsFlow) window.fieldsFlow.apply(state.view);
    if (window.fieldsNoise) {
      window.fieldsNoise.apply(state.view);
      if (state.view.quantity === 'noise') { const bd = $('#flowBadge'), kb = $('#btnKey'); let on = false; try { on = localStorage.getItem('fields.keyOn') === '1'; } catch (e) { void e; } const [h, ...rows] = window.fieldsNoise.describe(); if (bd) { bd.innerHTML = `<div class="kh">${h}</div><ul>${rows.map(r => `<li>${r}</li>`).join('')}</ul>`; bd.hidden = !on; } if (kb) { kb.hidden = false; kb.classList.toggle('is-on', on); } }
    }
    if (window.fieldsSignals) {
      window.fieldsSignals.apply(state.view);
      if (state.view.quantity === 'signals') { const bd = $('#flowBadge'), kb = $('#btnKey'); let on = false; try { on = localStorage.getItem('fields.keyOn') === '1'; } catch (e) { void e; } const [h, ...rows] = window.fieldsSignals.describe(); if (bd) { bd.innerHTML = `<div class="kh">${h}</div><ul>${rows.map(r => `<li>${r}</li>`).join('')}</ul>`; bd.hidden = !on; } if (kb) { kb.hidden = false; kb.classList.toggle('is-on', on); } }
    }
    if (window.fieldsPdn) {
      window.fieldsPdn.apply(state.view);
      if (state.view.quantity === 'pdn') { const bd = $('#flowBadge'), kb = $('#btnKey'); let on = false; try { on = localStorage.getItem('fields.keyOn') === '1'; } catch (e) { void e; } const [h, ...rows] = window.fieldsPdn.describe(); if (bd) { bd.innerHTML = `<div class="kh">${h}</div><ul>${rows.map(r => `<li>${r}</li>`).join('')}</ul>`; bd.hidden = !on; } if (kb) { kb.hidden = false; kb.classList.toggle('is-on', on); } }
    }
    if (window.fieldsGround) {
      window.fieldsGround.apply(state.view);
      if (state.view.quantity === 'ground') { const bd = $('#flowBadge'), kb = $('#btnKey'); let on = false; try { on = localStorage.getItem('fields.keyOn') === '1'; } catch (e) { void e; } const [h, ...rows] = window.fieldsGround.describe(); if (bd) { bd.innerHTML = `<div class="kh">${h}</div><ul>${rows.map(r => `<li>${r}</li>`).join('')}</ul>`; bd.hidden = !on; } if (kb) { kb.hidden = false; kb.classList.toggle('is-on', on); } }
    }
    if (window.fieldsEmc) {
      window.fieldsEmc.apply(state.view);
      if (state.view.quantity === 'emc') { const bd = $('#flowBadge'), kb = $('#btnKey'); let on = false; try { on = localStorage.getItem('fields.keyOn') === '1'; } catch (e) { void e; } const [h, ...rows] = window.fieldsEmc.describe(); if (bd) { bd.innerHTML = `<div class="kh">${h}</div><ul>${rows.map(r => `<li>${r}</li>`).join('')}</ul>`; bd.hidden = !on; } if (kb) { kb.hidden = false; kb.classList.toggle('is-on', on); } }
    }
    if (window.fieldsMagnetic) {
      window.fieldsMagnetic.apply(state.view);
      if (state.view.quantity === 'magnetic') { const bd = $('#flowBadge'), kb = $('#btnKey'); let on = false; try { on = localStorage.getItem('fields.keyOn') === '1'; } catch (e) { void e; } const [h, ...rows] = window.fieldsMagnetic.describe(); if (bd) { bd.innerHTML = `<div class="kh">${h}</div><ul>${rows.map(r => `<li>${r}</li>`).join('')}</ul>`; bd.hidden = !on; } if (kb) { kb.hidden = false; kb.classList.toggle('is-on', on); } }
    }
    if (window.fieldsCycle) {
      window.fieldsCycle.apply(state.view);
      if (state.view.quantity === 'cycle') { const bd = $('#flowBadge'), kb = $('#btnKey'); let on = false; try { on = localStorage.getItem('fields.keyOn') === '1'; } catch (e) { void e; } const [h, ...rows] = window.fieldsCycle.describe(); if (bd) { bd.innerHTML = `<div class="kh">${h}</div><ul>${rows.map(r => `<li>${r}</li>`).join('')}</ul>`; bd.hidden = !on; } if (kb) { kb.hidden = false; kb.classList.toggle('is-on', on); } }
    }
    else { const bd = $('#flowBadge'); if (bd) { bd.hidden = !['flow', 'river', 'energy', 'light'].includes(state.view.quantity); bd.textContent = flowNote; } }
  }
  let flowNote = 'Loading the flow data';
  function rawName(L) { const q = state.view.quantity, ls = slug(L), fk = fanKey(state.view.air); if (fk && (q === 'heat' || q === 'temperature') && !(q === 'heat' && state.view.net)) return (q === 'heat' ? 'q-' : 'T-') + fk + '-' + ls + '.raw.png'; return (q === 'current' || q === 'flow' || q === 'river' ? 'J-' : q === 'heat' ? 'q-' : (state.view.air === 'moving' ? 'Tm-' : 'T-')) + ls + '.raw.png'; }
  function loadRaw() {
    for (const L of ['F.Cu', 'B.Cu']) {
      const name = rawName(L); if (state.raw[name]) continue; state.raw[name] = { pending: true };
      const img = new Image(); img.crossOrigin = 'anonymous'; img.src = 'tex/' + name;
      img.onload = () => { const c = document.createElement('canvas'); c.width = img.width; c.height = img.height; const ctx = c.getContext('2d'); ctx.drawImage(img, 0, 0); const d = ctx.getImageData(0, 0, c.width, c.height).data; state.raw[name] = { w: c.width, h: c.height, d }; };
    }
  }
  // the viewer is the user's the moment they grab it: a press or a wheel in the 3D view stops any camera move in flight
  // (a tween, the Energy view's front turn and its return) and holds the drift until they have let go for 20 s
  scene.onPointerObservable.add((pi) => {
    if (pi.type !== BAB.PointerEventTypes.POINTERDOWN && pi.type !== BAB.PointerEventTypes.POINTERWHEEL) return;
    user.until = performance.now() + 20000; tweens = []; tweening = 0; energy.tok++; drift.base = null;
  });
  scene.onPointerObservable.add((pi) => {
    if (pi.type !== BAB.PointerEventTypes.POINTERMOVE || !board) return;
    const pick = scene.pick(scene.pointerX, scene.pointerY, (m) => state.view.quantity === 'river' ? m.name.startsWith('R-') : m.name.startsWith('L-'));
    const ro = $('#readout');
    if (!pick || !pick.hit) { ro.innerHTML = '<span class="k">hover the board</span>'; return; }
    const L = pick.pickedMesh.name.slice(2); const p = pick.pickedPoint;
    const x = p.x + board.x0 + board.widthMm / 2, y = board.y0 + board.heightMm / 2 - p.y;
    const raw = state.raw[rawName(L)]; let val = '';
    if (window.fieldsMagnetic && state.view.quantity === 'magnetic') val = window.fieldsMagnetic.readout(L, x, y);
    else if (window.fieldsGround && state.view.quantity === 'ground') val = window.fieldsGround.readout(L, x, y);
    else if (window.fieldsFlow && ['flow', 'river', 'energy', 'light', 'charge', 'cycle'].includes(state.view.quantity)) val = window.fieldsFlow.readout(L, x, y);
    else if (raw && raw.d) { const c = Math.min(raw.w - 1, Math.max(0, Math.floor((x - board.x0) / board.cellMm))), r = Math.min(raw.h - 1, Math.max(0, Math.floor((y - board.y0) / board.cellMm))); const i = (r * raw.w + c) * 4; const v = raw.d[i] / 255; const sc = state.fields.scales; const hi = state.view.quantity === 'current' ? sc.jHi : state.view.quantity === 'heat' ? sc.qHi : sc.tHi; const num = v * hi; val = state.view.quantity === 'current' ? num.toFixed(1) + ' A/mm²' : state.view.quantity === 'heat' ? num.toFixed(4) + ' W/mm' : '+' + num.toFixed(1) + ' °C'; }
    ro.innerHTML = `<span class="k">${L}</span> ${val}<br><span class="k">x</span> ${x.toFixed(2)} <span class="k">y</span> ${y.toFixed(2)} mm`;
  });

  // ---- panels ----------------------------------------------------------------------------
  function renderPanels() {
    const f = state.fields, v = state.view;
    for (const b of document.querySelectorAll('#qty button, #qty2 button')) { b.classList.toggle('is-on', b.dataset.q === v.quantity); b.setAttribute('aria-pressed', b.dataset.q === v.quantity); }
    for (const b of document.querySelectorAll('#layer button')) { const on = b.dataset.l === (v.layer || 'F.Cu'); b.classList.toggle('is-on', on); b.setAttribute('aria-pressed', on); }
    for (const b of document.querySelectorAll('#air button')) { const on = b.dataset.a === v.air || (fanKey(v.air) && b.dataset.a === fanKey(v.air)); b.classList.toggle('is-on', !!on); b.setAttribute('aria-pressed', !!on); }
    for (const b of document.querySelectorAll('.views .chip[data-cam]')) b.classList.toggle('is-on', b.dataset.cam === v.camera);
    const jmax = Math.max(1, ...f.nets.map(n => n.peakJ));
    $('#nets').innerHTML = f.nets.map(n => `<div class="row ${v.net === n.net ? 'is-on' : ''}" data-net="${n.net}" data-tip="${n.amps} A on ${n.layers.join(', ')}; ${n.copperMm2} mm² of copper, ${n.vias} vias, ${n.jouleW} W of Joule loss. Click to light this net alone."><div style="flex:1;min-width:0"><div class="name">${n.net}</div><div class="bar" style="width:${(n.peakJ / jmax * 100).toFixed(0)}%"></div></div><div class="num">${n.amps} A · ${n.peakJ.toFixed(0)} A/mm²</div></div>`).join('') || '<div class="empty">no loaded nets in the spec</div>';
    const chipRise = c => riseOf(c);
    $('#chips').innerHTML = [...f.chips].sort((a,b) => chipRise(b)-chipRise(a)).map(c => `<div class="row ${v.chip === c.reference ? 'is-on' : ''}" data-chip="${c.reference}" data-tip="${c.watts} W on ${c.tabNet}: ${(c.planeW / c.watts * 100).toFixed(0)}% spreads along F.Cu, ${(c.viaW / c.watts * 100).toFixed(0)}% goes down its ${c.vias} own via(s) on ${c.tabNet} (spec asks ${c.viasWanted}), ${(c.otherViaW / c.watts * 100).toFixed(0)}% through ${c.otherVias} other nets' barrel(s) nearby, ${(c.dielW / c.watts * 100).toFixed(0)}% through the bare dielectric. +${c.riseMovingC} °C in moving air."><div class="name">${c.reference} <span class="num">${c.watts} W</span></div><div class="num" style="color:${c.riseC > f.heat.budgetC ? 'var(--red)' : 'var(--text)'}">+${c.riseC.toFixed(0)} °C</div></div>`).join('') || '<div class="empty">no hot parts in the spec</div>';
    $('#issues').innerHTML = f.issues.map((i, k) => `<div class="issue ${v.issue === k ? 'is-on' : ''}" data-issue="${k}"><span class="sev ${i.severity}">${i.severity}</span> <span class="num" style="font-family:JetBrains Mono;font-size:11px;color:var(--text-2)">${i.kind}</span><div class="what">${i.what}</div><div class="fix">${i.fix}</div></div>`).join('') || '<div class="empty">No current or thermal threshold findings in this solve. Signal checks are reported separately.</div>';
    const signals = f.signals || {}; const signalPanel=$('#signals'); signalPanel.replaceChildren();
    const addSignal=(text)=>{const row=document.createElement('div');row.className='empty';row.textContent=text;signalPanel.appendChild(row);};
    for (const c of signals.capacitance || []) addSignal(`${c.name}: ${c.capacitancePf.toFixed(2)} pF, ${c.overlapMm2.toFixed(2)} mm² overlap (${c.status}).`);
    for (const p of signals.differentialPairs || []) addSignal(`${p.name}: lengths ${p.lengthMm.map(x=>x.toFixed(3)).join(' / ')} mm; skew ${p.skewMm.toFixed(3)} mm. Differential ${p.differentialOhms ? p.differentialOhms.map(x=>x.toFixed(1)).join(' to ')+' Ω' : 'not estimated'}. ${p.status}. ${p.warnings.join('; ')}`);
    if (!(signals.capacitance || []).length && !(signals.differentialPairs || []).length) addSignal('No signal checks specified. Add signals.capacitance or signals.differentialPairs with measured stackup and dielectric data, then analyze again.');
    addSignal(signals.method || 'No signal results in this solve.');
    const hsPanel = $('#heatsinks'); hsPanel.replaceChildren();
    // packages cooled off the board (PR 11): junction, case and sink rises in the chosen air
    for (const p of f.packageCooling || []) {
      const result = p[v.air === 'moving' ? 'moving' : 'still'];
      const row = document.createElement('div'); row.className = 'empty';
      row.textContent = `${p.definition.reference} supplied package model (${v.air}): junction +${result.junctionRiseC.toFixed(1)} °C, case +${result.caseRiseC.toFixed(1)} °C, sink +${result.sinkRiseC.toFixed(1)} °C. ${result.sinkToAmbientW.toFixed(3)} W to ambient; ${result.boardCouplingW.toFixed(3)} W to the board (negative: the board cools through the package). Steady-state screen; fan-fault transients not qualified.`;
      row.title = p.definition.provenance; hsPanel.appendChild(row);
    }
    for (const hs of f.heatsinks || []) {
      const row=document.createElement('div'); row.className='empty';
      row.textContent=`${hs.definition.name} (${hs.definition.layer}): ${hs.contactW.toFixed(3)} W into block, peak +${hs.peakRiseC.toFixed(1)} °C still / +${hs.peakRiseMovingC.toFixed(1)} °C moving. Contact ${hs.contactAreaMm2.toFixed(1)} mm².`;
      hsPanel.appendChild(row);
    }
    if (!(f.heatsinks || []).length && !(f.packageCooling || []).length) hsPanel.textContent='No heatsinks specified';
    $('#method').textContent = f.method + ` Solved in ${f.seconds} s on a ${f.board.cellMm} mm grid (${f.board.cols} x ${f.board.rows} cells x ${(f.board.copperLayers || f.board.layers).length} copper layers).`;
  }
  async function setView(patch) { const r = await post('ui/view', patch); if (!r) toast('The server did not answer', 'error', true); else if (r.status === 'error') toast(r.error + '. ' + (r.hint || ''), 'info', true); }
  document.addEventListener('click', e => {
    const q = e.target.closest('#qty button, #qty2 button'); if (q) return setView(q.dataset.q === 'energy' || q.dataset.q === 'charge' || q.dataset.q === 'signals' ? { quantity: q.dataset.q, issue: null, explode: Math.max(+state.view.explode || 0, 0.6) } : { quantity: q.dataset.q, issue: null });
    const l = e.target.closest('#layer button'); if (l) return setView({ layer: l.dataset.l });
    const a = e.target.closest('#air button'); if (a) return setView({ air: a.dataset.a });
    const cam = e.target.closest('.views .chip[data-cam]'); if (cam) return setView({ camera: cam.dataset.cam });
    const n = e.target.closest('[data-net]'); if (n) return post('ui/select', { net: state.view.net === n.dataset.net ? '' : n.dataset.net });
    const c = e.target.closest('[data-chip]'); if (c) return post('ui/select', { chip: c.dataset.chip });
    const is = e.target.closest('[data-issue]'); if (is) return post('ui/issue', { index: +is.dataset.issue });
    if (e.target.closest('#btnNets')) { $('#left').classList.toggle('is-open'); $('#right').classList.remove('is-open'); syncSideBtns(); }
    if (e.target.closest('#btnIssues')) { $('#right').classList.toggle('is-open'); $('#left').classList.remove('is-open'); syncSideBtns(); }
    if (e.target.closest('#btnExplain')) { if (narr.audio || narr.busy) return stopNarration(); return explain(state.view.quantity, false); }
    const nm = e.target.closest('#narrMode button'); if (nm) { narr.mode = nm.dataset.n; try { localStorage.setItem('fields.narrate', narr.mode); } catch (err) { void err; } syncNarrSeg(); if (narr.mode !== 'ask') toast(narr.mode === 'first' ? 'Each view explains itself the first time you open it' : 'Every view explains itself when you open it'); return; }
    if (e.target.closest('#btnSilk')) return setView({ silk: state.view.silk === false });
    if (e.target.closest('#btnExplode')) return setView({ explode: (+state.view.explode || 0) > 0.3 ? 0 : 1 });
    if (e.target.closest('#btnParts')) { const order = ['ghost', 'solid', 'off']; const cur = partsMode(); return setView({ parts: order[(order.indexOf(cur) + 1) % order.length] }); }
    if (e.target.closest('#btnLight')) { state.light = !state.light; V.setBottomLight(state.light); const b = $('#btnLight'); b.classList.toggle('is-on', state.light); b.setAttribute('aria-pressed', state.light); toast(state.light ? 'Underside light on' : 'Underside light off'); }
  });
  function applyView(v, first) {
    // the same command twice (same seq) is a no-op: a poll or a reconnect never moves the camera
    if (!first && v.seq !== undefined && v.seq === state.view.seq) return;
    const prevFocus = JSON.stringify(state.view.focus);
    state.view = Object.assign({}, state.view, v);
    renderPanels(); applyTextures(); syncNarrSeg();
    // a new kind of view: stop the last explanation, and explain this one if the narration rule says so
    if (state.view.quantity !== narr.view) { const had = narr.view !== null; narr.view = state.view.quantity; stopNarration(); const q = narr.view; setTimeout(() => { if (state.view.quantity === q) maybeExplain(q); }, had ? 900 : 2500); }
    const changed = (v.changed || []);
    const moveCamera = first || changed.includes('camera') || changed.includes('focus') || (changed.includes('explode') && state.view.camera !== 'zoom');
    // the Energy view's fields run between the copper layers, so from above the top copper hides them: turn to the front,
    // nearly edge-on, hold there, then settle back on the chosen view (John, 2026-10-19). A camera command or another view
    // during the tour cancels the return.
    if ((first || changed.includes('quantity')) && state.view.quantity === 'energy' && state.view.camera !== 'zoom' && !changed.includes('camera')) { energyTour(); return; }
    energy.tok++;
    if (!moveCamera) return;
    if (state.view.camera === 'zoom' && state.view.focus) { zoomTo(state.view.focus[0], state.view.focus[1], (state.view.layer || 'F.Cu') === 'B.Cu'); }
    else if (state.view.camera) fitCamera(state.view.camera);
    if (changed.includes('chip') && state.view.chip) { const c = state.fields.chips.find(x => x.reference === state.view.chip); if (c) toast(`${c.reference}: ${c.watts} W, +${c.riseC} °C at its tab in still air, ${(c.viaW / c.watts * 100).toFixed(0)}% of its heat down ${c.vias} via(s)`); }
    void prevFocus;
  }
  // ---- the live channel ------------------------------------------------------------------
  function connect() {
    const es = new EventSource('events');
    es.addEventListener('view', e => applyView(JSON.parse(e.data)));
    es.addEventListener('toast', e => { const d = JSON.parse(e.data); toast(d.text, d.kind, d.sticky); });
    es.addEventListener('explain', e => { const d = JSON.parse(e.data); if (d.stop) stopNarration(); else explain(d.quantity || state.view.quantity, false); });
    es.addEventListener('state', e => { const d = JSON.parse(e.data); if (d.view) applyView(d.view, false); $('#liveDot').classList.remove('off'); $('#liveText').textContent = 'live channel'; });
    es.onerror = () => { $('#liveDot').classList.add('off'); $('#liveText').textContent = 'reconnecting'; };
  }
  async function boot() {
    const f = await fetch('fields.json').then(r => r.json());
    state.fields = f;
    $('#boardName').textContent = f.board.name || 'board';
    $('#boardMeta').textContent = `${f.board.widthMm.toFixed(0)} x ${f.board.heightMm.toFixed(0)} mm · ${f.board.copperUm} µm · ${f.nets.length} nets · ${f.chips.length} hot parts · ${f.issues.length} issues`;
    // the bar never wraps: the name and meta truncate or drop, so the full text lives in a tooltip (issue 19)
    { const full = `${f.board.name || 'board'} · ${$('#boardMeta').textContent}`; $('#boardName').title = full; $('#boardMeta').title = full; }
    $('#solveInfo').textContent = `peak ${Math.max(0, ...f.nets.map(n => n.peakJ)).toFixed(0)} A/mm² · +${f.heat.peakRiseC.toFixed(0)} °C still, +${f.heat.peakRiseMovingC.toFixed(0)} °C moving · budget ${f.heat.budgetC} °C`;
    $('#ver').textContent = '';
    buildBoard(f);
    // the flow and the river: charge streaming through the copper, every net lifted to its voltage
    import('./solids.js').then(m => m.attach({ BAB, scene, state, Z, root })).then(sd => { window.fieldsSolids = sd; applyTextures(); if (sd && state.view.camera && state.view.camera !== 'zoom') fitCamera(state.view.camera); }).catch(e => console.error('solids: ' + (e && e.stack || e)));
    import('./flow.js').then(m => m.attach({ BAB, scene, V, camera, state, Z, LAYERS, frameDt, root, layerMeshes: state.layerMeshes, clock }))
      .then(api => { window.fieldsFlow = api; if (!api) flowNote = 'This solve has no flow data: run adom-fields analyze again with 0.7 or later.'; applyTextures();
        // the energy cycle of a switching converter, on the flow view's shaders
        return import('./cycle.js').then(m => m.attach({ BAB, scene, V, camera, state, Z, LAYERS, frameDt, root, layerMeshes: state.layerMeshes })).then(c => { window.fieldsCycle = c; applyTextures(); }).catch(e => console.error('cycle: ' + (e && e.stack || e)))
          .then(() => import('./magnetic.js')).then(m => m.attach({ BAB, scene, V, camera, state, LAYERS, frameDt, root, layerMeshes: state.layerMeshes, flow: api })).then(mg => { window.fieldsMagnetic = mg; applyTextures(); }).catch(e => console.error('magnetic: ' + (e && e.stack || e)))
          .then(() => import('./emc.js')).then(m => m.attach({ BAB, scene, V, camera, state, LAYERS, frameDt, root, layerMeshes: state.layerMeshes, flow: api, cycle: window.fieldsCycle })).then(em => { window.fieldsEmc = em; applyTextures(); }).catch(e => console.error('emc: ' + (e && e.stack || e)))
          .then(() => import('./ground.js')).then(m => m.attach({ BAB, scene, V, camera, state, LAYERS, frameDt, root, layerMeshes: state.layerMeshes, flow: api })).then(gr => { window.fieldsGround = gr; applyTextures(); }).catch(e => console.error('ground: ' + (e && e.stack || e)))
          .then(() => import('./noise.js')).then(m => m.attach({ BAB, scene, V, camera, state, LAYERS, frameDt, root, layerMeshes: state.layerMeshes, flow: api })).then(nz => { window.fieldsNoise = nz; applyTextures(); }).catch(e => console.error('noise: ' + (e && e.stack || e)))
          .then(() => import('./pdn.js')).then(m => m.attach({ BAB, scene, V, camera, state, LAYERS, frameDt, root, layerMeshes: state.layerMeshes, flow: api })).then(pd => { window.fieldsPdn = pd; applyTextures(); }).catch(e => console.error('pdn: ' + (e && e.stack || e)))
          .then(() => import('./signals.js')).then(m => m.attach({ BAB, scene, V, camera, state, LAYERS, frameDt, root, layerMeshes: state.layerMeshes, flow: api })).then(sg => { window.fieldsSignals = sg; applyTextures(); }).catch(e => console.error('signals: ' + (e && e.stack || e))); })
      .catch(e => { flowNote = 'The flow view did not load: ' + (e && e.message || e); console.error('flow: ' + (e && e.stack || e)); applyTextures(); });
    const st = await fetch('state').then(r => r.json()).catch(() => null);
    applyView(st && st.view ? st.view : {}, true);
    connect();
    // a poll as the fallback for proxies that buffer SSE
    setInterval(async () => { const s = await fetch('state').then(r => r.json()).catch(() => null); if (s && s.view && s.view.seq !== state.view.seq) applyView(s.view, false); }, 1500);
  }
  // frame-exact capture for video: stop the live loop, then step(dt) renders one frame dt seconds on
  window.fieldsCapture = {
    begin(dt) {
      clock.capture = true; clock.dt = dt || 1 / 30; V.getEngine().stopRenderLoop();
      // the orbit pivot marker is for the hand on the mouse, not for the video
      try { const ax = V.getAxesState && V.getAxesState(); if (ax === true || (ax && ax.visible)) V.toggleAxes(); } catch (e) { void e; }
      for (const n of scene.meshes.concat(scene.transformNodes)) if (n.name === 'arcRotateIndicatorSphere' || n.name === 'arrow' || (n.parent && n.parent.name === 'arrow')) n.setEnabled(false);
      return true;
    },
    step(dt) { if (dt) clock.dt = dt; scene.render(); return { seq: state.view.seq, tweening, partsReady: parts.ready }; },
    state: () => ({ view: state.view, tweening, partsReady: parts.ready, parts: parts.meshes.length, flow: window.fieldsFlow ? window.fieldsFlow.stats() : null, texturesReady: Object.values(state.tex).every(t => t.isReady()) }),
  };
  boot();
})();