← Natura·Docs/Modules/Source

frost.js

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

// frost.js -- Natura MICRO / FROST (SYSTEMS.md #12, ALGORITHMS.md): the small pole of the scale ladder,
// "the branching of rime on a leaf in a storm." Ice grows on a cold surface by vapour deposition, and it
// grows in DENDRITES -- feathery branches -- because the process is DIFFUSION-LIMITED: the tips stick out
// into the undepleted vapour and grow fastest, while the hollows are starved, so the pattern forks and
// forks again. This is the dielectric-breakdown / DLA model (the eta-exponent controls how feathery): a
// Laplacian VAPOUR field (1 at the far edges, 0 on the ice) relaxes each step, and the frost adds cells on
// its frontier with probability proportional to the local vapour^eta. Standalone it grows a snowflake; in
// a world it ACTIVATES only where the weather is below freezing and humid -- a micro system woken by the
// macro one. The same recipe draws lightning and river deltas; here it is rime.
//
// A Natura system spec (init/step/fields/events), deterministic (seeded rng). UMD (node test / window.Frost).
(function (root, factory) {
  if (typeof module !== 'undefined' && module.exports) module.exports = factory();
  else root.Frost = factory();
})(typeof self !== 'undefined' ? self : this, function () {
  'use strict';

  // make a Frost system. config: { N, eta(branchiness), growthPerStep, relaxIters, maxFill, weather,
  //   freezeTemp, seedAt('center'|'bottom'), name }.
  function makeFrost(config) {
    config = config || {};
    const N = config.N || 96;
    const eta = config.eta != null ? config.eta : 1.0;            // 0 = blobby, 1 = frost, >2 = spidery
    const growthPerStep = config.growthPerStep != null ? config.growthPerStep : 2;
    const relaxIters = config.relaxIters != null ? config.relaxIters : 4;   // Jacobi sweeps of the vapour field / step
    const maxFill = config.maxFill != null ? config.maxFill : 0.12;         // stop at this fraction of the surface iced
    const weatherName = config.weather || 'weather';
    const freezeTemp = config.freezeTemp != null ? config.freezeTemp : 0;   // grows only where the surface is below this
    const seedAt = config.seedAt || 'center';
    const nuclei = config.nuclei != null ? config.nuclei : 1;               // >1: many nucleation points, so rime spreads OVER a whole surface

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

      init(rng) {
        // NO seeds at init: a warm world must show ZERO rime. The nucleation points are placed LAZILY, the
        // first step the surface actually goes below freezing (below), so frost simply does not exist until
        // it is cold enough to form -- exactly as ice does.
        const ice = new Uint8Array(N * N), V = new Float32Array(N * N).fill(1);
        return { N, rng, ice, V, count: 0, done: false, bloomed: false, seeded: false };
      },

      step(st, ctx, dt) {
        if (st.done) return st;
        // ACTIVATION: in a world, rime forms only on a below-freezing surface (compose with weather)
        if (ctx && ctx.fields) {
          const w = ctx.fields[weatherName];
          if (w && w.temp) { const cT = w.temp[((N >> 1) * N) + (N >> 1)]; if (cT > freezeTemp) return st; }
        }
        const { ice, V, rng } = st;
        // it is cold: place the nucleation seeds NOW, the first cold step (not at init), then grow from them
        if (!st.seeded) {
          if (nuclei > 1) { for (let n = 0; n < nuclei; n++) { const i = 1 + ((rng() * (N - 2)) | 0), j = 1 + ((rng() * (N - 2)) | 0), k = j * N + i; if (!ice[k]) { ice[k] = 1; V[k] = 0; st.count++; } } }
          else if (seedAt === 'bottom') { for (let i = 0; i < N; i++) { ice[(N - 1) * N + i] = 1; V[(N - 1) * N + i] = 0; } st.count = N; }
          else { const c = ((N >> 1) * N) + (N >> 1); ice[c] = 1; V[c] = 0; st.count = 1; }
          st.seeded = true;
        }
        // relax the VAPOUR field toward Laplace: 1 at the edges (source), 0 on the ice, smooth between
        for (let it = 0; it < relaxIters; it++) {
          for (let j = 1; j < N - 1; j++) for (let i = 1; i < N - 1; i++) {
            const k = j * N + i; if (ice[k]) { V[k] = 0; continue; }
            V[k] = 0.25 * (V[k - 1] + V[k + 1] + V[k - N] + V[k + N]);
          }
        }
        // GROW: add frontier cells with probability ~ vapour^eta (the tips, in high vapour, win)
        for (let g = 0; g < growthPerStep; g++) {
          let total = 0; const cand = [], wt = [];
          for (let j = 1; j < N - 1; j++) for (let i = 1; i < N - 1; i++) {
            const k = j * N + i; if (ice[k]) continue;
            if (!(ice[k - 1] || ice[k + 1] || ice[k - N] || ice[k + N])) continue;   // must touch the ice
            const p = Math.pow(Math.max(0, V[k]), eta) + 1e-6; cand.push(k); wt.push(p); total += p;
          }
          if (!cand.length) { st.done = true; break; }
          let r = rng() * total, pick = cand[cand.length - 1];
          for (let m = 0; m < cand.length; m++) { r -= wt[m]; if (r <= 0) { pick = cand[m]; break; } }
          ice[pick] = 1; V[pick] = 0; st.count++;
          if (st.count > maxFill * N * N) { st.done = true; break; }
        }
        return st;
      },

      // OUT: ice (the frost pattern, 0/1), count. Render it as the rime on the glass.
      fields(st) { return { N: st.N, ice: st.ice, count: st.count }; },
      events(st) {
        if (st.done && !st.bloomed) { st.bloomed = true; return [{ kind: 'frostBloom', cells: st.count }]; }
        return [];
      }
    };
  }

  return { makeFrost };
});