← Natura·Docs/Modules/Source

vortex.js

69 lines · in natura-src-1.0.0.zip as natura/vortex.js · a module · its entry in the module reference · its test: vortex-test.js

// vortex.js -- Natura VORTEX-BLOB FLUID (physical / micro-landscape): a Lagrangian fluid substrate. Instead of a
// grid, the flow is carried by a handful of VORTEX PARTICLES, each a little spin (circulation) in the fluid; each
// one whirls the fluid around it (smoothed 2D Biot-Savart), and so they advect EACH OTHER. Out of that fall the
// classic emergent motions: a counter-rotating PAIR self-propels in a straight line (a smoke ring's cross-section),
// like-signed vortices ORBIT one another, and circulation is conserved throughout. It gives a cheap VELOCITY FIELD
// other systems can drift smoke / fog / seeds / scent through. SYSTEMS.md #12 named "a smoke-ring (vortex-blob)"
// as a planned MICRO member; this builds it. From "It Carries Itself". Deterministic, node-testable. UMD.
(function (root, factory) {
  if (typeof module !== 'undefined' && module.exports) module.exports = factory();
  else root.Vortex = factory();
})(typeof self !== 'undefined' ? self : this, function () {
  'use strict';
  const clamp = (v, a, b) => v < a ? a : v > b ? b : v;

  // config: { vortices:[{x,y,gamma}], core, speed, dtInner, maxSub, secPerDay, name }
  function makeVortex(config) {
    config = config || {};
    const core = config.core != null ? config.core : 0.08;      // blob core radius (desingularizes Biot-Savart)
    const core2 = core * core;
    const speed = config.speed != null ? config.speed : 1.0;     // overall time rate
    const dtInner = config.dtInner != null ? config.dtInner : 0.01;
    const maxSub = config.maxSub != null ? config.maxSub : 400;
    const secPerDay = config.secPerDay != null ? config.secPerDay : 86400;

    // velocity induced at (px,py) by all vortices (optionally skipping index `skip`)
    function induce(vort, px, py, skip) {
      let vx = 0, vy = 0;
      for (let j = 0; j < vort.length; j++) { if (j === skip) continue; const o = vort[j];
        const dx = px - o.x, dy = py - o.y, r2 = dx * dx + dy * dy + core2, f = o.gamma / (6.283185307 * r2);
        vx += -f * dy; vy += f * dx; }
      return [vx, vy];
    }

    return {
      name: config.name || 'vortex',
      domain: 'physical',
      scale: 'micro',
      params: config,

      init() {
        const src = config.vortices || [{ x: -0.5, y: 0, gamma: 1 }, { x: 0.5, y: 0, gamma: 1 }];
        return { vort: src.map(v => ({ x: v.x, y: v.y, gamma: v.gamma })), t: 0 };
      },

      step(st, ctx, dt) {
        const nsub = clamp(Math.round((dt / secPerDay) * speed / dtInner), 1, maxSub);
        const vort = st.vort, vx = st._vx || (st._vx = new Float64Array(vort.length)), vy = st._vy || (st._vy = new Float64Array(vort.length));
        for (let s = 0; s < nsub; s++) {
          for (let i = 0; i < vort.length; i++) { const uv = induce(vort, vort[i].x, vort[i].y, i); vx[i] = uv[0]; vy[i] = uv[1]; }
          for (let i = 0; i < vort.length; i++) { vort[i].x += vx[i] * dtInner; vort[i].y += vy[i] * dtInner; }
        }
        st.t += (dt / secPerDay) * speed;
        return st;
      },

      // OUT: the vortices (x,y,gamma), the total circulation (an invariant), the pair centroid, and a `sampleAt`
      // so other systems drift through the induced velocity field.
      fields(st) {
        const vort = st.vort; let g = 0, cx = 0, cy = 0, agsum = 0;
        for (const v of vort) { g += v.gamma; const ag = Math.abs(v.gamma); cx += v.x * ag; cy += v.y * ag; agsum += ag; }
        return { vortices: vort, circulation: +g.toFixed(6), absCirc: +agsum.toFixed(6),
          centroid: [+(cx / (agsum || 1)).toFixed(4), +(cy / (agsum || 1)).toFixed(4)],
          sampleAt: (px, py) => induce(vort, px, py, -1) };
      },
      events() { return []; }
    };
  }
  return { makeVortex };
});