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