main
John Lauer adom-fields 0.7.12: the Signals view in 3D (facing copper as glowing plates with the field between, one check at a time… 9b1e1da
// Adom Fields: the Signals view in 3D. The solve's own signal checks (fields.json signals), drawn where they are:
//  - capacitance: each checked pair of facing copper (net A on one layer, net B on the next) lights its real
//    overlap on both layers as two glowing plates, and the electric field crosses between them as sparks from
//    plate to plate; one check at a time, the others dim, its picofarads and overlap tagged at its centre;
//  - differential pairs: the two traces glow in two colours, tagged with their skew and impedance.
// The layers pull apart (the Signals button opens the stack) so the inner planes show.
export async function attach(ctx) {
  const { BAB, scene, V, camera, state, LAYERS, frameDt, root, flow } = ctx;
  if (!flow || !flow.data) return null;
  const { lay, W, H, d, meta, nets, zOf, bx, by } = flow.data;
  const sg = state.fields.signals || {};
  const caps = sg.capacitance || [], pairs = sg.differentialPairs || [];
  const idOf = (name) => nets.findIndex(n => n.name === name);
  const hues = [[0.35, 0.8, 1], [1, 0.55, 0.3], [0.45, 1, 0.55], [1, 0.4, 0.85], [1, 0.85, 0.3], [0.65, 0.5, 1], [0.3, 1, 0.9]];
  const hex = (c) => '#' + c.map(v => Math.round(v * 255).toString(16).padStart(2, '0')).join('');
  const group = new BAB.TransformNode('fields-signals', scene); group.parent = root; group.setEnabled(false);
  const host = document.getElementById('labels');
  const plate = (name, cells, rgb) => {
    const tex = new Uint8Array(W * H * 4);
    for (const i of cells) { const o = i * 4; tex[o] = 255; tex[o + 1] = 255; tex[o + 2] = 255; tex[o + 3] = 255; }
    const t = new BAB.RawTexture(tex, W, H, BAB.Constants.TEXTUREFORMAT_RGBA, scene, false, false, BAB.Texture.NEAREST_SAMPLINGMODE, BAB.Constants.TEXTURETYPE_UNSIGNED_BYTE);
    const p = BAB.MeshBuilder.CreatePlane(name, { width: W * d, height: H * d, sideOrientation: BAB.Mesh.DOUBLESIDE }, scene);
    const uvs = p.getVerticesData(BAB.VertexBuffer.UVKind); for (let i = 1; i < uvs.length; i += 2) uvs[i] = 1 - uvs[i]; p.setVerticesData(BAB.VertexBuffer.UVKind, uvs);
    p.position.x = bx(meta.x0 + W * d / 2); p.position.y = by(meta.y0 + H * d / 2);
    const m = new BAB.StandardMaterial(name + '-m', scene); m.diffuseTexture = t; t.hasAlpha = true; m.useAlphaFromDiffuseTexture = true; m.diffuseColor = new BAB.Color3(0, 0, 0); m.emissiveColor = new BAB.Color3(...rgb); m.disableLighting = true; m.backFaceCulling = false;
    p.material = m; p.parent = group; p.renderingGroupId = 2; p.isPickable = false; return { p, m };
  };
  // ---- the capacitance checks: two plates and the field between ------------------------------------------
  const checks = caps.map((c, ci) => {
    const df = c.definition || {}, ka = LAYERS.indexOf(df.layerA), kb = LAYERS.indexOf(df.layerB), ia = idOf(df.netA), ib = idOf(df.netB), rgb = hues[ci % hues.length];
    const cells = [];
    if (ka >= 0 && kb >= 0 && ia >= 0 && ib >= 0 && lay[ka] && lay[kb]) for (let i = 0; i < W * H; i++) if (lay[ka].net[i] === ia && lay[kb].net[i] === ib) cells.push(i);
    const a = plate('sig-a-' + ci, cells, rgb), b = plate('sig-b-' + ci, cells, rgb.map(v => v * 0.75));
    // the sparks: points spread over the overlap, each crossing from plate A to plate B on its own phase
    const n = Math.min(700, Math.max(cells.length ? 40 : 0, Math.round(cells.length / 6)));
    const sx = new Float32Array(n), sy = new Float32Array(n), ph = new Float32Array(n);
    for (let k = 0; k < n; k++) { const i = cells[Math.floor(((Math.sin(k * 12.9898 + ci) * 43758.5453) % 1 + 1) % 1 * cells.length)]; sx[k] = meta.x0 + ((i % W) + 0.5) * d; sy[k] = meta.y0 + (Math.floor(i / W) + 0.5) * d; ph[k] = ((Math.sin(k * 78.233 + ci * 3.1) * 12543.17) % 1 + 1) % 1; }
    let dots = null, buf = null;
    if (n) {
      dots = BAB.MeshBuilder.CreateSphere('sig-dots-' + ci, { diameter: 1, segments: 4 }, scene);
      const dm = new BAB.StandardMaterial('sig-dots-m' + ci, scene); dm.emissiveColor = new BAB.Color3(...rgb.map(v => Math.min(1, v * 0.4 + 0.6))); dm.disableLighting = true; dots.material = dm;
      buf = new Float32Array(n * 16); dots.thinInstanceSetBuffer('matrix', buf, 16, false); dots.parent = group; dots.renderingGroupId = 2; dots.isPickable = false; dots.alwaysSelectAsActiveMesh = true;
    }
    let cxs = 0, cys = 0; for (const i of cells) { cxs += (i % W) + 0.5; cys += Math.floor(i / W) + 0.5; }
    const cen = cells.length ? [meta.x0 + cxs / cells.length * d, meta.y0 + cys / cells.length * d] : [meta.x0 + W * d / 2, meta.y0 + H * d / 2];
    const tag = document.createElement('div'); tag.className = 'lbl signals'; tag.hidden = true; host.appendChild(tag);
    return { c, df, ka, kb, rgb, cells, a, b, dots, buf, n, sx, sy, ph, cen, tag, on: 0 };
  });
  // ---- differential pairs: the two traces in two colours ---------------------------------------------------
  const prs = pairs.map((p, pi) => {
    const k = LAYERS.indexOf(p.definition ? p.definition.layer : p.layer), ip = idOf(p.definition ? p.definition.positive : p.positive), inn = idOf(p.definition ? p.definition.negative : p.negative);
    const cp = [], cn = []; if (k >= 0 && lay[k]) for (let i = 0; i < W * H; i++) { if (lay[k].net[i] === ip) cp.push(i); else if (lay[k].net[i] === inn) cn.push(i); }
    const P = plate('sig-pp-' + pi, cp, [1, 0.35, 0.35]), N = plate('sig-pn-' + pi, cn, [0.35, 0.6, 1]);
    const all = cp.concat(cn); const cen = all.length ? [meta.x0 + all.reduce((s, i) => s + (i % W) + 0.5, 0) / all.length * d, meta.y0 + all.reduce((s, i) => s + Math.floor(i / W) + 0.5, 0) / all.length * d] : [0, 0];
    const tag = document.createElement('div'); tag.className = 'lbl signals'; tag.hidden = true; host.appendChild(tag);
    return { p, k, P, N, cen, tag };
  });
  // ---- the panel ---------------------------------------------------------------------------------------
  const pf = (v) => v >= 1000 ? (v / 1000).toFixed(2) + ' nF' : v >= 10 ? v.toFixed(0) + ' pF' : v.toFixed(1) + ' pF';
  const panel = document.createElement('div'); panel.className = 'cyclemeter signalspanel'; panel.hidden = true; document.querySelector('.stage').appendChild(panel);
  const sel = { i: 0, picked: false, t: 0 };
  panel.innerHTML = !checks.length && !prs.length ? `<div class="ch">Signals</div><div class="body"><div class="row">No signal checks in this solve: add signals.capacitance or signals.differentialPairs to the spec.</div></div>`
    : `<div class="ch" title="Click to fold">Signals</div><div class="body">
      ${checks.length ? `<div class="row" style="color:var(--text-2)">Capacitance between facing copper</div>` : ''}
      ${checks.map((x, i) => `<div class="row sigrow" data-i="${i}" style="cursor:pointer"><span class="dot" style="background:${hex(x.rgb)}"></span>${x.c.name}<i>${pf(x.c.capacitancePf)}</i></div>`).join('')}
      ${prs.map(x => `<div class="row"><span class="dot" style="background:#ff6b6b"></span>${x.p.name}<i>skew ${x.p.skewMm != null ? x.p.skewMm.toFixed(2) + ' mm' : '?'}${x.p.differentialOhms ? ' · ' + x.p.differentialOhms.toFixed(0) + ' Ω' : ''}</i></div>`).join('')}
      <div class="row" style="color:var(--text-2);margin-top:6px">Parallel-plate estimates: no fringe fields, no frequency dependence</div>
    </div>`;
  panel.addEventListener('click', (e) => { if (e.target.closest('.ch')) panel.classList.toggle('folded'); const r = e.target.closest('.sigrow'); if (r) { sel.i = +r.dataset.i; sel.picked = true; sel.t = 0; } });
  // ---- frame -------------------------------------------------------------------------------------------
  const knob = { on: 0, to: 0, t: 0 };
  scene.onBeforeRenderObservable.add(() => {
    const dt = frameDt();
    knob.on += (knob.to - knob.on) * (1 - Math.exp(-dt * 2.5)); if (Math.abs(knob.to - knob.on) < 1e-3) knob.on = knob.to;
    const live = knob.on > 0.01; group.setEnabled(live);
    if (!live) { for (const x of checks) x.tag.hidden = true; for (const x of prs) x.tag.hidden = true; return; }
    const sp = +state.view.speed || 1; knob.t += dt * sp; sel.t += dt * sp;
    // one check at a time, seven seconds each, unless one was picked
    if (!sel.picked && checks.length && sel.t > 7) { sel.t = 0; sel.i = (sel.i + 1) % checks.length; }
    const e = V.getEngine(), w = e.getRenderWidth(), h = e.getRenderHeight(), hostEl = document.getElementById('host'), wm = group.getWorldMatrix();
    const place = (el, x, y, z) => { const p = BAB.Vector3.Project(BAB.Vector3.TransformCoordinates(new BAB.Vector3(bx(x), by(y), z), wm), BAB.Matrix.Identity(), scene.getTransformMatrix(), camera.viewport.toGlobal(w, h)); el.hidden = p.z > 1 || p.z < 0; el.style.left = (p.x / (w / hostEl.clientWidth)) + 'px'; el.style.top = (p.y / (h / hostEl.clientHeight)) + 'px'; };
    checks.forEach((x, i) => {
      const act = i === sel.i; x.on += ((act ? 1 : 0) - x.on) * (1 - Math.exp(-dt * 4));
      const za = zOf(x.ka) + (x.ka < x.kb ? -0.02 : 0.02), zb = zOf(x.kb) + (x.kb < x.ka ? -0.02 : 0.02);
      x.a.p.position.z = za; x.b.p.position.z = zb;
      const glow = 0.75 + 0.25 * Math.sin(knob.t * 3);
      x.a.m.alpha = knob.on * (0.08 + 0.8 * x.on * glow); x.b.m.alpha = knob.on * (0.06 + 0.7 * x.on * glow);
      if (x.dots) {
        x.dots.setEnabled(x.on > 0.05);
        const s = 0.16 + 0.12 * x.on;
        for (let k = 0; k < x.n; k++) { const t = (x.ph[k] + knob.t * 0.45) % 1, o = k * 16; x.buf[o] = s; x.buf[o + 5] = s; x.buf[o + 10] = s; x.buf[o + 15] = 1; x.buf[o + 12] = bx(x.sx[k]); x.buf[o + 13] = by(x.sy[k]); x.buf[o + 14] = za + (zb - za) * t; }
        x.dots.thinInstanceBufferUpdated('matrix'); x.dots.visibility = x.on * knob.on;
      }
      x.tag.innerHTML = `<b>${x.c.name}</b> <span class="v">${pf(x.c.capacitancePf)}</span> · ${x.c.overlapMm2.toFixed(0)} mm² · ${x.df.dielectricMm} mm`;
      if (act) place(x.tag, x.cen[0], x.cen[1], Math.max(za, zb) + 1.2); else x.tag.hidden = true;
    });
    for (const x of prs) { const z = zOf(x.k) + 0.03; x.P.p.position.z = z; x.N.p.position.z = z; x.P.m.alpha = x.N.m.alpha = knob.on * (0.6 + 0.3 * Math.sin(knob.t * 3)); x.tag.innerHTML = `<b>${x.p.name}</b> skew ${x.p.skewMm != null ? x.p.skewMm.toFixed(2) + ' mm' : '?'}${x.p.differentialOhms ? ' · ' + x.p.differentialOhms.toFixed(0) + ' Ω' : ''}`; place(x.tag, x.cen[0], x.cen[1], z + 1); }
  });
  function apply(view) {
    knob.to = view.quantity === 'signals' ? 1 : 0; panel.hidden = view.quantity !== 'signals';
    if (view.quantity !== 'signals') return;
    if (view.net) { const i = checks.findIndex(x => x.df.netA === view.net || x.df.netB === view.net); if (i >= 0) { sel.i = i; sel.picked = true; } }
    for (const Lname of LAYERS) { const m = ctx.layerMeshes[Lname]; if (m && m.material) m.material.alpha = 0.12; }
  }
  function describe() {
    return ['<b>Signals</b>: copper facing copper across the board as capacitors, and the differential pairs',
      '<b>Two glowing plates</b> = one checked overlap: net A on its layer, net B on the next, the same shape on both',
      '<b>Sparks crossing between them</b> = the electric field through the board material; one check at a time, the panel picks one',
      'Picofarads from the overlap area, the dielectric thickness and εr (parallel plate, no fringe fields)'];
  }
  function narration() {
    if (!checks.length && !prs.length) return 'This is the Signals view, but this solve has no signal checks: add capacitance or differential pair checks to the spec.';
    const say = (v) => v >= 1000 ? `${(v / 1000).toFixed(1)} nanofarads` : `${Math.round(v)} picofarads`;
    const big = checks.slice().sort((a, b) => b.c.capacitancePf - a.c.capacitancePf)[0];
    const sw = checks.filter(x => /(^|\/)(SW|SH[A-C]?|DRV_SH|PHASE)/i.test(x.df.netA + ' ' + x.df.netB) || nets.some(n => n.switching && (n.name === x.df.netA || n.name === x.df.netB)));
    const plane = checks.find(x => /In\d/.test(x.df.layerA) && /In\d/.test(x.df.layerB));
    let t = `This is the Signals view: copper as capacitors. Wherever copper on one layer faces copper on the next, the two pieces are the plates of a capacitor, and the electric field crosses the board material between them. Each pair of glowing plates is one check, the sparks are that field, and the layers are pulled apart so the inner copper shows.`;
    if (big) t += ` The biggest here is ${big.c.name.replace(/:.*$/, '')}: about ${say(big.c.capacitancePf)} over ${Math.round(big.c.overlapMm2)} square millimetres, through ${Math.round(big.df.dielectricMm * 100) / 100} millimetres of board.`;
    if (plane && plane !== big) t += ` The two inner planes make ${say(plane.c.capacitancePf)} between them, a little free decoupling at very high frequencies, small because the core between them is thick.`;
    else if (plane) t += ` That is the two inner planes: a little free decoupling at very high frequencies, small because the core between them is thick.`;
    if (sw.length) t += ` The switching copper matters most: ${sw.map(x => `${x.c.name.replace(/ switch node.*$/i, '').replace(/:.*$/, '')} has ${say(x.c.capacitancePf)}`).join(', ')} to the ground plane under it. Every switching edge has to charge and empty that capacitance, so it costs a little energy each cycle and pushes a spike of current into ground; a smaller switch-node area keeps it down.`;
    if (prs.length) t += ` The differential pairs glow red and blue, tagged with their length mismatch and their impedance.`;
    return t + ' These are parallel-plate estimates, a screen that shows where the coupling is.';
  }
  console.log(`signals: ${checks.map(x => `${x.c.name} ${x.cells.length} cells`).join(', ')}`);
  return { apply, describe, narration };
}