main
John Lauer adom-fields 0.8.5: solid bodies follow the parts' offset as the stack opens 4ef4cc2
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980
// Adom Fields: the flow and the river.
// Flow: the solved current as charge streaming through the copper, thousands of glowing streaks whose
// speed and heat follow the current density, dropping through the via barrels in proportion to the
// current each barrel carries, with the layer stack pulled apart so the inner copper shows.
// River: every net's copper lifted to its voltage (the rail where the net is fed plus the IR drop the
// solve found, exaggerated and labelled), the current running over it as water, a switching node as a
// tide between its rails, the loads as waterfalls down to ground and the sources as fountains back up.
// Everything moves on the page's own clock (ctx.frameDt), so a frame-exact capture is the same picture.
export async function attach(ctx) {
  const { BAB, scene, V, camera, state, Z, LAYERS, frameDt, root } = ctx;
  const meta = state.fields.flow;
  if (!meta) { console.warn('flow: this solve carries no flow data; run `adom-fields analyze` again with 0.7 or later'); return null; }
  const buf = await fetch('flow.bin').then(r => { if (!r.ok) throw new Error('flow.bin: HTTP ' + r.status); return r.arrayBuffer(); });
  const W = meta.cols, H = meta.rows, N = W * H, d = meta.cellMm;
  const all = new Float32Array(buf);
  if (all.length !== LAYERS.length * 4 * N) throw new Error(`flow.bin holds ${all.length} values, expected ${LAYERS.length * 4 * N}`);
  const board = state.fields.board;
  const cuMm = LAYERS.map(l => ((board.copperUmByLayer || {})[l] || board.copperUm) / 1000);
  const lay = LAYERS.map((name, k) => {
    const o = k * 4 * N;
    const L = { name, k, jx: all.subarray(o, o + N), jy: all.subarray(o + N, o + 2 * N), volt: all.subarray(o + 2 * N, o + 3 * N), net: all.subarray(o + 3 * N, o + 4 * N) };
    let cu = 0, cur = 0; for (let i = 0; i < N; i++) { if (L.net[i] >= 0) cu++; if (L.jx[i] !== 0 || L.jy[i] !== 0) cur++; }
    L.cuCells = cu; L.curCells = cur;
    return L;
  });
  const nets = meta.nets;
  const netIndex = Object.fromEntries(nets.map((n, i) => [n.name, i]));
  // world frame: x right, y up the board (KiCad's y runs down), z out of the top copper
  const bx = (x) => x - board.x0 - board.widthMm / 2, by = (y) => board.y0 + board.heightMm / 2 - y;
  const diag = Math.hypot(board.widthMm, board.heightMm), minDim = Math.min(board.widthMm, board.heightMm);
  // the density scale: 99.5th percentile of the solved |J| (a single hot cell must not wash out the rest)
  const mags = []; for (const L of lay) for (let i = 0; i < N; i += 3) { const m = Math.hypot(L.jx[i], L.jy[i]); if (m > 0) mags.push(m); }
  mags.sort((a, b) => a - b);
  const jHi = mags.length ? mags[Math.min(mags.length - 1, Math.floor(mags.length * 0.995))] : 1, jLo = jHi / 300;
  const logT = (m) => m <= jLo ? 0 : Math.min(1, Math.log(m / jLo) / Math.log(jHi / jLo));

  // ---- the view's knobs, eased so nothing snaps ----------------------------------------------------
  const knob = { explode: 0, explodeTo: 0, river: 0, riverTo: 0, flowOn: 0, flowOnTo: 0, energyOn: 0, energyTo: 0, lightOn: 0, lightTo: 0, chargeOn: 0, chargeTo: 0, electrons: false, speed: 1, time: 0, tide: 0 };
  const gap = Math.max(3, 0.16 * Math.max(board.widthMm, board.heightMm));
  // pulled apart, the real copper layers spread evenly; an empty slot sits between its neighbours
  const realCu = board.copperLayers || LAYERS;
  const spread = (() => { const ri = LAYERS.map(L => realCu.indexOf(L)); const n = Math.max(1, realCu.length - 1); const val = ri.map(i => i >= 0 ? 1.5 - 3 * i / n : null);
    for (let k = 0; k < val.length; k++) if (val[k] === null) { let a = k - 1; while (a >= 0 && val[a] === null) a--; let z = k + 1; while (z < val.length && val[z] === null) z++; const va = a >= 0 ? val[a] : 1.5, vz = z < val.length ? val[z] : -1.5; val[k] = va + (vz - va) * (k - a) / (z - a); } return val; })();
  const zOf = (k) => Z[LAYERS[k]] + knob.explode * gap * spread[k];
  // the river: the top rail rises a quarter of the board's short side; drops within a net are exaggerated
  // so a millivolt slope can be seen, and the factor is printed on the badge
  const topRail = Math.max(1e-6, meta.topRailV || meta.voltMax || 1);
  const kR = 0.25 * minDim / topRail;
  const maxDrop = Math.max(1e-6, ...nets.filter(n => n.loaded && !n.switching).map(n => n.dropV || 0));
  const kD = 0.05 * minDim / maxDrop;
  const exaggeration = kD / kR;
  const swNets = nets.map(n => n.switching || null);
  const railPart = (id) => { const n = nets[id]; if (!n) return 0; if (n.switching) return n.switching.lowV || 0; return n.railV ?? 0; };
  // the tide: a switching node slowed to 0.4 Hz, a softened square wave between its rails
  const TIDE_HZ = 0.4;
  const tideOf = (t) => 0.5 + 0.5 * Math.tanh(4 * Math.sin(2 * Math.PI * TIDE_HZ * t));
  function heightAt(k, i) {
    const id = lay[k].net[i]; if (id < 0) return 0;
    const n = nets[id], v = lay[k].volt[i];
    const rail = railPart(id), drop = Number.isFinite(v) ? (n.switching ? v : v - (n.railV ?? 0)) : 0;
    const sw = n.switching ? (n.switching.highV - (n.switching.lowV || 0)) * knob.tide : 0;
    return knob.river * ((rail + sw) * kR + drop * kD);
  }

  // ---- the vias: the current each barrel carries between adjacent layers ---------------------------
  // per cell: the current crossing each layer gap, positive downward (F.Cu toward B.Cu)
  const viaGaps = new Map();
  for (const n of nets) for (const v of (n.vias || [])) {
    const c = Math.floor((v.x - meta.x0) / d), r = Math.floor((v.y - meta.y0) / d); if (c < 0 || r < 0 || c >= W || r >= H) continue;
    const i = r * W + c, a = LAYERS.indexOf(v.from), b = LAYERS.indexOf(v.to); if (a < 0 || b < 0) continue;
    const g = viaGaps.get(i) || new Float32Array(LAYERS.length - 1);
    const lo = Math.min(a, b), hi = Math.max(a, b), sgn = a < b ? 1 : -1;
    for (let k = lo; k < hi; k++) g[k] += sgn * v.amps;
    viaGaps.set(i, g);
  }

  // ---- the energy flow (the Poynting vector S = E x H) in the board material between copper layers -------
  // Between two copper layers a gap h apart: E is vertical, (Va - Vb)/h; two current sheets K = J t give
  // H = 1/2 (Kb - Ka) x z between them; so S = E x H = 1/2 (Va - Vb)/h (Ka - Kb), in W/mm², along the
  // supply's current. Integrated over a supply over its return, that is V x I: the power delivered.
  // Only the field inside the board is drawn; part of the energy travels in the air outside it.
  const voltsAt = (k, i) => {
    const id = lay[k].net[i]; if (id < 0) return NaN; const n = nets[id], v = lay[k].volt[i];
    if (n.switching) { const sw = n.switching; const mean = sw.meanV ?? (sw.highV + (sw.lowV || 0)) / 2; return mean + (Number.isFinite(v) ? v : 0); }
    if (n.railV == null) return NaN; return Number.isFinite(v) ? v : n.railV;
  };
  // real copper layers only: an inner layer of a two-layer board holds nothing but via and pad rings
  let real = lay.filter(L => L.cuCells > 0.15 * N).map(L => L.k);
  if (real.length < 2) real = lay.filter(L => L.cuCells > 0).map(L => L.k);
  const gaps = [];
  for (let g = 0; g + 1 < real.length; g++) {
    const a = real[g], b = real[g + 1], h = Math.max(0.05, Z[LAYERS[a]] - Z[LAYERS[b]]);
    const Sx = new Float32Array(N), Sy = new Float32Array(N), info = new Int16Array(N * 2).fill(-1);
    const A = lay[a], B = lay[b];
    for (let i = 0; i < N; i++) {
      const va = voltsAt(a, i), vb = voltsAt(b, i); if (!Number.isFinite(va) || !Number.isFinite(vb)) continue;
      const f = 0.5 * (va - vb) / h;
      Sx[i] = f * (A.jx[i] * cuMm[a] - B.jx[i] * cuMm[b]); Sy[i] = f * (A.jy[i] * cuMm[a] - B.jy[i] * cuMm[b]);
      info[i * 2] = A.net[i]; info[i * 2 + 1] = B.net[i];
    }
    gaps.push({ a, b, h, Sx, Sy, info });
  }
  const smags = []; for (const G of gaps) for (let i = 0; i < N; i += 2) { const m = Math.hypot(G.Sx[i], G.Sy[i]); if (m > 0) smags.push(m); }
  smags.sort((x, y) => x - y);
  const sHi = smags.length ? smags[Math.min(smags.length - 1, Math.floor(smags.length * 0.995))] : 1, sLo = sHi / 300;
  const sT = (m) => m <= sLo ? 0 : Math.min(1, Math.log(m / sLo) / Math.log(sHi / sLo));
  // electrons drift at v = J / (n e): copper has n = 8.5e28 /m³, so 1 A/mm² is 0.0734 mm/s
  const DRIFT = 1e6 / (8.5e28 * 1.602e-19) * 1e3;

  // ---- seeded randomness, so a capture replays the same frames --------------------------------------
  let seed = 0x9e3779b9;
  const rnd = () => { seed |= 0; seed = seed + 0x6D2B79F5 | 0; let t = Math.imul(seed ^ seed >>> 15, 1 | seed); t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; return ((t ^ t >>> 14) >>> 0) / 4294967296; };

  // ---- where charge is born: weighted by the square root of |J|, so thin, quiet copper still shows -
  let cdf = null, cdfKeys = null, cdfNet = undefined;
  function buildSpawn(netName) {
    const only = netName ? netIndex[netName] : -1;
    const keys = [], w = [];
    for (const L of lay) {
      if (!L.curCells) continue;
      for (let i = 0; i < N; i++) {
        const m = Math.hypot(L.jx[i], L.jy[i]); if (m <= jLo) continue;
        if (only >= 0 && L.net[i] !== only) continue;
        keys.push(L.k * N + i); w.push(Math.pow(m, 0.8));
      }
    }
    cdfKeys = Int32Array.from(keys); cdf = new Float64Array(w.length); let s = 0; for (let i = 0; i < w.length; i++) { s += w[i]; cdf[i] = s; }
    cdfNet = netName || null;
  }
  function sampleKey() { if (!cdf || !cdf.length) return -1; const t = rnd() * cdf[cdf.length - 1]; let lo = 0, hi = cdf.length - 1; while (lo < hi) { const m = (lo + hi) >> 1; if (cdf[m] < t) lo = m + 1; else hi = m; } return cdfKeys[lo]; }

  // ---- the particle pool --------------------------------------------------------------------------
  const tier = (() => { const e = V.getEngine(); const r = e.getRenderer ? '' : ''; void r; const big = (e.getCaps().maxTextureSize || 4096) >= 16384; return big ? 'high' : 'mid'; })();
  const COUNTS = { low: 6000, mid: 16000, high: 32000 };
  // the count follows the copper that carries current (about four charges per square millimetre), so a
  // small board is not a white wall and a big one is not sparse; the tier caps it
  const curArea = lay.reduce((s, L) => s + L.curCells, 0) * d * d;
  let P = Math.max(2500, Math.min(COUNTS[tier], Math.round(curArea * 4)));
  const FALLS = 2400;
  const MAX = COUNTS.high + FALLS;
  const EMAX = 9000;            // the energy view's own particles, after the charge and the falls
  const TOTAL = MAX + EMAX + 7000;   // + the charge view's field particles
  const px = new Float32Array(MAX), py = new Float32Array(MAX), pz = new Float32Array(MAX), pk = new Int8Array(MAX);
  const age = new Float32Array(MAX), life = new Float32Array(MAX), mode = new Int8Array(MAX), pnet = new Int16Array(MAX);
  const dTo = new Int8Array(MAX), dT = new Float32Array(MAX), lastCell = new Int32Array(MAX).fill(-1);
  const vx = new Float32Array(MAX), vy = new Float32Array(MAX), vz = new Float32Array(MAX), heat = new Float32Array(MAX);
  // instance buffers: position + size, velocity + alpha, colour + intensity
  const IP = new Float32Array(TOTAL * 4), IV = new Float32Array(TOTAL * 4), IC = new Float32Array(TOTAL * 4);

  function sampleJ(L, x, y) {
    // bilinear over the four nearest cells, only cells of the particle's own net count
    const fx = (x - meta.x0) / d - 0.5, fy = (y - meta.y0) / d - 0.5;
    const c0 = Math.floor(fx), r0 = Math.floor(fy), tx = fx - c0, ty = fy - r0;
    let sx = 0, sy = 0, sw = 0;
    for (let dr = 0; dr < 2; dr++) for (let dc = 0; dc < 2; dc++) {
      const c = c0 + dc, r = r0 + dr; if (c < 0 || r < 0 || c >= W || r >= H) continue;
      const i = r * W + c; if (L.net[i] < 0) continue;
      const wgt = (dc ? tx : 1 - tx) * (dr ? ty : 1 - ty); sx += L.jx[i] * wgt; sy += L.jy[i] * wgt; sw += wgt;
    }
    return sw > 1e-6 ? [sx / sw, sy / sw] : [0, 0];
  }
  const cellOf = (x, y) => { const c = Math.floor((x - meta.x0) / d), r = Math.floor((y - meta.y0) / d); return (c < 0 || r < 0 || c >= W || r >= H) ? -1 : r * W + c; };

  function spawn(p) {
    const key = sampleKey(); if (key < 0) { life[p] = 0; return; }
    const k = Math.floor(key / N), i = key % N;
    px[p] = meta.x0 + ((i % W) + rnd()) * d; py[p] = meta.y0 + (Math.floor(i / W) + rnd()) * d; pk[p] = k;
    pnet[p] = lay[k].net[i]; age[p] = 0; life[p] = knob.electrons ? 6 + rnd() * 6 : 1.5 + rnd() * 3.5; mode[p] = 0; lastCell[p] = i;
  }
  // the falls and fountains: a load drops its net's level to ground, a source lifts it back up
  const terminals = [];
  for (const [id, n] of nets.entries()) {
    if (!n.loaded || n.ground) continue;
    for (const t of n.terminals || []) terminals.push({ id, x: t.x, y: t.y, amps: t.amps, up: t.role === 'source', ref: t.ref });
  }
  const termAmps = terminals.reduce((s, t) => s + t.amps, 0) || 1;
  const termCdf = []; { let s = 0; for (const t of terminals) { s += t.amps; termCdf.push(s); } }
  function spawnFall(p) {
    if (!terminals.length) { life[p] = 0; return; }
    const r = rnd() * termAmps; let j = termCdf.findIndex(c => c >= r); if (j < 0) j = terminals.length - 1;
    const t = terminals[j]; const rad = 0.22 * Math.sqrt(rnd());
    const a = rnd() * Math.PI * 2;
    px[p] = t.x + rad * Math.cos(a); py[p] = t.y + rad * Math.sin(a); pk[p] = 0; pnet[p] = t.id; mode[p] = t.up ? 3 : 2;
    age[p] = 0; life[p] = 1.2 + rnd() * 0.8; dT[p] = rnd() * 0.15;
  }
  function levelOf(id, x, y) {
    // the height of a net's own copper at this point (its rail, its drop, the tide)
    const i = cellOf(x, y); if (i >= 0 && lay[0].net[i] === id) return heightAt(0, i);
    const n = nets[id]; const sw = n && n.switching ? (n.switching.highV - (n.switching.lowV || 0)) * knob.tide : 0;
    return knob.river * (railPart(id) + sw) * kR;
  }

  function step(dt) {
    knob.time += dt * knob.speed;
    knob.tide = tideOf(knob.time);
    const base = diag * 0.11 * knob.speed;
    const sel = state.view.layer || 'F.Cu', selK = LAYERS.indexOf(sel);
    const exploded = knob.explode > 0.3;
    const showFlow = knob.flowOn > 0.01;
    for (let p = 0; p < P; p++) {
      if (!showFlow) break;
      if (age[p] >= life[p]) spawn(p);
      if (!(life[p] > 0)) continue;
      age[p] += dt;
      const k = pk[p], L = lay[k];
      if (mode[p] === 1) {
        // dropping through a barrel
        dT[p] += dt * 3.2 * knob.speed;
        const za = zOf(k), zb = zOf(dTo[p]);
        if (dT[p] >= 1) {
          const from = k; pk[p] = dTo[p];
          // keep falling if this barrel carries the current on to the next layer as well
          const g = viaGaps.get(lastCell[p]); const down = dTo[p] > from;
          const nk = pk[p] + (down ? 1 : -1);
          const inflow = g ? Math.abs(g[Math.min(from, pk[p])]) : 0;
          const onward = g && nk >= 0 && nk < LAYERS.length ? (down ? g[pk[p]] : -g[pk[p] - 1]) : 0;
          if (onward > 0 && rnd() < Math.min(1, onward / Math.max(inflow, 1e-9))) { dTo[p] = nk; dT[p] = 0; }
          else { mode[p] = 0; }
        }
        pz[p] = za + (zb - za) * Math.min(1, dT[p]);
        vx[p] = 0; vy[p] = 0; vz[p] = (zb - za) * 3.2 * knob.speed; heat[p] = Math.max(heat[p], 0.8);
        continue;
      }
      // in the copper: midpoint step along the current
      const [ax, ay] = sampleJ(L, px[p], py[p]);
      let m = Math.hypot(ax, ay);
      if (m <= jLo * 0.5) { age[p] = life[p]; continue; }
      const t = logT(m), sp = knob.electrons ? DRIFT * m * knob.speed : base * (0.12 + 0.88 * t);
      const hx = px[p] + ax / m * sp * dt * 0.5, hy = py[p] + ay / m * sp * dt * 0.5;
      const [bx2, by2] = sampleJ(L, hx, hy); const m2 = Math.hypot(bx2, by2) || m;
      const dir = knob.electrons ? -1 : 1;   // electrons move against the conventional current
      const ux = dir * (m2 > 0 ? bx2 / m2 : ax / m), uy = dir * (m2 > 0 ? by2 / m2 : ay / m);
      px[p] += ux * sp * dt; py[p] += uy * sp * dt;
      const i = cellOf(px[p], py[p]);
      if (i < 0 || L.net[i] !== pnet[p]) { age[p] = life[p]; continue; }
      vx[p] = ux * sp; vy[p] = uy * sp; vz[p] = 0; heat[p] = t;
      // a new cell with a barrel in it: the barrel takes its share of the current, down or up
      if (i !== lastCell[p] && !knob.electrons) {
        lastCell[p] = i;
        const g = viaGaps.get(i);
        if (g) {
          const down = k < LAYERS.length - 1 ? Math.max(0, g[k]) : 0, up = k > 0 ? Math.max(0, -g[k - 1]) : 0;
          const out = down + up;
          if (out > 0) {
            const plane = m * d * cuMm[k];
            if (rnd() < out / (out + plane)) {
              mode[p] = 1; dT[p] = 0; dTo[p] = (rnd() * out < down) ? k + 1 : k - 1;
              px[p] = meta.x0 + ((i % W) + 0.5) * d; py[p] = meta.y0 + (Math.floor(i / W) + 0.5) * d;
            }
          }
        }
      }
      pz[p] = zOf(pk[p]) + (pk[p] === 0 ? 1 : pk[p] === LAYERS.length - 1 ? -1 : 0) * 0.03 + heightAt(pk[p], i >= 0 ? i : 0);
      void selK; void exploded;
    }
    // falls and fountains (river only)
    for (let p = COUNTS.high; p < COUNTS.high + FALLS; p++) {
      if (knob.river < 0.02) { life[p] = 0; continue; }
      if (age[p] >= life[p] || !(life[p] > 0)) spawnFall(p);
      if (!(life[p] > 0)) continue;
      age[p] += dt * knob.speed;
      const top = levelOf(pnet[p], px[p], py[p]), z0 = zOf(0) + 0.03;
      const u = Math.min(1, Math.max(0, (age[p] - dT[p]) / (life[p] - dT[p])));
      // a fall accelerates, a fountain decelerates as it rises
      const f = mode[p] === 2 ? u * u : 1 - (1 - u) * (1 - u);
      const zTop = z0 + top, zBot = z0;
      pz[p] = mode[p] === 2 ? zTop + (zBot - zTop) * f : zBot + (zTop - zBot) * f;
      vx[p] = 0; vy[p] = 0; vz[p] = (mode[p] === 2 ? -1 : 1) * Math.max(0.5, (zTop - zBot)) * 2.2 * knob.speed; heat[p] = 0.6;
    }
  }

  // ---- energy particles: born where S is strong, carried along S, rising into the part that takes the
  // energy where the flow ends at a load
  const ex = new Float32Array(EMAX), ey = new Float32Array(EMAX), ez = new Float32Array(EMAX), eg = new Int8Array(EMAX);
  const eage = new Float32Array(EMAX), elife = new Float32Array(EMAX), emode = new Int8Array(EMAX), eh = new Float32Array(EMAX);
  const evx = new Float32Array(EMAX), evy = new Float32Array(EMAX), evz = new Float32Array(EMAX), ez0 = new Float32Array(EMAX);
  let ecdf = null, ekeys = null; const EP = Math.max(1500, Math.min(EMAX, Math.round(curArea * 3)));
  function buildEnergySpawn() {
    const keys = [], w = [];
    for (const [g, G] of gaps.entries()) for (let i = 0; i < N; i++) { const m = Math.hypot(G.Sx[i], G.Sy[i]); if (m > sLo) { keys.push(g * N + i); w.push(Math.pow(m, 0.75)); } }
    ekeys = Int32Array.from(keys); ecdf = new Float64Array(w.length); let acc = 0; for (let i = 0; i < w.length; i++) { acc += w[i]; ecdf[i] = acc; }
  }
  const sinkNear = (x, y) => terminals.find(t => !t.up && Math.hypot(t.x - x, t.y - y) < 1.4);
  const gapZ = (g) => (zOf(gaps[g].a) + zOf(gaps[g].b)) / 2;
  function sampleS(G, x, y) {
    const fx = (x - meta.x0) / d - 0.5, fy = (y - meta.y0) / d - 0.5, c0 = Math.floor(fx), r0 = Math.floor(fy), tx = fx - c0, ty = fy - r0;
    let sx = 0, sy = 0;
    for (let dr = 0; dr < 2; dr++) for (let dc = 0; dc < 2; dc++) { const c = c0 + dc, r = r0 + dr; if (c < 0 || r < 0 || c >= W || r >= H) continue; const i = r * W + c, wgt = (dc ? tx : 1 - tx) * (dr ? ty : 1 - ty); sx += G.Sx[i] * wgt; sy += G.Sy[i] * wgt; }
    return [sx, sy];
  }
  function spawnEnergy(p) {
    if (!ecdf || !ecdf.length) { elife[p] = 0; return; }
    const r = rnd() * ecdf[ecdf.length - 1]; let lo = 0, hi = ecdf.length - 1; while (lo < hi) { const m = (lo + hi) >> 1; if (ecdf[m] < r) lo = m + 1; else hi = m; }
    const key = ekeys[lo], g = Math.floor(key / N), i = key % N;
    ex[p] = meta.x0 + ((i % W) + rnd()) * d; ey[p] = meta.y0 + (Math.floor(i / W) + rnd()) * d; eg[p] = g;
    eage[p] = 0; elife[p] = 2 + rnd() * 3; emode[p] = 0; ez[p] = gapZ(g);
  }
  function stepEnergy(dt) {
    const base = diag * 0.16 * knob.speed;
    for (let p = 0; p < EP; p++) {
      if (eage[p] >= elife[p]) spawnEnergy(p);
      if (!(elife[p] > 0)) continue;
      eage[p] += dt;
      if (emode[p] === 1) {
        // into the part: the flow ends here, so the energy leaves the board material up into the load
        const u = Math.min(1, (eage[p] - ez0[p]) / 0.7), top = zOf(0) + 1.4;
        ez[p] = gapZ(eg[p]) + (top - gapZ(eg[p])) * u * u; evx[p] = 0; evy[p] = 0; evz[p] = 4; eh[p] = 0.8;
        if (u >= 1) eage[p] = elife[p];
        continue;
      }
      const G = gaps[eg[p]]; const [sx, sy] = sampleS(G, ex[p], ey[p]); const m = Math.hypot(sx, sy);
      if (m <= sLo * 0.6) { if (sinkNear(ex[p], ey[p])) { emode[p] = 1; ez0[p] = eage[p]; elife[p] = eage[p] + 0.75; } else eage[p] = elife[p]; continue; }
      const t = sT(m), sp = base * (0.2 + 0.8 * t);
      ex[p] += sx / m * sp * dt; ey[p] += sy / m * sp * dt; ez[p] = gapZ(eg[p]);
      evx[p] = sx / m * sp; evy[p] = sy / m * sp; evz[p] = 0; eh[p] = t;
      if (ex[p] < meta.x0 || ey[p] < meta.y0 || ex[p] > meta.x0 + W * d || ey[p] > meta.y0 + H * d) eage[p] = elife[p];
    }
  }

  // ---- the streak renderer: one instanced quad, stretched along each particle's velocity --------
  BAB.Effect.ShadersStore.fieldsStreakVertexShader = `precision highp float;
attribute vec3 position; attribute vec4 ip; attribute vec4 iv; attribute vec4 ic;
uniform mat4 world; uniform mat4 viewProjection; uniform vec3 camPos; uniform float trail; uniform float width;
varying vec2 vQ; varying vec4 vC;
void main(void) {
  vec3 pw = (world * vec4(ip.xyz, 1.0)).xyz;
  vec3 vel = mat3(world) * iv.xyz; float sp = length(vel);
  vec3 dir = sp > 1e-6 ? vel / sp : vec3(1.0, 0.0, 0.0);
  vec3 toCam = normalize(camPos - pw);
  vec3 side = cross(dir, toCam); float sl = length(side); side = sl > 1e-4 ? side / sl : vec3(0.0, 1.0, 0.0);
  float along = position.x * 0.5 + 0.5;
  float len = ip.w + sp * trail;
  vec3 p = pw - dir * len * along + side * ip.w * width * position.y;
  vQ = vec2(along, position.y); vC = vec4(ic.rgb, ic.a * iv.w);
  gl_Position = viewProjection * vec4(p, 1.0);
}`;
  BAB.Effect.ShadersStore.fieldsStreakFragmentShader = `precision highp float;
varying vec2 vQ; varying vec4 vC;
void main(void) {
  float across = max(0.0, 1.0 - vQ.y * vQ.y);
  float tail = pow(1.0 - vQ.x, 1.7);
  float head = 1.0 + 1.6 * exp(-vQ.x * 18.0);
  float a = vC.a * across * tail * head;
  if (a < 0.004) discard;
  gl_FragColor = vec4(vC.rgb, min(1.0, a));
}`;
  const streak = new BAB.Mesh('fields-flow-streaks', scene);
  { const vd = new BAB.VertexData(); vd.positions = [-1, -1, 0, 1, -1, 0, 1, 1, 0, -1, 1, 0]; vd.indices = [0, 1, 2, 0, 2, 3]; vd.applyToMesh(streak, false); }
  const engine = V.getEngine();
  const vbP = new BAB.VertexBuffer(engine, IP, 'ip', true, false, 4, true);
  const vbV = new BAB.VertexBuffer(engine, IV, 'iv', true, false, 4, true);
  const vbC = new BAB.VertexBuffer(engine, IC, 'ic', true, false, 4, true);
  streak.setVerticesBuffer(vbP); streak.setVerticesBuffer(vbV); streak.setVerticesBuffer(vbC);
  streak.forcedInstanceCount = TOTAL;
  streak.alwaysSelectAsActiveMesh = true; streak.isPickable = false; streak.parent = root;
  const smat = new BAB.ShaderMaterial('fields-flow-streak', scene, { vertex: 'fieldsStreak', fragment: 'fieldsStreak' }, { attributes: ['position', 'ip', 'iv', 'ic'], uniforms: ['world', 'viewProjection', 'camPos', 'trail', 'width'], needAlphaBlending: true });
  smat.alphaMode = BAB.Constants.ALPHA_ADD; smat.disableDepthWrite = true; smat.backFaceCulling = false;
  smat.setFloat('trail', 0.22); smat.setFloat('width', 1.0);
  streak.material = smat; streak.renderingGroupId = 1;
  scene.setRenderingAutoClearDepthStencil(1, false, false, false);

  // the electric palette: deep teal where the current idles, cyan, then white-hot in a neck
  const ramp = [[0.0, 0.28, 0.42], [0.0, 0.62, 0.80], [0.30, 0.88, 1.0], [0.95, 0.95, 0.70], [1.0, 0.72, 0.25]];
  const rampAt = (t) => { const s = Math.max(0, Math.min(1, t)) * (ramp.length - 1); const i = Math.min(ramp.length - 2, Math.floor(s)), f = s - i; return [0, 1, 2].map(c => ramp[i][c] + (ramp[i + 1][c] - ramp[i][c]) * f); };
  const size = Math.max(0.06, diag * 0.0042);
  function upload() {
    const sel = state.view.layer || 'F.Cu', selK = LAYERS.indexOf(sel);
    const exploded = knob.explode > 0.3;
    const sx = bx(0) - 0, sy = by(0);
    for (let p = 0; p < MAX; p++) {
      const o = p * 4;
      const live = life[p] > 0 && (p < P || p >= COUNTS.high);
      let alpha = 0;
      if (live) {
        const fade = Math.min(1, age[p] / 0.25, (life[p] - age[p]) / 0.35);
        const isFall = p >= COUNTS.high;
        const layerA = isFall ? knob.river : (exploded || knob.river > 0.5 || pk[p] === selK || mode[p] === 1) ? 1 : 0.1;
        alpha = knob.electrons && !isFall ? Math.max(0, fade) * 0.7 * knob.flowOn : Math.max(0, fade) * layerA * (isFall ? 0.28 : (0.12 + 0.7 * heat[p] * heat[p]) * (1 - 0.55 * knob.river)) * (isFall ? 1 : knob.flowOn);
      }
      IP[o] = px[p] + sx; IP[o + 1] = sy - py[p]; IP[o + 2] = pz[p]; IP[o + 3] = size * (p >= COUNTS.high ? 0.8 : knob.electrons ? 0.9 : 1);
      IV[o] = vx[p]; IV[o + 1] = -vy[p]; IV[o + 2] = vz[p]; IV[o + 3] = alpha;
      let c;
      if (p >= COUNTS.high) c = mode[p] === 3 ? [0.55, 1.0, 0.7] : [0.75, 0.93, 1.0];
      else if (knob.river > 0.5) c = [0.70, 0.92, 1.0];
      else if (knob.electrons) c = [0.40, 0.62, 1.0];
      else c = rampAt(heat[p]);
      IC[o] = c[0]; IC[o + 1] = c[1]; IC[o + 2] = c[2]; IC[o + 3] = 1.0;
    }
    // the energy: magenta to white, in the gap between the layers
    for (let q = 0; q < EMAX; q++) {
      const o = (MAX + q) * 4, live = q < EP && elife[q] > 0 && knob.energyOn > 0.01;
      const fade = live ? Math.max(0, Math.min(1, eage[q] / 0.3, (elife[q] - eage[q]) / 0.4)) : 0;
      IP[o] = bx(ex[q]); IP[o + 1] = by(ey[q]); IP[o + 2] = ez[q]; IP[o + 3] = size * 1.15;
      IV[o] = evx[q]; IV[o + 1] = -evy[q]; IV[o + 2] = evz[q]; IV[o + 3] = fade * knob.energyOn * (0.10 + 0.42 * eh[q] * eh[q]);
      const t = eh[q], c = t < 0.5 ? [0.55 + 0.9 * t, 0.10 + 0.3 * t, 0.55 + 0.6 * t] : [1.0, 0.25 + 1.3 * (t - 0.5), 0.85 + 0.3 * (t - 0.5)];
      IC[o] = Math.min(1, c[0]); IC[o + 1] = Math.min(1, c[1]); IC[o + 2] = Math.min(1, c[2]); IC[o + 3] = 1;
    }
    // the charge view's field: + face to - face across each gap, red to blue
    for (let q = 0; q < 7000; q++) {
      const o = (MAX + EMAX + q) * 4, live = q < CP && knob.chargeOn > 0.01 && cs[q] > 0;
      if (!live) { IV[o + 3] = 0; continue; }
      const G = gaps[cg[q]], i = cellOf(cx[q], cy[q]); const a = lay[G.a].net[i], b = lay[G.b].net[i];
      const va = railOf(a, knob.tide), vb = railOf(b, knob.tide), dv = va - vb;
      cu[q] += lastDt * 0.9 * cs[q] * knob.speed; if (cu[q] >= 1) { cu[q] -= 1; spawnCharge(q); }
      const za = zOf(G.a), zb = zOf(G.b), from = dv >= 0 ? za : zb, to = dv >= 0 ? zb : za, z = from + (to - from) * cu[q];
      const t = Math.min(1, Math.abs(dv) / Math.max(1e-9, topRail));
      IP[o] = bx(cx[q]); IP[o + 1] = by(cy[q]); IP[o + 2] = z; IP[o + 3] = size * 0.9;
      IV[o] = 0; IV[o + 1] = 0; IV[o + 2] = (to - from) * 1.2; IV[o + 3] = knob.chargeOn * (0.1 + 0.6 * t) * Math.sin(Math.PI * cu[q]);
      IC[o] = 1 - 0.65 * cu[q]; IC[o + 1] = 0.35 + 0.25 * cu[q]; IC[o + 2] = 0.3 + 0.7 * cu[q]; IC[o + 3] = 1;
    }
    vbP.update(IP); vbV.update(IV); vbC.update(IC);
    smat.setVector3('camPos', camera.globalPosition || camera.position);
  }

  // ---- the river terrain: a height-field per layer, coloured by volts, water running over it -------
  BAB.Effect.ShadersStore.fieldsRiverVertexShader = `precision highp float;
attribute vec3 position; attribute vec2 uv; attribute vec4 rv;
uniform mat4 world; uniform mat4 viewProjection; uniform float kR; uniform float kD; uniform float tide; uniform float lift;
varying vec2 vUV; varying float vV; varying vec3 vW; varying vec3 vL;
void main(void) {
  float v = rv.x + rv.z * tide;
  vec3 p = position + vec3(0.0, 0.0, lift * (v * kR + rv.y * kD));
  vUV = uv; vV = v + rv.y; vW = (world * vec4(p, 1.0)).xyz; vL = p;
  gl_Position = viewProjection * vec4(vW, 1.0);
}`;
  BAB.Effect.ShadersStore.fieldsRiverFragmentShader = `precision highp float;
varying vec2 vUV; varying float vV; varying vec3 vW; varying vec3 vL;
uniform sampler2D flowTex; uniform float time; uniform float vTop; uniform vec3 camPos; uniform vec2 cells; uniform float alpha;
float hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
float noise(vec2 p) { vec2 i = floor(p), f = fract(p); f = f * f * (3.0 - 2.0 * f);
  return mix(mix(hash(i), hash(i + vec2(1.0, 0.0)), f.x), mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), f.x), f.y); }
float ripple(vec2 p, vec2 dir, float ph) {
  vec2 n = vec2(-dir.y, dir.x);
  vec2 q = vec2(dot(p, dir), dot(p, n));
  q.x -= ph * 7.0;
  return noise(vec2(q.x * 0.55, q.y * 2.6)) * 0.65 + noise(vec2(q.x * 1.3, q.y * 5.0) + 17.0) * 0.35;
}
void main(void) {
  vec4 ft = texture2D(flowTex, vUV);
  if (ft.a < 0.5) discard;
  vec2 fl = ft.gb * 2.0 - 1.0; float fm = length(fl); vec2 dir = fm > 0.05 ? fl / fm : vec2(1.0, 0.0); float mag = ft.r;
  vec3 nrm = normalize(cross(dFdx(vW), dFdy(vW)));
  vec3 vd = normalize(camPos - vW); if (dot(nrm, vd) < 0.0) nrm = -nrm;
  float t = clamp(vV / vTop, 0.0, 1.0);
  vec3 c = mix(vec3(0.015, 0.07, 0.22), vec3(0.0, 0.42, 0.55), smoothstep(0.0, 0.4, t));
  c = mix(c, vec3(0.35, 0.85, 0.98), smoothstep(0.35, 1.0, t));
  vec2 p = vUV * cells * 0.35;
  float ph = time * (0.25 + 1.6 * mag);
  float p0 = fract(ph), p1 = fract(ph + 0.5);
  float r = mix(ripple(p, dir, p0), ripple(p + 3.7, dir, p1), abs(1.0 - 2.0 * p0));
  float foam = smoothstep(0.66, 0.95, r) * pow(mag, 1.4) * step(0.05, fm);
  vec3 Ld = normalize(vec3(0.35, 0.55, 0.75));
  float diff = 0.5 + 0.5 * max(dot(nrm, Ld), 0.0);
  vec3 h = normalize(Ld + vd); float spec = pow(max(dot(nrm, h), 0.0), 60.0) * 0.6;
  float fres = pow(1.0 - abs(dot(nrm, vd)), 3.0);
  vec3 col = c * diff + foam * vec3(0.85, 0.97, 1.0) * 0.9 + spec + fres * vec3(0.25, 0.45, 0.55);
  // a terrace's side is rock, not water: no current crosses the gap between two nets
  vec3 nl = normalize(cross(dFdx(vL), dFdy(vL)));
  float cliff = smoothstep(0.55, 0.85, 1.0 - abs(nl.z));
  vec3 rock = vec3(0.10, 0.13, 0.17) * (0.6 + 0.4 * diff) + vec3(0.0, 0.10, 0.14) * t;
  col = mix(col, rock, cliff);
  gl_FragColor = vec4(col, alpha * mix(1.0, 0.8, cliff));
}`;
  const terrains = [];
  const stride = Math.max(1, Math.ceil(Math.sqrt(N / 70000)));
  for (const L of lay) {
    if (L.cuCells < 0.02 * N) continue;
    const cw = Math.ceil(W / stride) + 1, ch = Math.ceil(H / stride) + 1;
    const pos = new Float32Array(cw * ch * 3), uvs = new Float32Array(cw * ch * 2), rv = new Float32Array(cw * ch * 4), ind = [];
    for (let rr = 0; rr < ch; rr++) for (let cc = 0; cc < cw; cc++) {
      const c = Math.min(W - 1, cc * stride), r = Math.min(H - 1, rr * stride), v = rr * cw + cc;
      const x = meta.x0 + Math.min(W, cc * stride) * d, y = meta.y0 + Math.min(H, rr * stride) * d;
      pos[v * 3] = bx(x); pos[v * 3 + 1] = by(y); pos[v * 3 + 2] = 0;
      uvs[v * 2] = Math.min(W, cc * stride) / W; uvs[v * 2 + 1] = Math.min(H, rr * stride) / H;
      // off the copper, borrow the nearest copper's level so a plateau keeps its edge (the mask cuts it)
      let i = r * W + c;
      if (L.net[i] < 0) { let best = -1; for (let rad = 1; rad <= 3 && best < 0; rad++) for (let dr = -rad; dr <= rad && best < 0; dr++) for (let dc = -rad; dc <= rad; dc++) { const r2 = r + dr, c2 = c + dc; if (r2 < 0 || c2 < 0 || r2 >= H || c2 >= W) continue; const j = r2 * W + c2; if (L.net[j] >= 0) { best = j; break; } } if (best >= 0) i = best; }
      const id = L.net[i];
      if (id >= 0) {
        const n = nets[id], vv = L.volt[i];
        rv[v * 4] = railPart(id); rv[v * 4 + 1] = Number.isFinite(vv) ? (n.switching ? vv : vv - (n.railV ?? 0)) : 0;
        rv[v * 4 + 2] = n.switching ? n.switching.highV - (n.switching.lowV || 0) : 0; rv[v * 4 + 3] = 1;
      }
    }
    for (let rr = 0; rr < ch - 1; rr++) for (let cc = 0; cc < cw - 1; cc++) { const a = rr * cw + cc; ind.push(a, a + cw, a + 1, a + 1, a + cw, a + cw + 1); }
    const mesh = new BAB.Mesh('R-' + L.name, scene);
    const vd = new BAB.VertexData(); vd.positions = pos; vd.uvs = uvs; vd.indices = ind; vd.applyToMesh(mesh, false);
    mesh.setVerticesData('rv', rv, false, 4);
    // the flow texture: |J| (log) in red, direction in green and blue, copper in alpha
    const tex = new Uint8Array(N * 4);
    for (let i = 0; i < N; i++) {
      const m = Math.hypot(L.jx[i], L.jy[i]);
      tex[i * 4] = Math.round(logT(m) * 255);
      tex[i * 4 + 1] = Math.round((m > 0 ? L.jx[i] / m * 0.5 + 0.5 : 0.5) * 255);
      tex[i * 4 + 2] = Math.round((m > 0 ? L.jy[i] / m * 0.5 + 0.5 : 0.5) * 255);
      tex[i * 4 + 3] = L.net[i] >= 0 ? 255 : 0;
    }
    const ftex = new BAB.RawTexture(tex, W, H, BAB.Constants.TEXTUREFORMAT_RGBA, scene, false, false, BAB.Texture.BILINEAR_SAMPLINGMODE, BAB.Constants.TEXTURETYPE_UNSIGNED_BYTE);
    ftex.wrapU = ftex.wrapV = BAB.Texture.CLAMP_ADDRESSMODE;
    const mat = new BAB.ShaderMaterial('fields-river-' + L.name, scene, { vertex: 'fieldsRiver', fragment: 'fieldsRiver' }, { attributes: ['position', 'uv', 'rv'], uniforms: ['world', 'viewProjection', 'kR', 'kD', 'tide', 'lift', 'time', 'vTop', 'camPos', 'cells', 'alpha'], samplers: ['flowTex'], needAlphaBlending: true });
    mat.setTexture('flowTex', ftex); mat.setFloat('kR', kR); mat.setFloat('kD', kD); mat.setFloat('vTop', topRail); mat.setVector2('cells', new BAB.Vector2(W * d, H * d)); mat.backFaceCulling = false;
    mesh.material = mat; mesh.parent = root; mesh.isPickable = true; mesh.setEnabled(false);
    terrains.push({ L, mesh, mat });
  }

  // ---- light: how fast a change travels, cell by cell ------------------------------------------------
  // A change (switch-on, every switching edge) travels as a wave in the field around the copper, at
  // v = c / sqrt(er_eff). Between two planes the field is all FR4 (er_eff = er); a trace over a plane is a
  // microstrip, half in air: er_eff = (er+1)/2 + (er-1)/2 (1 + 12 h/w)^-1/2 (Hammerstad), so a wide pour is
  // slower than a thin trace; copper with no reference plane under it is mostly air. The delay to a part
  // is the sum of length / speed along the copper from the source, the fastest path (Dijkstra).
  const ER = +(state.fields.signals && state.fields.signals.epsilonR) || 4.3;
  const C_MM_PS = 0.299792458;
  const pctC = new Float32Array(LAYERS.length * N).fill(NaN);
  {
    const realSet = new Set(real);
    for (const k of real) {
      const L = lay[k], idx = real.indexOf(k);
      const above = idx > 0 ? real[idx - 1] : -1, below = idx + 1 < real.length ? real[idx + 1] : -1;
      // the distance (cells) from each copper cell to the edge of its own net's copper: half the local width
      const dist = new Float32Array(N).fill(1e9);
      const q = [];
      for (let i = 0; i < N; i++) { const n = L.net[i]; if (n < 0) continue; const r = Math.floor(i / W), c = i % W;
        const edge = c === 0 || r === 0 || c === W - 1 || r === H - 1 || L.net[i - 1] !== n || L.net[i + 1] !== n || L.net[i - W] !== n || L.net[i + W] !== n;
        if (edge) { dist[i] = 0.5; q.push(i); } }
      for (let h0 = 0; h0 < q.length; h0++) { const i = q[h0], n = L.net[i]; for (const j of [i - 1, i + 1, i - W, i + W]) { if (j < 0 || j >= N || L.net[j] !== n) continue; if (dist[j] > dist[i] + 1) { dist[j] = dist[i] + 1; q.push(j); } } }
      for (let i = 0; i < N; i++) {
        if (L.net[i] < 0) continue;
        const refUp = above >= 0 && lay[above].net[i] >= 0, refDn = below >= 0 && lay[below].net[i] >= 0;
        let eff;
        if (refUp && refDn) eff = ER;                                         // stripline: all FR4
        else if (refUp || refDn) {                                            // microstrip over a plane
          const h = Math.abs(Z[LAYERS[k]] - Z[LAYERS[refUp ? above : below]]);
          let wl = 0; const r = Math.floor(i / W), c = i % W;                 // local width: twice the widest half-width nearby
          for (let dr = -2; dr <= 2; dr++) for (let dc = -2; dc <= 2; dc++) { const rr = r + dr, cc = c + dc; if (rr < 0 || cc < 0 || rr >= H || cc >= W) continue; const j = rr * W + cc; if (L.net[j] === L.net[i]) wl = Math.max(wl, dist[j]); }
          const w = Math.max(d, 2 * wl * d);
          eff = (ER + 1) / 2 + (ER - 1) / 2 / Math.sqrt(1 + 12 * h / w);
        } else eff = 1 + (ER - 1) * 0.15;                                     // no reference: mostly air
        pctC[k * N + i] = 1 / Math.sqrt(eff);
      }
      void realSet;
    }
  }
  // arrival time (ps) from each fed net's source terminals over its own copper, vias joining the layers
  const arrival = new Float64Array(LAYERS.length * N).fill(Infinity);   // 64-bit: the queue compares exact times
  const arrivals = [];
  {
    const heap = []; const push = (t, v) => { heap.push([t, v]); let i = heap.length - 1; while (i > 0) { const p = (i - 1) >> 1; if (heap[p][0] <= heap[i][0]) break; [heap[p], heap[i]] = [heap[i], heap[p]]; i = p; } };
    const pop = () => { const top = heap[0], last = heap.pop(); if (heap.length) { heap[0] = last; let i = 0; for (;;) { const l = 2 * i + 1, r = l + 1; let m = i; if (l < heap.length && heap[l][0] < heap[m][0]) m = l; if (r < heap.length && heap[r][0] < heap[m][0]) m = r; if (m === i) break; [heap[m], heap[i]] = [heap[i], heap[m]]; i = m; } } return top; };
    const viaCells = new Set(state.fields.vias.map(v => cellOf(v.x, v.y)).filter(i => i >= 0));
    for (const [id, n] of nets.entries()) {
      if (!n.loaded || n.ground) continue;
      for (const t of (n.terminals || []).filter(t => t.role === 'source')) {
        const i0 = cellOf(t.x, t.y); if (i0 < 0) continue;
        for (const k of real) { const R = Math.ceil(0.6 / d); const r0 = Math.floor(i0 / W), c0 = i0 % W;
          for (let dr = -R; dr <= R; dr++) for (let dc = -R; dc <= R; dc++) { const r = r0 + dr, c = c0 + dc; if (r < 0 || c < 0 || r >= H || c >= W) continue; const i = r * W + c; if (lay[k].net[i] === id && arrival[k * N + i] > 0) { arrival[k * N + i] = 0; push(0, k * N + i); } } }
      }
      while (heap.length) {
        const [t, v] = pop(); if (t > arrival[v]) continue;
        const k = Math.floor(v / N), i = v % N, r = Math.floor(i / W), c = i % W;
        const sp = (pctC[v] || 0.5) * C_MM_PS;
        const nb = [];
        if (c > 0) nb.push(v - 1); if (c < W - 1) nb.push(v + 1); if (r > 0) nb.push(v - W); if (r < H - 1) nb.push(v + W);
        if (viaCells.has(i)) for (const k2 of real) if (k2 !== k) nb.push(k2 * N + i);
        for (const u of nb) {
          const k2 = Math.floor(u / N), j = u % N; if (lay[k2].net[j] !== id) continue;
          const len = k2 === k ? d : Math.abs(Z[LAYERS[k]] - Z[LAYERS[k2]]);
          const nt = t + len / (0.5 * (sp + (pctC[u] || 0.5) * C_MM_PS));
          if (nt < arrival[u]) { arrival[u] = nt; push(nt, u); }
        }
      }
      { let cnt = 0, mx = 0; for (const k of real) for (let i = 0; i < N; i++) if (lay[k].net[i] === id && Number.isFinite(arrival[k * N + i])) { cnt++; mx = Math.max(mx, arrival[k * N + i]); } console.log(`light: ${n.name} wave reaches ${cnt} cells, the last at ${mx.toFixed(0)} ps`); }
      for (const t of (n.terminals || []).filter(t => t.role === 'sink')) {
        const i = cellOf(t.x, t.y); let best = Infinity;
        for (const k of real) { const R = Math.ceil(0.6 / d), r0 = Math.floor(i / W), c0 = i % W; for (let dr = -R; dr <= R; dr++) for (let dc = -R; dc <= R; dc++) { const r = r0 + dr, c = c0 + dc; if (r < 0 || c < 0 || r >= H || c >= W) continue; const j = r * W + c; if (lay[k].net[j] === id) best = Math.min(best, arrival[k * N + j]); } }
        if (Number.isFinite(best)) arrivals.push({ net: n.name, ref: t.ref, x: t.x, y: t.y, ps: best, from: (n.terminals.find(s => s.role === 'source') || {}).ref });
      }
    }
  }
  let tMax = 1; for (let v = 0; v < arrival.length; v++) if (Number.isFinite(arrival[v])) tMax = Math.max(tMax, arrival[v]);
  // the light layers: flat sheets on the copper, coloured by % of c, the wave front sweeping by arrival time
  BAB.Effect.ShadersStore.fieldsLightVertexShader = `precision highp float;
attribute vec3 position; attribute vec2 uv; uniform mat4 world; uniform mat4 viewProjection; varying vec2 vUV;
void main(void) { vUV = uv; gl_Position = viewProjection * world * vec4(position, 1.0); }`;
  BAB.Effect.ShadersStore.fieldsLightFragmentShader = `precision highp float;
varying vec2 vUV; uniform sampler2D lightTex; uniform float phase; uniform float alpha;
void main(void) {
  vec4 t = texture2D(lightTex, vUV);
  if (t.a < 0.5) discard;
  float pc = t.g;                                    // 0..1 maps 40 %..100 % of c
  vec3 slow = vec3(0.10, 0.20, 0.75), mid = vec3(0.0, 0.75, 0.80), fast = vec3(0.85, 1.0, 0.75);
  vec3 base = pc < 0.3 ? mix(slow, mid, pc / 0.3) : mix(mid, fast, (pc - 0.3) / 0.7);
  float arrived = t.b > 0.5 ? 1.0 : 0.0;
  float dt = phase - t.r;                            // how long ago the front passed this cell (0..1 of the loop)
  float front = arrived * exp(-pow(dt * 40.0, 2.0)) ;
  float wake = arrived * step(0.0, dt) * exp(-dt * 6.0);
  vec3 col = base * (0.35 + 0.65 * wake) + vec3(1.0) * front * 1.2;
  gl_FragColor = vec4(col, alpha * (0.55 + 0.45 * max(front, wake)));
}`;
  const lightMeshes = [];
  for (const k of real) {
    const L = lay[k], tex = new Uint8Array(N * 4);
    for (let i = 0; i < N; i++) {
      const v = k * N + i, pc = pctC[v], at = arrival[v];
      tex[i * 4] = Number.isFinite(at) ? Math.round(Math.min(1, at / tMax) * 255) : 0;
      tex[i * 4 + 1] = Number.isFinite(pc) ? Math.round(Math.max(0, Math.min(1, (pc - 0.4) / 0.6)) * 255) : 0;
      tex[i * 4 + 2] = Number.isFinite(at) ? 255 : 0;
      tex[i * 4 + 3] = L.net[i] >= 0 ? 255 : 0;
    }
    const ltex = new BAB.RawTexture(tex, W, H, BAB.Constants.TEXTUREFORMAT_RGBA, scene, false, false, BAB.Texture.BILINEAR_SAMPLINGMODE, BAB.Constants.TEXTURETYPE_UNSIGNED_BYTE);
    const mesh = BAB.MeshBuilder.CreatePlane('LT-' + L.name, { width: W * d, height: H * d, sideOrientation: BAB.Mesh.DOUBLESIDE }, scene);
    // the plane's uv runs bottom-up; the texture rows run down the board (KiCad y): flip v
    const uvs = mesh.getVerticesData(BAB.VertexBuffer.UVKind); for (let i = 1; i < uvs.length; i += 2) uvs[i] = 1 - uvs[i]; mesh.setVerticesData(BAB.VertexBuffer.UVKind, uvs);
    mesh.position.x = bx(meta.x0 + W * d / 2); mesh.position.y = by(meta.y0 + H * d / 2);
    const mat = new BAB.ShaderMaterial('fields-light-' + L.name, scene, { vertex: 'fieldsLight', fragment: 'fieldsLight' }, { attributes: ['position', 'uv'], uniforms: ['world', 'viewProjection', 'phase', 'alpha'], samplers: ['lightTex'], needAlphaBlending: true });
    mat.setTexture('lightTex', ltex); mat.backFaceCulling = false; mat.alphaMode = BAB.Constants.ALPHA_COMBINE;
    mesh.material = mat; mesh.parent = root; mesh.isPickable = false; mesh.setEnabled(false);
    lightMeshes.push({ k, mesh, mat });
  }
  const LIGHT_LOOP = 3.2;   // seconds per sweep on screen; the real sweep takes tMax picoseconds
  const lightTags = [];
  { const host = document.getElementById('labels'); const seen = new Set();
    for (const a of arrivals.sort((x, y) => x.ps - y.ps)) { const key = a.ref + a.net; if (seen.has(key)) continue; seen.add(key);
      const el = document.createElement('div'); el.className = 'lbl light'; el.hidden = true; host.appendChild(el);
      el.innerHTML = `<b>${a.ref}</b> ${a.net} arrives <span class="ps">${a.ps.toFixed(0)} ps</span> after ${a.from || 'the source'}`;
      lightTags.push({ el, a }); } }

  // ---- charge: the board as capacitors --------------------------------------------------------------
  // Copper at different voltages facing each other across the board material is a capacitor: each face
  // holds sigma = e0 er dV / h (C/mm², + on the higher voltage), the field between them is dV / h. A switching
  // node's copper charges and empties with its tide every cycle. The capacitor parts hold Q = C V.
  const E0 = 8.854e-15;   // F/mm
  let lastDt = 1 / 60;
  const railOf = (id, tide) => { const n = nets[id]; if (!n) return NaN; if (n.switching) return (n.switching.lowV || 0) + (n.switching.highV - (n.switching.lowV || 0)) * tide; return n.railV == null ? NaN : n.railV; };
  const chargeTex = [];
  const pairs = new Map();   // "top|bottom" -> { area mm², h, top, bot }
  for (const G of gaps) {
    const A = lay[G.a], B = lay[G.b];
    for (let i = 0; i < N; i++) { const ta = A.net[i], tb = B.net[i]; if (ta < 0 || tb < 0 || ta === tb) continue; const key = ta + '|' + tb; const p = pairs.get(key) || { area: 0, h: G.h, top: ta, bot: tb }; p.area += d * d; pairs.set(key, p); }
  }
  const pairList = [...pairs.values()].map(p => ({ ...p, C: E0 * ER * p.area / p.h })).sort((x, y) => y.C - x.C);
  let sigMax = 1e-30;
  const sigmaAt = (k, i, tide) => { // the charge per mm² on layer k's copper at cell i
    const id = lay[k].net[i]; if (id < 0) return 0; const vk = railOf(id, tide); if (!Number.isFinite(vk)) return 0; let s = 0;
    for (const G of gaps) { if (G.a !== k && G.b !== k) continue; const o = G.a === k ? G.b : G.a; const oid = lay[o].net[i]; if (oid < 0 || oid === id) continue; const vo = railOf(oid, tide); if (Number.isFinite(vo)) s += E0 * ER * (vk - vo) / G.h; }
    return s;
  };
  for (const k of real) for (let i = 0; i < N; i += 3) sigMax = Math.max(sigMax, Math.abs(sigmaAt(k, i, 1)), Math.abs(sigmaAt(k, i, 0)));
  for (const k of real) {
    const L = lay[k], data = new Uint8Array(N * 4);
    const tex = new BAB.RawTexture(data, W, H, BAB.Constants.TEXTUREFORMAT_RGBA, scene, false, false, BAB.Texture.BILINEAR_SAMPLINGMODE, BAB.Constants.TEXTURETYPE_UNSIGNED_BYTE);
    const mesh = BAB.MeshBuilder.CreatePlane('CH-' + L.name, { width: W * d, height: H * d, sideOrientation: BAB.Mesh.DOUBLESIDE }, scene);
    const uvs = mesh.getVerticesData(BAB.VertexBuffer.UVKind); for (let i = 1; i < uvs.length; i += 2) uvs[i] = 1 - uvs[i]; mesh.setVerticesData(BAB.VertexBuffer.UVKind, uvs);
    mesh.position.x = bx(meta.x0 + W * d / 2); mesh.position.y = by(meta.y0 + H * d / 2);
    const m = new BAB.StandardMaterial('CH-m-' + L.name, scene); m.diffuseTexture = tex; tex.hasAlpha = true; m.useAlphaFromDiffuseTexture = true; m.emissiveColor = new BAB.Color3(1, 1, 1); m.disableLighting = true; m.backFaceCulling = false;
    mesh.material = m; mesh.parent = root; mesh.isPickable = false; mesh.setEnabled(false);
    // the switching cells, so a frame only rewrites what the tide moves
    const sw = []; for (let i = 0; i < N; i++) { const id = L.net[i]; if (id < 0) continue; let moving = !!(nets[id] && nets[id].switching); if (!moving) for (const G of gaps) { if (G.a !== k && G.b !== k) continue; const o = G.a === k ? G.b : G.a, oid = lay[o].net[i]; if (oid >= 0 && nets[oid] && nets[oid].switching) moving = true; } if (moving) sw.push(i); }
    chargeTex.push({ k, L, data, tex, mesh, mat: m, sw, painted: false });
  }
  function paintCharge(ct, tide, all) {
    const { k, L, data } = ct; const cells = all ? null : ct.sw;
    const n = all ? N : cells.length;
    for (let q = 0; q < n; q++) {
      const i = all ? q : cells[q], o = i * 4;
      if (L.net[i] < 0) { data[o + 3] = 0; continue; }
      const s = sigmaAt(k, i, tide), t = Math.sqrt(Math.min(1, Math.abs(s) / sigMax));
      if (s >= 0) { data[o] = 255; data[o + 1] = Math.round(70 + 60 * (1 - t)); data[o + 2] = Math.round(60 + 60 * (1 - t)); }
      else { data[o] = Math.round(60 + 60 * (1 - t)); data[o + 1] = Math.round(120 + 40 * (1 - t)); data[o + 2] = 255; }
      data[o + 3] = Math.round(40 + 215 * t);
    }
    ct.tex.update(data);
  }
  // the field: particles crossing each gap from the + face to the - face
  const CMAX = 7000, cx = new Float32Array(CMAX), cy = new Float32Array(CMAX), cu = new Float32Array(CMAX), cg = new Int8Array(CMAX), cdir = new Int8Array(CMAX), cs = new Float32Array(CMAX);
  let cCdf = null, cKeys = null; const CP = Math.max(1500, Math.min(CMAX, Math.round(lay[0].cuCells * d * d * 4)));
  function buildChargeSpawn() {
    const keys = [], w = [];
    for (const [g, G] of gaps.entries()) for (let i = 0; i < N; i++) { const a = lay[G.a].net[i], b = lay[G.b].net[i]; if (a < 0 || b < 0 || a === b) continue; const dv = Math.max(Math.abs(railOf(a, 1) - railOf(b, 1)), Math.abs(railOf(a, 0) - railOf(b, 0))); if (Number.isFinite(dv) && dv > 0.05) { keys.push(g * N + i); w.push(dv); } }
    cKeys = Int32Array.from(keys); cCdf = new Float64Array(w.length); let acc = 0; for (let i = 0; i < w.length; i++) { acc += w[i]; cCdf[i] = acc; }
    for (let p = 0; p < CMAX; p++) cu[p] = rnd();
    for (let p = 0; p < CP; p++) spawnCharge(p);
  }
  function spawnCharge(p) {
    if (!cCdf || !cCdf.length) { cs[p] = 0; return; }
    const r = rnd() * cCdf[cCdf.length - 1]; let lo = 0, hi = cCdf.length - 1; while (lo < hi) { const m = (lo + hi) >> 1; if (cCdf[m] < r) lo = m + 1; else hi = m; }
    const key = cKeys[lo], g = Math.floor(key / N), i = key % N; cg[p] = g; cx[p] = meta.x0 + ((i % W) + rnd()) * d; cy[p] = meta.y0 + (Math.floor(i / W) + rnd()) * d; cs[p] = 0.7 + 0.6 * rnd();
  }
  const capParts = [];
  { const vals = state.fields.partValues || {}, pads = state.fields.pads || [];
    const parseC = (s) => { for (const tok of String(s || '').replace(',', '.').split(/\s+/)) { const m = tok.match(/^(\d+(?:\.\d+)?)([pnuµm]?)(\d*)F?$/i); if (!m || !m[2]) continue; const mul = { p: 1e-12, n: 1e-9, u: 1e-6, 'µ': 1e-6, m: 1e-3 }[m[2].toLowerCase()] || 1; return parseFloat(m[1] + (m[3] ? '.' + m[3] : '')) * mul; } return null; };
    const byRef = {}; for (const p of pads) if (/^C\d/i.test(p.ref)) (byRef[p.ref] = byRef[p.ref] || []).push(p);
    for (const [ref, ps] of Object.entries(byRef)) { const ids = [...new Set(ps.map(p => netIndex[p.net]))]; if (ids.length !== 2) continue; const C = parseC(vals[ref]); if (!C) continue; const v = Math.abs(railOf(ids[0], 1) - railOf(ids[1], 1)); if (!Number.isFinite(v)) continue; capParts.push({ ref, C, V: v, Q: C * v, E: 0.5 * C * v * v, x: ps.reduce((s, p) => s + p.x, 0) / ps.length, y: ps.reduce((s, p) => s + p.y, 0) / ps.length }); }
    capParts.sort((a, b) => b.Q - a.Q); }
  const fmtC = (c) => c >= 1e-6 ? (c * 1e6).toFixed(c >= 1e-5 ? 0 : 1) + ' µF' : c >= 1e-9 ? (c * 1e9).toFixed(1) + ' nF' : (c * 1e12).toFixed(1) + ' pF';
  const fmtQ = (q) => q >= 1e-6 ? (q * 1e6).toFixed(0) + ' µC' : q >= 1e-9 ? (q * 1e9).toFixed(1) + ' nC' : (q * 1e12).toFixed(1) + ' pC';
  const fmtE = (e) => e >= 1e-3 ? (e * 1e3).toFixed(2) + ' mJ' : e >= 1e-6 ? (e * 1e6).toFixed(2) + ' µJ' : (e * 1e9).toFixed(3) + ' nJ';
  const chargePanel = document.createElement('div'); chargePanel.className = 'cyclemeter chargepanel'; chargePanel.hidden = true; document.querySelector('.stage').appendChild(chargePanel);
  {
    const rows = pairList.filter(p => Number.isFinite(railOf(p.top, 1) - railOf(p.bot, 1))).slice(0, 3).map(p => { const a = nets[p.top], b = nets[p.bot], v = Math.abs(railOf(p.top, 1) - railOf(p.bot, 1)); return `<div class="row"><span class="dot in"></span>${a.name}/${b.name}${a.switching || b.switching ? ' ~' : ''}<i>${fmtC(p.C)} · ${fmtQ(p.C * v)}</i></div>`; }).join('');
    const parts = capParts.slice(0, 3).map(c => `<div class="row"><span class="dot out"></span>${c.ref} ${fmtC(c.C)}<i>${fmtQ(c.Q)} · ${fmtE(c.E)}</i></div>`).join('');
    const ratio = capParts[0] && pairList[0] ? capParts[0].C / pairList[0].C : 0;
    chargePanel.innerHTML = `<div class="ch" title="Click to fold">Capacitance on this board</div><div class="body"><div class="row" style="color:var(--text-2)">The copper itself (~ switching)</div>${rows}<div class="row" style="color:var(--text-2);margin-top:8px">The capacitor parts</div>${parts}${ratio ? `<div class="row" style="color:var(--text-2);margin-top:8px">${capParts[0].ref} alone holds ${ratio >= 1e6 ? (ratio / 1e6).toFixed(0) + ' million' : Math.round(ratio).toLocaleString()} times the copper's ${nets[pairList[0].top].name} capacitance</div>` : ''}</div>`;
    chargePanel.addEventListener('click', (e) => { if (e.target.closest('.ch')) chargePanel.classList.toggle('folded'); });
  }

  // ---- glow: the streaks bloom, nothing else does --------------------------------------------------
  let glow = null;
  try { glow = new BAB.GlowLayer('fields-flow-glow', scene, { mainTextureSamples: 2, blurKernelSize: 24 }); glow.intensity = 0.3; glow.addIncludedOnlyMesh(streak); glow.referenceMeshToUseItsOwnMaterial(streak); }
  catch (e) { console.warn('flow: no glow layer: ' + (e && e.message || e)); }

  // ---- the stack: layer planes, vias, board and parts follow the explode ------------------------------
  const boardBox = scene.getMeshByName('fr4_board');
  const partsRoot = scene.getTransformNodeByName('fields-parts');
  const partSide = {}, partAt = {}; for (const it of ((state.fields.parts || {}).items || [])) { partSide[it.ref] = it.side; const i = cellOf(it.x, it.y); if (i >= 0) partAt[it.ref] = { i, x: it.x, y: it.y }; }
  const viaMesh = scene.getMeshByName('barrel');
  function applyStack() {
    for (const [k, Lname] of LAYERS.entries()) { const m = ctx.layerMeshes[Lname]; if (m) m.position.z = zOf(k); }
    for (const t of terrains) t.mesh.position.z = zOf(t.L.k);
    if (viaMesh) viaMesh.scaling.z = 1 + knob.explode * gap * 3 / Math.max(0.2, board.thicknessMm);
    // pulled apart, the barrels turn to glass so the current falling inside them shows
    if (viaMesh && viaMesh.material) { const m = viaMesh.material; m.alpha = 1 - 0.8 * knob.explode; m.transparencyMode = knob.explode > 0.02 ? BAB.Material.MATERIAL_ALPHABLEND : BAB.Material.MATERIAL_OPAQUE; }
    if (boardBox && boardBox.material) boardBox.material.alpha = 0.94 * (1 - 0.82 * knob.explode) * (1 - 0.6 * knob.river) * (1 - 0.7 * knob.energyOn) * (1 - 0.7 * knob.chargeOn);
    const pr = partsRoot || scene.getTransformNodeByName('fields-parts');
    if (pr) for (const h of pr.getChildren()) { const ref = h.name.replace(/^part-/, ''); const side = partSide[ref] === 'bottom' ? -1 : 1; if (h._z0 === undefined) h._z0 = h.position.z; const at = partAt[ref]; const lift = side > 0 && at ? heightAt(0, at.i) : 0; h.position.z = h._z0 + knob.explode * gap * 1.5 * side + lift; }
  }

  // ---- the river's signposts: each net's name and voltage over the middle of its own copper ------------
  const signs = [];
  {
    const host = document.getElementById('labels');
    for (const [id, n] of nets.entries()) {
      if (!n.loaded && n.railV == null) continue;
      // the copper cell of this net nearest its centroid, on the top copper (else any layer)
      let best = -1, bk = 0;
      for (const L of lay) {
        let sx = 0, sy = 0, cnt = 0; for (let i = 0; i < N; i++) if (L.net[i] === id) { sx += i % W; sy += Math.floor(i / W); cnt++; }
        if (cnt < 30) continue;
        const cx = sx / cnt, cy = sy / cnt; let bd = Infinity;
        for (let i = 0; i < N; i++) if (L.net[i] === id) { const dd = (i % W - cx) ** 2 + (Math.floor(i / W) - cy) ** 2; if (dd < bd) { bd = dd; best = i; } }
        bk = L.k; break;
      }
      if (best < 0) continue;
      const el = document.createElement('div'); el.className = 'lbl river'; el.hidden = true; host.appendChild(el);
      const mv = (v) => v >= 1 ? v.toFixed(2) + ' V' : (v * 1000).toFixed(v < 0.01 ? 1 : 0) + ' mV';
      el.textContent = n.switching ? `${n.name} ${n.switching.lowV || 0} ↔ ${n.switching.highV} V` : n.railV == null ? `${n.name} · rail not given` : n.loaded && n.dropV > 1e-6 ? `${n.name} ${n.railV} V · ${n.ground ? 'rises ' : ''}${mv(n.dropV)}${n.ground ? '' : ' drop'}` : `${n.name} ${n.railV} V`;
      signs.push({ el, k: bk, i: best, x: meta.x0 + ((best % W) + 0.5) * d, y: meta.y0 + (Math.floor(best / W) + 0.5) * d });
    }
  }
  // the parts' own currents: what each part takes from a net and gives to one, from the solve's terminals
  const chipTags = [];
  {
    const host = document.getElementById('labels'), byRef = {};
    for (const n of nets) for (const t of (n.terminals || [])) { const r = byRef[t.ref] = byRef[t.ref] || { x: 0, y: 0, k: 0, ins: {}, outs: {} }; r.x += t.x; r.y += t.y; r.k++; const side = t.role === 'sink' ? r.ins : r.outs; side[n.name] = (side[n.name] || 0) + t.amps; }
    const amp = (a) => a >= 1 ? a.toFixed(1) + ' A' : (a * 1000).toFixed(0) + ' mA';
    const list = (m) => Object.entries(m).sort((a, b) => b[1] - a[1]).map(([net, a]) => `${net} ${amp(a)}`).join(', ');
    for (const [ref, r] of Object.entries(byRef)) {
      const el = document.createElement('div'); el.className = 'lbl chip'; el.hidden = true; host.appendChild(el);
      const ins = list(r.ins), outs = list(r.outs);
      el.innerHTML = `<b>${ref}</b>${ins ? ` <span class="in">in ${ins}</span>` : ''}${outs ? ` <span class="out">out ${outs}</span>` : ''}`;
      const at = partAt[ref] || { x: r.x / r.k, y: r.y / r.k, i: cellOf(r.x / r.k, r.y / r.k) };
      chipTags.push({ el, ref, at, connector: /^(J|P|MC|MP|TP)\d/i.test(ref) });
    }
  }
  scene.onAfterRenderObservable.add(() => {
    // seen from low on the side (the Energy view's front turn), the tags all land in one band across the stack and hide the
    // field between the layers: they show only while the camera looks down on the board, from above about 25 degrees
    const edgeOn = camera.beta > 1.12 && camera.beta < Math.PI - 1.12;
    const show = (knob.flowOn > 0.5 || knob.river > 0.5) && knob.lightOn < 0.5 && state.view.chipTags !== false && !edgeOn;
    const e = engine, w = e.getRenderWidth(), h = e.getRenderHeight(), hostEl = document.getElementById('host'), wm = root.getWorldMatrix();
    for (const t of chipTags) {
      // in the river the nets carry their own signs: the connectors' tags would only crowd them
      if (!show || (knob.river > 0.5 && t.connector)) { t.el.hidden = true; continue; }
      const side = partSide[t.ref] === 'bottom' ? -1 : 1;
      let top = 0; const ms = scene.getTransformNodeByName('part-' + t.ref);
      if (ms) { for (const m of ms.getChildMeshes(false)) { const bb = m.getBoundingInfo().boundingBox; top = Math.max(top, (side > 0 ? bb.maximumWorld : bb.minimumWorld).z); } }
      const z0 = side > 0 ? zOf(0) + (t.at.i >= 0 ? heightAt(0, t.at.i) : 0) : zOf(LAYERS.length - 1);
      const local = new BAB.Vector3(bx(t.at.x), by(t.at.y), z0 + side * 2.2);
      const p = BAB.Vector3.Project(BAB.Vector3.TransformCoordinates(local, wm), BAB.Matrix.Identity(), scene.getTransformMatrix(), camera.viewport.toGlobal(w, h));
      t.el.hidden = p.z > 1 || p.z < 0 || (side < 0 && knob.explode < 0.3 && (state.view.layer || 'F.Cu') !== 'B.Cu');
      t.el.style.left = (p.x / (w / hostEl.clientWidth)) + 'px'; t.el.style.top = (p.y / (h / hostEl.clientHeight)) + 'px';
      void top;
    }
    for (const t of lightTags) {
      if (knob.lightOn < 0.5 || edgeOn) { t.el.hidden = true; continue; }
      const local = new BAB.Vector3(bx(t.a.x), by(t.a.y), zOf(0) + 2.2);
      const p = BAB.Vector3.Project(BAB.Vector3.TransformCoordinates(local, wm), BAB.Matrix.Identity(), scene.getTransformMatrix(), camera.viewport.toGlobal(w, h));
      t.el.hidden = p.z > 1 || p.z < 0; t.el.style.left = (p.x / (w / hostEl.clientWidth)) + 'px'; t.el.style.top = (p.y / (h / hostEl.clientHeight)) + 'px';
    }
    // tags that land on each other step down until they clear (top to bottom, one pass)
    const vis = chipTags.concat(lightTags).filter(t => !t.el.hidden).map(t => ({ t, r: t.el.getBoundingClientRect() })).sort((a, b) => a.r.top - b.r.top);
    const placed = [];
    for (const v of vis) {
      let dy = 0, r = v.r;
      for (let guard = 0; guard < 8; guard++) {
        const hit = placed.find(q => r.left < q.right && r.right > q.left && r.top + dy < q.bottom && r.bottom + dy > q.top);
        if (!hit) break; dy = hit.bottom - r.top + 2;
      }
      if (dy) v.t.el.style.top = (parseFloat(v.t.el.style.top) + dy) + 'px';
      placed.push({ left: r.left, right: r.right, top: r.top + dy, bottom: r.bottom + dy });
    }
  });
  scene.onAfterRenderObservable.add(() => {
    const on = knob.river > 0.5;
    if (!on) { for (const s of signs) s.el.hidden = true; return; }
    const e = engine, w = e.getRenderWidth(), h = e.getRenderHeight(), hostEl = document.getElementById('host');
    const wm = root.getWorldMatrix();
    for (const s of signs) {
      const show = knob.explode > 0.3 || s.k === LAYERS.indexOf(state.view.layer || 'F.Cu');
      const local = new BAB.Vector3(bx(s.x), by(s.y), zOf(s.k) + heightAt(s.k, s.i) + 0.6);
      const p = BAB.Vector3.Project(BAB.Vector3.TransformCoordinates(local, wm), BAB.Matrix.Identity(), scene.getTransformMatrix(), camera.viewport.toGlobal(w, h));
      s.el.hidden = !show || p.z > 1 || p.z < 0;
      s.el.style.left = (p.x / (w / hostEl.clientWidth)) + 'px'; s.el.style.top = (p.y / (h / hostEl.clientHeight)) + 'px';
    }
  });

  // ---- the frame ------------------------------------------------------------------------------------
  const ease = (cur, to, dt, rate) => { const k = 1 - Math.exp(-dt * rate); return Math.abs(to - cur) < 1e-3 ? to : cur + (to - cur) * k; };
  let fpsAcc = 0, fpsN = 0, tierChecked = false, sinceStart = 0;
  scene.onBeforeRenderObservable.add(() => {
    const dt = frameDt();
    const was = [knob.explode, knob.river];
    knob.explode = ease(knob.explode, knob.explodeTo, dt, 1.4);
    knob.river = ease(knob.river, knob.riverTo, dt, 1.1);
    knob.flowOn = ease(knob.flowOn, knob.flowOnTo, dt, 2.5);
    const wasE = knob.energyOn; knob.energyOn = ease(knob.energyOn, knob.energyTo, dt, 2.0);
    knob.lightOn = ease(knob.lightOn, knob.lightTo, dt, 2.0);
    const wasC = knob.chargeOn; knob.chargeOn = ease(knob.chargeOn, knob.chargeTo, dt, 2.0); if (knob.chargeOn !== wasC) applyStack();
    if (knob.chargeOn > 0.01) {
      if (!cCdf) buildChargeSpawn();
      for (const ct of chargeTex) { ct.mesh.setEnabled(true); ct.mesh.position.z = zOf(ct.k) + (ct.k === real[0] ? -0.03 : 0.03); ct.mat.alpha = knob.chargeOn * 0.9; if (!ct.painted) { paintCharge(ct, knob.tide, true); ct.painted = true; } else if (ct.sw.length) paintCharge(ct, knob.tide, false); }
    } else for (const ct of chargeTex) ct.mesh.setEnabled(false);
    for (const lm of lightMeshes) { const on = knob.lightOn > 0.01; lm.mesh.setEnabled(on); if (!on) continue; lm.mesh.position.z = zOf(lm.k) + (lm.k === 0 ? 0.02 : -0.02); lm.mat.setFloat('alpha', knob.lightOn * ((knob.explode > 0.3 || LAYERS[lm.k] === (state.view.layer || 'F.Cu')) ? 1 : 0.25)); lm.mat.setFloat('phase', ((knob.time % LIGHT_LOOP) / LIGHT_LOOP) * 1.25); }
    if (knob.explode !== was[0] || knob.river !== was[1] || knob.river > 0 || knob.energyOn !== wasE) applyStack();
    for (const t of terrains) {
      const on = knob.river > 0.01; t.mesh.setEnabled(on);
      if (!on) continue;
      t.mat.setFloat('tide', knob.tide); t.mat.setFloat('lift', knob.river); t.mat.setFloat('time', knob.time);
      t.mat.setVector3('camPos', camera.globalPosition || camera.position);
      const sel = state.view.layer || 'F.Cu';
      t.mat.setFloat('alpha', (knob.explode > 0.3 || t.L.name === sel) ? 0.94 * Math.min(1, knob.river * 1.4) : 0.0);
    }
    if (knob.lightOn > 0.01) knob.time += dt * knob.speed;
    lastDt = dt;   // the light view's own clock (no particles to step it)
    const active = knob.flowOn > 0.01 || knob.river > 0.01 || knob.energyOn > 0.01 || knob.chargeOn > 0.01;
    streak.setEnabled(active);
    if (!active) return;
    if (cdfNet === undefined || cdfNet !== (state.view.net || null)) buildSpawn(state.view.net || null);
    step(dt);
    if (knob.energyOn > 0.01) { if (!ecdf) buildEnergySpawn(); stepEnergy(dt); }
    upload();
    // the quality tier: a laptop that cannot hold 40 fps drops to fewer particles, once
    if (!ctx.clock.capture) { sinceStart += dt; fpsAcc += engine.getFps(); fpsN++; if (!tierChecked && sinceStart > 4) { tierChecked = true; const fps = fpsAcc / fpsN; if (fps < 38 && P > COUNTS.low) { P = Math.max(COUNTS.low, Math.floor(P * 0.5)); console.log(`flow: ${fps.toFixed(0)} fps, particles down to ${P}`); } else console.log(`flow: ${fps.toFixed(0)} fps with ${P} particles (${tier} tier)`); } }
  });

  // ---- what the badge and the legend say -------------------------------------------------------------
  // the key: what every thing on screen is, in plain words, so nobody has to guess
  const xf = exaggeration >= 100 ? Math.round(exaggeration).toLocaleString() : exaggeration.toFixed(0);
  // what each animated view says out loud: a template filled with this board's own numbers, so no AI
  // writes anything at view time and adom-tts caches the audio by its text
  const say = (x) => String(x).replace(/\bmm\b/g, 'millimetres');
  function narration(q) {
    if (q === 'charge') {
      const p0 = pairList[0], c0 = capParts[0], swp = pairList.find(p => nets[p.top].switching || nets[p.bot].switching);
      return `This is the Charge view: the board as capacitors. Wherever two pieces of copper at different voltages face each other across the board, they store charge, like the two plates of a capacitor. Red is positive charge on the higher-voltage copper, blue is the negative charge on the copper facing it, and the particles are the electric field crossing between them.${p0 ? ` The biggest one here is ${nets[p0.top].name} over ${nets[p0.bot].name}, about ${(p0.C * 1e12).toFixed(0)} picofarads.` : ''}${c0 ? ` That is tiny next to the capacitor parts: ${c0.ref} alone holds ${fmtC(c0.C).replace('µF', 'microfarads')}, millions of times more, which is why the circuit needs real capacitors.` : ''}${swp ? ` Watch ${nets[swp.top].switching ? nets[swp.top].name : nets[swp.bot].name}: its copper blinks, because it charges and empties every switching cycle, ${Math.round((nets.find(n => n.switching).switching.freqHz || 500000) / 1000)} thousand times a second. That stray capacitance is one of the ways switching noise couples into the rest of the board.` : ''}`;
    }
    const amps = nets.filter(n => n.loaded && !n.ground);
    if (q === 'flow') return `This is the Flow view. Every particle is current, moving the way the current flows, from plus to minus. The faster and brighter a particle, the more current is packed into each square millimetre of copper: up to ${Math.round((state.fields.scales && state.fields.scales.jMax) || jHi)} amps per square millimetre at the busiest spot on this board. The colour underneath is the same current density, so a bright yellow region is where the copper is working hardest. At a via, the share of particles that drop through it is the share of the current it carries. Each part's tag says the current it takes in and puts out. Use the layers button to pull the layers apart and watch the current move between them.`;
    if (q === 'river') {
      const rails = nets.filter(n => n.loaded && n.railV != null && !n.switching).sort((a, b) => b.railV - a.railV).map(n => `${n.name} at ${n.railV} volts`);
      const sw = nets.find(n => n.switching);
      return `This is the River view: the circuit as water. Height and colour are voltage, so every net sits at its own level${rails.length ? ': ' + rails.join(', ') : ''}. Ground is the sea. The water runs over each net, faster where the current is denser. Where current passes through a part down to ground, you see a waterfall, and where a source lifts it back up, a fountain.${sw ? ` ${sw.name} is a switching node: it rises and falls like a tide between ${sw.switching.lowV || 0} and ${sw.switching.highV} volts, slowed down so your eye can follow it.` : ''} Each net's sign gives its voltage and how much it drops across the board. The slopes are exaggerated so millivolts can be seen.`;
    }
    if (q === 'energy') return `This is the Energy view, and it shows something surprising. The pink particles are energy, and they are not in the copper. They travel through the board material between the copper layers, in the electric and magnetic fields around the conductors. The copper only guides them. Where the pink rises up into a part, that part is taking in its energy. The blue dots on the copper are the electrons themselves, at their real speed: only about ${(DRIFT * jHi).toFixed(1)} millimetres per second at the busiest spot, and moving backwards, against the direction of the current. So the electrons barely crawl, while the energy arrives at about half the speed of light.`;
    if (q === 'light') {
      const a = arrivals.slice().sort((x, y) => y.ps - x.ps)[0];
      return `This is the Light view. The colour is how fast a change travels around each piece of copper, as a percentage of the speed of light: about ${Math.round(100 / Math.sqrt(ER))} percent inside the board material, and a little faster on thin traces with air above them. The white front is a switch-on wave leaving each source. In real life it crosses this board in about ${Math.round(tMax)} picoseconds; here it is slowed down billions of times.${a ? ` The tags say when the wave reaches each part: ${a.net} reaches ${a.ref} ${Math.round(a.ps)} picoseconds after ${a.from || 'its source'}.` : ''} Each stretch of copper has its own speed, and the delay to a part is the sum of every stretch along the way.`;
    }
    void amps; return '';
  }
  function describe(q) {
    if (q === 'charge') return ['<b>Charge</b>: the board as capacitors',
      '<b>Red</b> = positive charge on the higher-voltage copper, <b>blue</b> = negative on the copper facing it; brighter = more charge per mm²',
      '<b>Particles</b> = the electric field, crossing the board from the + face to the − face',
      'A switching node blinks: its copper charges and empties every cycle (slowed to ' + TIDE_HZ + ' Hz)',
      'The panel: the copper\'s own capacitance, tiny, next to the capacitor parts',
      'Parallel plates, er = ' + ER + '; fringing at the edges and the air above are not counted'];
    if (q === 'light') return ['<b>Light</b>: how fast a change crosses the board',
      '<b>Colour</b> = local speed, % of the speed of light: deep blue inside the FR4 (about ' + Math.round(100 / Math.sqrt(ER)) + ' %), cyan to green on thin traces with air above them',
      '<b>The white front</b> = a switch-on wave leaving each source; it really crosses this board in about ' + tMax.toFixed(0) + ' ps, slowed here to ' + LIGHT_LOOP + ' s',
      '<b>Part tags</b> = when the front reaches that part',
      'Each stretch of copper has its own speed; the delay to a part is the sum along the way, not the slowest stretch',
      'er = ' + ER + ' (FR4); er_eff by Hammerstad for traces over a plane'];
    if (q === 'energy') return ['<b>Energy</b>: where the energy really travels',
      '<b>Pink particles</b> = energy, flowing through the board material between the copper layers (E × H)',
      '<b>Blue dots</b> = the electrons in the copper, at their real speed: ' + (DRIFT * jHi).toFixed(1) + ' mm/s at ' + jHi.toFixed(0) + ' A/mm², moving against the current',
      'The copper guides the energy; the energy itself travels in the fields around it, at about half the speed of light',
      '<b>Pink rising</b> into a part = that part taking its energy',
      'DC, drawn inside the board only: part of the energy travels in the air around it'];
    if (q === 'river') return ['<b>River</b>: the circuit as water',
      '<b>Height and colour</b> = voltage (1 V = ' + kR.toFixed(2) + ' mm)',
      '<b>Water speed and foam</b> = current density',
      '<b>Waterfall</b> = current dropping through a part to ground; <b>fountain</b> = a source lifting it back up',
      '<b>SW</b> rises and falls between its rails, slowed to ' + TIDE_HZ + ' Hz',
      'Slopes within a net exaggerated ×' + xf + ' so millivolts show'];
    return ['<b>Flow</b>: where the current goes',
      '<b>Each particle</b> = current, moving the way it flows (+ to −)',
      '<b>Faster and brighter</b> = more current per mm² of copper',
      '<b>Colour under them</b> = the same current density, A/mm²',
      '<b>Through a via</b>: the share of particles = the share of current',
      'Chip tags: the current each part takes in and puts out'];
  }
  function apply(view) {
    const q = view.quantity;
    knob.flowOnTo = q === 'flow' || q === 'river' || q === 'energy' ? 1 : 0;
    knob.energyTo = q === 'energy' ? 1 : 0; knob.electrons = q === 'energy'; knob.lightTo = q === 'light' ? 1 : 0; knob.chargeTo = q === 'charge' ? 1 : 0;
    chargePanel.hidden = q !== 'charge';
    knob.riverTo = q === 'river' ? 1 : 0;
    knob.explodeTo = Math.max(0, Math.min(1, +view.explode || 0));
    knob.speed = Math.max(0.1, Math.min(4, +view.speed || 1));
    if (q === 'flow') { for (const Lname of LAYERS) { const m = ctx.layerMeshes[Lname]; if (m) m.material.alpha = knob.explodeTo > 0.3 ? (lay[LAYERS.indexOf(Lname)].cuCells > 0.02 * N ? 0.3 : 0) : m.material.alpha * 0.3; } }
    if (q === 'river') { for (const Lname of LAYERS) { const m = ctx.layerMeshes[Lname]; if (m) m.material.alpha = 0; } }
    if (q === 'energy') { for (const Lname of LAYERS) { const m = ctx.layerMeshes[Lname]; if (m) m.material.alpha = real.includes(LAYERS.indexOf(Lname)) ? 0.16 : 0; } }
    if (q === 'light' || q === 'charge') { for (const Lname of LAYERS) { const m = ctx.layerMeshes[Lname]; if (m) m.material.alpha = 0; } }
    const badge = document.getElementById('flowBadge'); if (badge) { badge.hidden = !(q === 'flow' || q === 'river' || q === 'energy' || q === 'light' || q === 'charge'); const [head, ...rows] = describe(q); badge.innerHTML = `<div class="kh">${head}</div><ul>${rows.map(r => `<li>${r}</li>`).join('')}</ul>`;  badge.hidden = badge.hidden || !keyOn(); const kb = document.getElementById('btnKey'); if (kb) { kb.hidden = !(q === 'flow' || q === 'river' || q === 'energy' || q === 'light' || q === 'charge'); kb.classList.toggle('is-on', keyOn()); } }
    const leg = document.getElementById('legend'); leg.classList.remove('volts', 'amps', 'energy', 'light', 'charge');
    if (q === 'river') {
      leg.hidden = false; leg.classList.remove('magma'); leg.classList.add('volts');
      document.getElementById('legendTitle').textContent = 'Voltage, V: height and colour'; document.getElementById('legLo').textContent = '0'; document.getElementById('legHi').textContent = topRail.toFixed(topRail < 10 ? 1 : 0);
    } else if (q === 'charge') {
      leg.hidden = false; leg.classList.remove('magma'); leg.classList.add('charge');
      document.getElementById('legendTitle').textContent = 'Charge on the copper, C/mm²: + red, − blue'; document.getElementById('legLo').textContent = '−' + sigMax.toExponential(1); document.getElementById('legHi').textContent = '+' + sigMax.toExponential(1);
    } else if (q === 'light') {
      leg.hidden = false; leg.classList.remove('magma'); leg.classList.add('light');
      document.getElementById('legendTitle').textContent = 'Speed of a change, % of the speed of light'; document.getElementById('legLo').textContent = '40 %'; document.getElementById('legHi').textContent = '100 %';
    } else if (q === 'energy') {
      leg.hidden = false; leg.classList.remove('magma'); leg.classList.add('energy');
      document.getElementById('legendTitle').textContent = 'Energy flow, W/mm² (log): the pink particles\' glow'; document.getElementById('legLo').textContent = sLo.toFixed(3); document.getElementById('legHi').textContent = sHi.toFixed(2);
    } else if (q === 'flow') {
      document.getElementById('legendTitle').textContent = 'Current density, A/mm² (log): the map, and the particles\' speed and glow';
    }
  }
  // the key is off by default (the legend says what the colours are); the Key button shows it and remembers
  let keyShown = false; try { keyShown = localStorage.getItem('fields.keyOn') === '1'; } catch (e) { void e; }
  const keyOn = () => keyShown;
  document.addEventListener('click', (e) => { if (!e.target.closest('#btnKey') && !e.target.closest('#flowBadge .kh')) return; keyShown = !keyShown; try { localStorage.setItem('fields.keyOn', keyShown ? '1' : '0'); } catch (err) { void err; } apply(state.view); });
  function readout(Lname, x, y) {
    if (state.view.quantity === 'cycle') return '';
    if (state.view.quantity === 'charge') {
      const i = cellOf(x, y), k = LAYERS.indexOf(Lname); if (i < 0 || k < 0) return '';
      const id = lay[k].net[i]; if (id < 0) return 'no copper'; const s = sigmaAt(k, i, knob.tide);
      const G = gaps.find(g => g.a === k || g.b === k); const o = G ? (G.a === k ? G.b : G.a) : -1, oid = o >= 0 ? lay[o].net[i] : -1;
      return `${nets[id].name}${oid >= 0 && oid !== id ? ' facing ' + nets[oid].name : ''} · ${s >= 0 ? '+' : '−'}${Math.abs(s * 1e12).toFixed(3)} pC/mm²` + (oid >= 0 && oid !== id && G ? ` · field ${(Math.abs(railOf(id, knob.tide) - railOf(oid, knob.tide)) / G.h).toFixed(1)} V/mm` : '');
    }
    if (state.view.quantity === 'light') {
      const i = cellOf(x, y), k = LAYERS.indexOf(Lname); if (i < 0 || k < 0) return '';
      const pc = pctC[k * N + i], at = arrival[k * N + i], n = nets[lay[k].net[i]];
      if (!Number.isFinite(pc)) return 'no copper';
      return `${n ? n.name + ' · ' : ''}${(pc * 100).toFixed(1)} % of c (${(pc * C_MM_PS * 1000).toFixed(0)} mm/ns)${Number.isFinite(at) ? ` · reached ${at.toFixed(0)} ps after its source` : ''}`;
    }
    if (state.view.quantity === 'energy') {
      const i = cellOf(x, y); if (i < 0) return ''; const k = LAYERS.indexOf(Lname), L = lay[k] || lay[0];
      const G = gaps[0]; let txt = '';
      if (G) { const m = Math.hypot(G.Sx[i], G.Sy[i]); const top = nets[G.info[i * 2]], bot = nets[G.info[i * 2 + 1]]; txt = m > 0 ? `energy ${m.toFixed(2)} W/mm²` + (top && bot ? ` (${top.name} over ${bot.name}, ${(voltsAt(G.a, i) - voltsAt(G.b, i)).toFixed(1)} V across ${G.h.toFixed(2)} mm)` : '') : 'no energy flow here'; }
      const j = L ? Math.hypot(L.jx[i], L.jy[i]) : 0; if (j > 0) txt += ` · electrons ${(DRIFT * j).toFixed(3)} mm/s`;
      return txt;
    }
    const k = LAYERS.indexOf(Lname); if (k < 0) return '';
    const i = cellOf(x, y); if (i < 0) return '';
    const L = lay[k], id = L.net[i]; if (id < 0) return 'no copper';
    const n = nets[id], v = L.volt[i], m = Math.hypot(L.jx[i], L.jy[i]);
    const vtxt = n.switching ? `switching ${n.switching.lowV || 0} to ${n.switching.highV} V${n.switching.freqHz ? ' at ' + (n.switching.freqHz / 1000).toFixed(0) + ' kHz' : ''}` : Number.isFinite(v) ? (n.railV == null ? `${(v * 1000).toFixed(2)} mV of drop (rail unknown)` : `${v.toFixed(4)} V`) : 'rail unknown';
    return `${n.name} · ${vtxt}${m > 0 ? ` · ${m.toFixed(1)} A/mm²` : ''}`;
  }
  console.log(`flow: ${nets.filter(n => n.loaded).length} loaded nets, ${viaGaps.size} barrels carrying current, ${terrains.length} terrain layer(s) at stride ${stride}, ${P} particles (${tier}); river 1 V = ${kR.toFixed(3)} mm, slopes x${exaggeration.toFixed(0)}`);
  console.log(`light: er ${ER}, ${arrivals.length} part arrivals, the slowest ${tMax.toFixed(0)} ps; ` + arrivals.slice(0, 6).map(a => `${a.ref}/${a.net} ${a.ps.toFixed(0)} ps`).join(', '));
  console.log(`energy: ${gaps.length} gap(s) between copper layers ${real.map(k => LAYERS[k]).join(', ')}; S up to ${sHi.toFixed(2)} W/mm² (99.5th pct); electrons drift ${DRIFT.toFixed(4)} mm/s per A/mm²`);
  const api = { data: { lay, real, W, H, d, meta, cuMm, nets, zOf, cellOf, bx, by, ER }, /** how far the top-side parts ride up as the stack opens: the solid bodies (solids.js) follow the same rule */ topOffset: () => knob.explode * gap * 1.5, apply, readout, describe, narration, knob, get particles() { return P; }, set particles(n) { P = Math.max(500, Math.min(COUNTS.high, n | 0)); }, stats: () => ({ energyParticles: EP, gaps: gaps.length, sHi, particles: P, tier, jLo, jHi, kR, kD, exaggeration, terrains: terrains.length, barrels: viaGaps.size, explode: knob.explode, river: knob.river }) };
  return api;
}