app
Adom Fields
Public Made by Adomby adom
Adom Fields: where the current and the heat go on your board.
main
John Lauer
adom-fields 0.7.4: the Magnetic view, the field of every current, and its video
5931f32
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190
// Adom Fields: the magnetic field of the board's currents.
// Every current makes a magnetic field that wraps around it (Biot-Savart: dB = mu0/4pi I dl x r / r^3).
// The solved DC current on every cell of copper, and the current down every via, are summed onto a
// coarse grid of current elements; the field is then evaluated anywhere above or around the board:
// field lines traced in 3D for blue particles to ride, and a probe plane at a chosen height coloured by
// |B| the way a near-field probe would see it. DC: the switching field comes with the switching currents.
export async function attach(ctx) {
const { BAB, scene, V, camera, state, LAYERS, frameDt, root, flow } = ctx;
if (!flow || !flow.data) return null;
const { lay, real, W, H, d, meta, cuMm, nets, zOf, bx, by } = flow.data;
const board = state.fields.board;
// ---- the current elements: I·dl (A·mm) summed onto a grid of about half a millimetre --------------
const G = Math.max(1, Math.round(0.5 / d)), gw = Math.ceil(W / G), gh = Math.ceil(H / G);
const src = []; // {x, y, z, ix, iy, iz} in board mm (KiCad x right, y down), z up
for (const k of real) {
const L = lay[k], ax = new Float64Array(gw * gh), ay = new Float64Array(gw * gh);
for (let i = 0; i < W * H; i++) { if (L.jx[i] === 0 && L.jy[i] === 0) continue; const c = Math.floor((i % W) / G), r = Math.floor(Math.floor(i / W) / G), g = r * gw + c; const a = cuMm[k] * d * d; ax[g] += L.jx[i] * a; ay[g] += L.jy[i] * a; }
const z = zOf(k);
for (let g = 0; g < gw * gh; g++) { if (Math.hypot(ax[g], ay[g]) < 1e-6) continue; src.push({ x: meta.x0 + ((g % gw) + 0.5) * G * d, y: meta.y0 + (Math.floor(g / gw) + 0.5) * G * d, z, ix: ax[g], iy: ay[g], iz: 0 }); }
}
// the vias: the current down each barrel between the layers it joins (z up, so downward current is -z)
for (const n of nets) for (const v of (n.vias || [])) { const za = zOf(LAYERS.indexOf(v.from)), zb = zOf(LAYERS.indexOf(v.to)); src.push({ x: v.x, y: v.y, z: (za + zb) / 2, ix: 0, iy: 0, iz: v.amps * (zb - za) }); }
const S = src.length, SX = new Float64Array(S), SY = new Float64Array(S), SZ = new Float64Array(S), IX = new Float64Array(S), IY = new Float64Array(S), IZ = new Float64Array(S);
// board frame for the maths: x right, y UP (flip KiCad's y), z up; then B comes out in the same frame
src.forEach((s, i) => { SX[i] = s.x; SY[i] = -s.y; SZ[i] = s.z; IX[i] = s.ix; IY[i] = -s.iy; IZ[i] = s.iz; });
const SOFT = (G * d * 0.5) ** 2; // a soft core the size of a grid cell, so the field stays finite on the copper
// B in µT at a point (board mm, y up): 1e-7 T·m/A × 1000 (mm) → µT ×100 per A/mm
function B(x, y, z) {
let bx_ = 0, by_ = 0, bz_ = 0;
for (let i = 0; i < S; i++) {
const rx = x - SX[i], ry = y - SY[i], rz = z - SZ[i], r2 = rx * rx + ry * ry + rz * rz + SOFT, inv = 1 / (r2 * Math.sqrt(r2));
bx_ += (IY[i] * rz - IZ[i] * ry) * inv; by_ += (IZ[i] * rx - IX[i] * rz) * inv; bz_ += (IX[i] * ry - IY[i] * rx) * inv;
}
return [bx_ * 100, by_ * 100, bz_ * 100];
}
const EARTH = 50; // µT
// ---- the probe plane: |B| at a height, the board plus a margin, coloured by strength ----------------
const margin = 4, PW = board.widthMm + 2 * margin, PH = board.heightMm + 2 * margin, res = Math.max(0.25, Math.max(PW, PH) / 200);
const pw = Math.ceil(PW / res), ph = Math.ceil(PH / res);
const ptex = new Uint8Array(pw * ph * 4), pval = new Float32Array(pw * ph);
const tex = new BAB.RawTexture(ptex, pw, ph, BAB.Constants.TEXTUREFORMAT_RGBA, scene, false, false, BAB.Texture.BILINEAR_SAMPLINGMODE, BAB.Constants.TEXTURETYPE_UNSIGNED_BYTE);
const plane = BAB.MeshBuilder.CreatePlane('fields-probe', { width: PW, height: PH, sideOrientation: BAB.Mesh.DOUBLESIDE }, scene);
{ const uvs = plane.getVerticesData(BAB.VertexBuffer.UVKind); for (let i = 1; i < uvs.length; i += 2) uvs[i] = 1 - uvs[i]; plane.setVerticesData(BAB.VertexBuffer.UVKind, uvs); }
const pm = new BAB.StandardMaterial('fields-probe-m', scene); pm.diffuseTexture = tex; tex.hasAlpha = true; pm.useAlphaFromDiffuseTexture = true; pm.emissiveColor = new BAB.Color3(1, 1, 1); pm.disableLighting = true; pm.backFaceCulling = false;
plane.material = pm; plane.parent = root; plane.isPickable = true; plane.renderingGroupId = 1; plane.setEnabled(false);
let probeAt = null, bMax = 1, bMin = 0.01, computing = false;
const ramp = [[10, 8, 40], [60, 30, 140], [40, 110, 230], [70, 210, 255], [235, 250, 255]];
async function paintProbe(h) {
if (computing) return; computing = true;
const zTop = zOf(real[0]), z = zTop + h; let mx = 0;
for (let r = 0; r < ph; r++) {
for (let c = 0; c < pw; c++) { const x = meta.x0 - margin + (c + 0.5) * res, y = meta.y0 - margin + (r + 0.5) * res; const b = B(x, -y, z); const m = Math.hypot(b[0], b[1], b[2]); pval[r * pw + c] = m; if (m > mx) mx = m; }
if (r % 20 === 19) await new Promise(res2 => setTimeout(res2, 0)); // keep the page breathing
}
bMax = mx || 1; bMin = bMax / 300;
for (let i = 0; i < pw * ph; i++) {
const t = pval[i] <= bMin ? 0 : Math.min(1, Math.log(pval[i] / bMin) / Math.log(bMax / bMin)), s = t * (ramp.length - 1), k = Math.min(ramp.length - 2, Math.floor(s)), f = s - k;
for (let ch = 0; ch < 3; ch++) ptex[i * 4 + ch] = Math.round(ramp[k][ch] + (ramp[k + 1][ch] - ramp[k][ch]) * f);
// thin contour lines every power of ten of µT, so the fall-off with height reads at a glance
const lg = Math.log10(Math.max(1e-6, pval[i])), contour = t > 0.15 && Math.abs(lg - Math.round(lg)) < 0.006 ? 1 : 0;
ptex[i * 4 + 3] = Math.round(Math.min(255, (t < 0.3 ? 0 : 230 * ((t - 0.3) / 0.7) ** 1.3) + 110 * contour));
if (contour) { ptex[i * 4] = 255; ptex[i * 4 + 1] = 255; ptex[i * 4 + 2] = 255; }
}
tex.update(ptex); probeAt = h; computing = false;
legend();
}
// ---- field lines: traced once, followed by particles ----------------------------------------------
// traced only when the view is first opened, a line at a time, so the page never stalls
const lines = []; let traced = false, tracing = false, sMax = 1e-9;
async function traceLines() {
tracing = true;
const order = src.map((s, i) => [Math.hypot(s.ix, s.iy, s.iz), i]).sort((a, b) => b[0] - a[0]);
const pick = []; for (const [m, i] of order) { if (pick.length >= 48) break; if (pick.every(j => Math.hypot(SX[j] - SX[i], SY[j] - SY[i]) > 1.4)) pick.push(i); }
for (const i of pick) for (const off of [0.35, 0.9]) {
let p = [SX[i], SY[i], SZ[i] + off];
const pts = [p.slice()]; const step = 0.12;
for (let n = 0; n < 360; n++) {
const b1 = B(p[0], p[1], p[2]), m1 = Math.hypot(...b1) || 1e-9;
const mid = [p[0] + b1[0] / m1 * step / 2, p[1] + b1[1] / m1 * step / 2, p[2] + b1[2] / m1 * step / 2];
const b2 = B(mid[0], mid[1], mid[2]), m2 = Math.hypot(...b2) || 1e-9;
p = [p[0] + b2[0] / m2 * step, p[1] + b2[1] / m2 * step, p[2] + b2[2] / m2 * step];
pts.push(p.slice());
if (n > 20 && Math.hypot(p[0] - pts[0][0], p[1] - pts[0][1], p[2] - pts[0][2]) < step * 1.5) break; // closed the loop
if (Math.abs(p[2] - SZ[i]) > 6 || Math.abs(p[0] - SX[i]) > 8 || Math.abs(p[1] - SY[i]) > 8) break;
}
if (pts.length > 12) { const strength = Math.hypot(...B(pts[0][0], pts[0][1], pts[0][2])); lines.push({ pts, strength }); sMax = Math.max(sMax, strength); }
await new Promise(r => setTimeout(r, 0));
}
makeLines();
traced = true; tracing = false;
console.log(`magnetic: ${lines.length} field lines traced`);
}
// ---- the lines themselves: GreasedLine, glowing, dashes running along the field's direction ---------
let lineMeshes = [];
function makeLines() {
try {
// three strength bands, brightest for the strongest field, so the eye finds the hot loop
const bands = [[], [], []];
for (const l of lines) { const t = Math.sqrt(l.strength / sMax); bands[t > 0.6 ? 2 : t > 0.3 ? 1 : 0].push(l.pts.flatMap(p => [wx(p[0]), wy(p[1]), p[2]])); }
const cols = [new BAB.Color3(0.22, 0.35, 0.85), new BAB.Color3(0.35, 0.62, 1.0), new BAB.Color3(0.75, 0.9, 1.0)];
bands.forEach((pts, b) => {
if (!pts.length) return;
const m = BAB.CreateGreasedLine('fields-mag-lines-' + b, { points: pts }, { color: cols[b], width: 0.05 + 0.03 * b, useDash: true, dashCount: 40, dashRatio: 0.55, sizeAttenuation: true }, scene);
m.parent = root; m.isPickable = false; m.renderingGroupId = 1; m.setEnabled(false);
try { const g = scene.effectLayers && scene.effectLayers.find(l => l.name === 'fields-flow-glow'); if (g) g.addIncludedOnlyMesh(m); } catch (e) { void e; }
lineMeshes.push(m);
});
console.log(`magnetic: ${lines.length} field lines drawn in ${lineMeshes.length} bands`);
} catch (e) { console.warn('magnetic: no GreasedLine, particles only: ' + (e && e.message || e)); }
}
// ---- the particles riding the lines (the flow view's shader) ------------------------------------
const PER = 18, CAP = 96 * PER;
const IP = new Float32Array(CAP * 4), IV = new Float32Array(CAP * 4), IC = new Float32Array(CAP * 4);
const mesh = new BAB.Mesh('fields-magnetic-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(mesh, false); }
const engine = V.getEngine();
const vbP = new BAB.VertexBuffer(engine, IP, 'ip', true, false, 4, true), vbV = new BAB.VertexBuffer(engine, IV, 'iv', true, false, 4, true), vbC = new BAB.VertexBuffer(engine, IC, 'ic', true, false, 4, true);
mesh.setVerticesBuffer(vbP); mesh.setVerticesBuffer(vbV); mesh.setVerticesBuffer(vbC);
mesh.forcedInstanceCount = CAP; mesh.alwaysSelectAsActiveMesh = true; mesh.isPickable = false; mesh.parent = root; mesh.renderingGroupId = 1; mesh.setEnabled(false);
const mat = new BAB.ShaderMaterial('fields-magnetic-streak', scene, { vertex: 'fieldsStreak', fragment: 'fieldsStreak' }, { attributes: ['position', 'ip', 'iv', 'ic'], uniforms: ['world', 'viewProjection', 'camPos', 'trail', 'width'], needAlphaBlending: true });
mat.alphaMode = BAB.Constants.ALPHA_ADD; mat.disableDepthWrite = true; mat.backFaceCulling = false; mat.setFloat('trail', 0.3); mat.setFloat('width', 1.0);
mesh.material = mat;
try { const g = scene.effectLayers && scene.effectLayers.find(l => l.name === 'fields-flow-glow'); if (g) { g.addIncludedOnlyMesh(mesh); g.referenceMeshToUseItsOwnMaterial(mesh); } } catch (e) { void e; }
const size = Math.max(0.05, Math.hypot(board.widthMm, board.heightMm) * 0.0035);
const phase = new Float32Array(CAP); for (let i = 0; i < CAP; i++) phase[i] = (i % PER) / PER + Math.random() * 0.05;
// world: board mm (y up in the maths) to the scene's board frame
const wx = (x) => bx(x), wy = (y) => by(-y);
function frame(dt) {
for (let i = lines.length * PER; i < CAP; i++) IV[i * 4 + 3] = 0;
for (let li = 0; li < Math.min(lines.length, 96); li++) {
const l = lines[li], n = l.pts.length, t = Math.sqrt(l.strength / sMax);
for (let k = 0; k < PER; k++) {
const i = li * PER + k, o = i * 4;
phase[i] = (phase[i] + dt * (0.04 + 0.12 * t) * (+state.view.speed || 1)) % 1;
const u = phase[i] * (n - 1), a = Math.floor(u), f = u - a, p = l.pts[a], q = l.pts[Math.min(n - 1, a + 1)];
const x = p[0] + (q[0] - p[0]) * f, y = p[1] + (q[1] - p[1]) * f, z = p[2] + (q[2] - p[2]) * f;
IP[o] = wx(x); IP[o + 1] = wy(y); IP[o + 2] = z; IP[o + 3] = size;
IV[o] = (q[0] - p[0]) * 20; IV[o + 1] = (q[1] - p[1]) * 20; IV[o + 2] = (q[2] - p[2]) * 20; IV[o + 3] = knob.on * (lineMeshes.length ? 0.1 + 0.35 * t : 0.18 + 0.7 * t);
IC[o] = 0.35 + 0.4 * t; IC[o + 1] = 0.6 + 0.3 * t; IC[o + 2] = 1; IC[o + 3] = 1;
}
}
vbP.update(IP); vbV.update(IV); vbC.update(IC);
mat.setVector3('camPos', camera.globalPosition || camera.position);
}
// ---- the view ---------------------------------------------------------------------------------------
const knob = { on: 0, to: 0 };
const probeH = () => Math.max(0.5, Math.min(15, +state.view.probe || 2));
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; mesh.setEnabled(live); plane.setEnabled(live && probeAt !== null);
for (const m of lineMeshes) { m.setEnabled(live); if (live && m.greasedLineMaterial) { m.greasedLineMaterial.dashOffset = (m.greasedLineMaterial.dashOffset || 0) - dt * 0.12 * (+state.view.speed || 1); m.visibility = knob.on; } }
if (!live) return;
if (probeAt !== probeH() && !computing) paintProbe(probeH());
if (!traced && !tracing && probeAt !== null) traceLines();
plane.position.z = zOf(real[0]) + (probeAt ?? probeH()); pm.alpha = knob.on * 0.75;
frame(dt);
});
function legend() {
if (state.view.quantity !== 'magnetic') return;
const leg = document.getElementById('legend'); if (!leg) return; leg.hidden = false; leg.classList.remove('magma'); leg.classList.add('magnetic');
document.getElementById('legendTitle').textContent = `Magnetic field at ${probeH().toFixed(1)} mm, µT (log)`; document.getElementById('legLo').textContent = bMin.toFixed(2); document.getElementById('legHi').textContent = bMax.toFixed(bMax < 10 ? 1 : 0);
}
function apply(view) {
knob.to = view.quantity === 'magnetic' ? 1 : 0;
const ctl = document.getElementById('probeCtl'); if (ctl) { ctl.hidden = view.quantity !== 'magnetic'; const inp = document.getElementById('probeH'); if (inp && document.activeElement !== inp) inp.value = probeH(); const lab = document.getElementById('probeVal'); if (lab) lab.textContent = probeH().toFixed(1) + ' mm'; }
if (view.quantity !== 'magnetic') { const leg = document.getElementById('legend'); if (leg) leg.classList.remove('magnetic'); return; }
for (const Lname of LAYERS) { const m = ctx.layerMeshes[Lname]; if (m && m.material) m.material.alpha = Lname === 'F.Cu' ? 0.35 : 0; }
legend();
}
function readout(Lname, x, y) {
const h = probeH(), b = B(x, -y, zOf(real[0]) + h), m = Math.hypot(...b);
return `${h.toFixed(1)} mm above: ${m.toFixed(m < 10 ? 2 : 1)} µT (${(m / EARTH * 100).toFixed(0)} % of Earth's field), H ${(m / 1.2566).toFixed(1)} A/m`;
}
function describe() {
return ['<b>Magnetic</b>: the field every current makes',
'<b>Blue lines</b> = magnetic field lines, looping around the currents; the dashes run the way the field points, brighter where it is stronger',
'<b>The coloured sheet</b> = the field strength at a height above the board, like a near-field probe; the slider moves it (white rings mark each tenfold step)',
'Earth\'s own field is about 50 µT, for scale',
'DC: the steady currents. The switching field comes with the EMC loop views'];
}
function narration() {
const h = probeH(), top = (() => { let best = 0, bi = 0; for (let i = 0; i < pval.length; i++) if (pval[i] > best) { best = pval[i]; bi = i; } return { b: best, x: meta.x0 - margin + ((bi % pw) + 0.5) * res, y: meta.y0 - margin + (Math.floor(bi / pw) + 0.5) * res }; })();
const at = (() => { let best = null, bd = 1e9; for (const n of nets) for (const t of (n.terminals || [])) { const dd = Math.hypot(t.x - top.x, t.y - top.y); if (dd < bd) { bd = dd; best = t.ref; } } return best; })();
return `This is the Magnetic view. Every current makes a magnetic field that wraps around it. The glowing blue lines are those field lines, looping around the copper, and their moving dashes show which way the field points. The coloured sheet floating above the board is the field strength at ${h.toFixed(0)} millimetres up, the way a near-field probe would see it. The strongest field here is about ${top.b.toFixed(0)} microtesla${at ? `, near ${at}` : ''}, which is ${top.b > EARTH ? `about ${(top.b / EARTH).toFixed(1)} times` : `about ${Math.round(top.b / EARTH * 100)} percent of`} the Earth's own magnetic field. Move the sheet higher with the slider and watch the field fade: it drops quickly with distance. This is the steady, DC field; the field from the switching currents is what radiates noise, and it comes next with the EMC views.`;
}
console.log(`magnetic: ${S} current elements on a ${(G * d).toFixed(2)} mm grid; the field lines trace when the view opens`);
return { apply, describe, narration, readout, B };
}