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 };
});