← Natura·Docs/Modules/Source

granular.js

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

// granular.js -- Natura GRANULAR MEDIA (physical / material): sand, grain, gravel -- a solid that flows but is
// not a fluid. A cellular pile under gravity: grains fall, and when blocked slide diagonally, so a poured heap
// settles to its ANGLE OF REPOSE instead of leveling like water. Drain it through an orifice and the strangest
// thing: the flow is CONSTANT, independent of how much sand is left above (Beverloo's law) -- an hourglass keeps
// time because of it -- and a too-narrow orifice ARCHES and JAMS. (The Brazil-nut segregation is the sibling
// behaviour under shaking; noted for a later mode.) From The Narrow of the Glass / The Big One Rises.
// Deterministic (seeded tie-break), node-testable. UMD.
(function (root, factory) {
  if (typeof module !== 'undefined' && module.exports) module.exports = factory();
  else root.Granular = factory();
})(typeof self !== 'undefined' ? self : this, function () {
  'use strict';
  const clamp = (v, a, b) => v < a ? a : v > b ? b : v;
  function mulberry32(a) { return function () { a |= 0; a = a + 0x6D2B79F5 | 0; let t = Math.imul(a ^ a >>> 15, 1 | a); t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; return ((t ^ t >>> 14) >>> 0) / 4294967296; }; }

  // config: { N(width), H(height), orifice, fillFrac, feed(grains/step at top centre), seed, maxDtDays, subPerDay, name }
  function makeGranular(config) {
    config = config || {};
    const N = config.N || 41;
    const H = config.H || 60;
    const orifice = config.orifice != null ? config.orifice : 5;   // outlet width (0 = closed, a heap-builder)
    const feed = config.feed != null ? config.feed : 0;            // grains dropped at top-centre per sub-step
    const subPerDay = config.subPerDay != null ? config.subPerDay : 40;
    const maxDd = config.maxDtDays != null ? config.maxDtDays : 4;

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

      init() {
        const g = new Uint8Array(N * H);
        const fill = config.fillFrac != null ? config.fillFrac : 0.6;   // fill the top `fill` of the box
        const topRows = Math.round(H * fill);
        for (let y = 0; y < topRows; y++) for (let x = 0; x < N; x++) g[y * N + x] = 1;
        return { N, H, g, exited: 0, lastDischarge: 0, _rng: mulberry32(config.seed || 1) };
      },

      step(st, ctx, dt) {
        const subs = clamp(Math.round((dt / 86400) * subPerDay), 1, subPerDay * maxDd);
        const g = st.g, rng = st._rng, at = (x, y) => g[y * N + x], set = (x, y, v) => { g[y * N + x] = v; };
        const cx = (N - 1) / 2, half = orifice / 2;
        let exitedStart = st.exited;
        for (let s = 0; s < subs; s++) {
          // 1. OUTLET: grains at the bottom row within the orifice fall out
          if (orifice > 0) for (let x = 0; x < N; x++) if (Math.abs(x - cx) <= half && at(x, H - 1)) { set(x, H - 1, 0); st.exited++; }
          // 2. GRAVITY: bottom-up, each grain falls straight down or, if blocked, slides diagonally (-> repose)
          for (let y = H - 2; y >= 0; y--) for (let x = 0; x < N; x++) {
            if (!at(x, y)) continue;
            if (!at(x, y + 1)) { set(x, y, 0); set(x, y + 1, 1); continue; }
            const dl = x > 0 && !at(x - 1, y + 1), dr = x < N - 1 && !at(x + 1, y + 1);
            if (dl && dr) { if (rng() < 0.5) { set(x, y, 0); set(x - 1, y + 1, 1); } else { set(x, y, 0); set(x + 1, y + 1, 1); } }
            else if (dl) { set(x, y, 0); set(x - 1, y + 1, 1); }
            else if (dr) { set(x, y, 0); set(x + 1, y + 1, 1); }
          }
          // 3. FEED at the top centre (a running hourglass / a poured heap)
          if (feed > 0) { let dropped = 0; for (let k = 0; k < feed && dropped < feed; k++) { const fx = Math.round(cx + (rng() - 0.5) * 3); if (fx >= 0 && fx < N && !at(fx, 0)) { set(fx, 0, 1); dropped++; } } }
        }
        st.lastDischarge = (st.exited - exitedStart) / subs;   // grains per sub-step (the discharge RATE)
        return st;
      },

      // OUT: grid (the packing), count (grains held), exited (cumulative through the outlet), discharge (rate),
      // heapWidth (base width of the pile -> angle of repose), heapHeight
      fields(st) {
        const g = st.g; let count = 0, minX = N, maxX = -1, top = H;
        for (let y = 0; y < H; y++) for (let x = 0; x < N; x++) if (g[y * N + x]) { count++; if (y === H - 1) { if (x < minX) minX = x; if (x > maxX) maxX = x; } if (y < top) top = y; }
        return { N: st.N, H: st.H, grid: g, count, exited: st.exited, discharge: +st.lastDischarge.toFixed(3),
          heapWidth: maxX >= minX ? maxX - minX + 1 : 0, heapHeight: H - top };
      },
      events(st) { const f = this.fields(st); return (orifice > 0 && f.count > 0 && f.discharge < 0.05) ? [{ type: 'jam' }] : []; }
    };
  }
  return { makeGranular };
});