← Natura·Docs/Modules/Source

crystal.js

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

// crystal.js -- Natura CRYSTALLIZATION (physical / material): nucleation + grain growth, and the one law behind
// a geode, a cast metal, and a frozen pane -- HOW FAST IT COOLED SETS THE GRAIN. As the melt cools, nuclei appear
// (faster cooling -> many nuclei at once); each is the seed of a GRAIN that grows outward until it meets its
// neighbours, tiling the whole into a mosaic. Cool it FAST and you get a swarm of tiny grains (a fine, hard
// microstructure); cool it SLOW and a few nuclei grow into big lazy crystals (the geode's cathedral). Grain size
// ~ 1/nucleation-density -- the metallurgist's Hall-Petch knob. Distinct from frost.js (DLA dendrites, a different
// growth law). Deterministic, node-testable. UMD. From Geode / How Slowly It Cooled / The Grain in the Steel.
(function (root, factory) {
  if (typeof module !== 'undefined' && module.exports) module.exports = factory();
  else root.Crystal = 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, seed, coolRate, growth, maxGrains, maxDtDays, name }
  function makeCrystal(config) {
    config = config || {};
    const N = config.N || 80;
    const coolRate = config.coolRate != null ? config.coolRate : 6;   // nuclei formed per day (x melt fraction) -- the COOLING RATE knob
    const growth = config.growth != null ? config.growth : 1;         // grain-front advance in cell-rings per day
    const maxGrains = config.maxGrains != null ? config.maxGrains : 4000;
    const maxDd = config.maxDtDays != null ? config.maxDtDays : 2;

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

      init() {
        const grain = new Int32Array(N * N).fill(-1), orient = new Float32Array(maxGrains);   // orient PER GRAIN (id -> colour)
        return { N, grain, orient, grains: 0, _rng: mulberry32(config.seed || 1), _nuc: 0, _grow: 0 };
      },

      step(st, ctx, dt) {
        const dd = clamp(dt / 86400, 0, maxDd); if (dd <= 0) return st;
        const { grain, orient, _rng } = st, M = N * N;
        let melt = 0; for (let k = 0; k < M; k++) if (grain[k] < 0) melt++;
        if (melt === 0) return st;
        // 1. NUCLEATION -- new grains seed in the remaining melt, at a rate set by the cooling
        st._nuc += coolRate * dd * (melt / M);
        while (st._nuc >= 1 && st.grains < maxGrains && melt > 0) {
          let tries = 0, k; do { k = (_rng() * M) | 0; tries++; } while (grain[k] >= 0 && tries < 40);
          if (grain[k] < 0) { const id = st.grains++; orient[id] = _rng(); grain[k] = id; melt--; }
          st._nuc -= 1;
        }
        // 2. GROWTH -- each grain advances its front into adjacent melt (rings per day), deterministic (min-id wins)
        st._grow += growth * dd; let rings = Math.floor(st._grow); st._grow -= rings;
        while (rings-- > 0 && melt > 0) {
          const next = st._next || (st._next = new Int32Array(M)); next.set(grain);
          for (let y = 0; y < N; y++) for (let x = 0; x < N; x++) { const k = y * N + x; if (grain[k] >= 0) continue;
            let best = -1;
            if (x > 0 && grain[k - 1] >= 0) best = best < 0 ? grain[k - 1] : Math.min(best, grain[k - 1]);
            if (x < N - 1 && grain[k + 1] >= 0) best = best < 0 ? grain[k + 1] : Math.min(best, grain[k + 1]);
            if (y > 0 && grain[k - N] >= 0) best = best < 0 ? grain[k - N] : Math.min(best, grain[k - N]);
            if (y < N - 1 && grain[k + N] >= 0) best = best < 0 ? grain[k + N] : Math.min(best, grain[k + N]);
            if (best >= 0) { next[k] = best; melt--; }   // a cell joins the lowest-id neighbouring grain; its colour = orient[grainId]
          }
          grain.set(next);
        }
        return st;
      },

      // OUT: grain (per-cell grain id), orient (0..1 per grain, for colour), grainCount, meanGrainSize (cells),
      // solidFraction. The emergence: meanGrainSize falls as the cooling rate rises.
      fields(st) {
        const M = st.N * st.N; let solid = 0; const seen = new Set();
        for (let k = 0; k < M; k++) { if (st.grain[k] >= 0) { solid++; seen.add(st.grain[k]); } }
        const gc = seen.size;
        return { N: st.N, grain: st.grain, orient: st.orient, grainCount: gc,
          meanGrainSize: gc ? +(solid / gc).toFixed(2) : 0, solidFraction: +(solid / M).toFixed(5) };
      },
      events(st) { return st.grains > 0 && this.fields(st).solidFraction >= 1 ? [{ type: 'solidified', grains: this.fields(st).grainCount }] : []; }
    };
  }
  return { makeCrystal };
});