chemistry.js
106 lines · in natura-src-1.0.0.zip as natura/chemistry.js · a module · its entry in the module reference · its test: chemistry-test.js
// chemistry.js -- Natura CHEMISTRY / the CHEMICAL LAYER (ONTOLOGY-FOLD-IN #1, the biggest hole): matter that
// changes COMPOSITION, not just shape and temperature. Every STATES system so far is thermal / phase /
// mechanical / erosive -- iron bends and melts but never RUSTS. Here each cell holds a MATERIAL and an aging
// progress 0..1, and it WEATHERS over sim-time driven by EXPOSURE: iron rusts in DAMP air (dry iron barely
// ages -- the driver, not the clock, decides), copper and bronze grow a green PATINA over decades, silver
// TARNISHES, wood ROTS where it is damp AND warm. The emergence is that composition tracks the CONDITIONS:
// the same iron rusts on the wet coast and stays bright in the desert. Couples to WEATHER (moisture), and gives
// ARTIFACT a chemical clock (a bronze statue greens, a shipwreck rusts). Deterministic, node-testable.
// UMD (node test / window.Chemistry).
(function (root, factory) {
if (typeof module !== 'undefined' && module.exports) module.exports = factory();
else root.Chemistry = factory();
})(typeof self !== 'undefined' ? self : this, function () {
'use strict';
const clamp = (v, a, b) => v < a ? a : v > b ? b : v;
const YEAR = 365 * 86400;
// the materials + their real weathering: a DRIVER (what pushes the reaction), a RATE (fraction/year at full
// drive), and the COLOUR stages the aging runs through (pristine -> ... -> fully weathered).
const MATERIALS = [
{ name: 'iron', driver: 'moist', rate: 0.30, cols: [[124, 126, 132], [150, 92, 58], [110, 62, 42]] }, // bright -> rust
{ name: 'copper', driver: 'time', rate: 0.020, cols: [[198, 126, 74], [126, 86, 58], [74, 150, 120]] }, // shine -> brown -> verdigris
{ name: 'silver', driver: 'time', rate: 0.55, cols: [[226, 228, 232], [168, 168, 158], [92, 90, 82]] }, // bright -> tarnished
{ name: 'bronze', driver: 'time', rate: 0.016, cols: [[178, 138, 76], [112, 86, 56], [72, 132, 106]] }, // gold -> patina
{ name: 'wood', driver: 'dampwarm', rate: 0.14, cols: [[156, 114, 72], [122, 112, 96], [82, 80, 68]] }, // fresh -> grey -> rot
{ name: 'paint', driver: 'light', rate: 0.42, cols: [[178, 62, 58], [180, 120, 98], [190, 182, 170]] } // vivid -> SUN-FADED pale (light bleaches the pigment)
];
const IDX = {}; MATERIALS.forEach((m, i) => IDX[m.name] = i);
// the colour of a material at a given aging (interpolated through its stages) -- for pieces to render.
function matColor(mi, aged) {
const c = (MATERIALS[mi] || MATERIALS[0]).cols, t = clamp(aged, 0, 1) * (c.length - 1);
const i = Math.min(c.length - 2, Math.floor(t)), f = t - i, a = c[i], b = c[i + 1];
return [a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f, a[2] + (b[2] - a[2]) * f];
}
function driveOf(driver, moist, temp, light) {
if (driver === 'moist') return moist; // rust needs damp air
if (driver === 'dampwarm') return moist * clamp((temp - 2) / 18, 0, 1); // rot needs damp AND warm
if (driver === 'light') return light; // paint fades / silver-halide darkens under light
return 1; // 'time' -- patina/tarnish proceed regardless
}
// config: { N, material (index | name | fn(x,y)->index|name), moisture (0..1 | fn(x,y)), temperature (degC | fn),
// weather (name to read a live moisture field), maxDtYears, materials (override the registry) }
function makeChemistry(config) {
config = config || {};
const N = config.N || 64;
const MAT = config.materials || MATERIALS;
const maxDtYears = config.maxDtYears != null ? config.maxDtYears : 40;
const weatherName = config.weather || null;
const gx = (i) => (i / (N - 1)) * 2 - 1;
const resolveMat = (v) => typeof v === 'string' ? (IDX[v] != null ? IDX[v] : 0) : (v | 0);
const matFn = config.material;
const moistCfg = config.moisture != null ? config.moisture : 0.5;
const tempCfg = config.temperature != null ? config.temperature : 15;
const lightCfg = config.light != null ? config.light : 0.5;
const scal = (c, x, y) => typeof c === 'function' ? c(x, y) : c;
return {
name: config.name || 'chemistry',
domain: 'physical',
scale: 'material',
params: config,
init() {
const material = new Int8Array(N * N), aged = new Float32Array(N * N);
for (let j = 0; j < N; j++) for (let i = 0; i < N; i++) {
const k = j * N + i;
material[k] = matFn == null ? 0 : resolveMat(typeof matFn === 'function' ? matFn(gx(i), gx(j)) : matFn);
}
return { N, material, aged, meanAged: 0, done: 0 };
},
step(st, ctx, dt) {
const dd = clamp(dt / YEAR, 0, maxDtYears); if (dd <= 0) return st;
const { material, aged } = st;
const w = weatherName && ctx.fields[weatherName] ? ctx.fields[weatherName] : null;
const wMoist = w && w.moisture ? w.moisture : (w && w.humidity ? w.humidity : null);
const wTemp = w && w.temp ? w.temp : null;
let sum = 0, done = 0;
for (let j = 0; j < N; j++) for (let i = 0; i < N; i++) {
const k = j * N + i, m = MAT[material[k]] || MAT[0];
const moist = wMoist ? clamp(wMoist[k], 0, 1) : clamp(scal(moistCfg, gx(i), gx(j)), 0, 1);
const temp = wTemp ? wTemp[k] : scal(tempCfg, gx(i), gx(j));
const light = clamp(scal(lightCfg, gx(i), gx(j)), 0, 1);
if (aged[k] < 1) {
const before = aged[k];
aged[k] = clamp(aged[k] + m.rate * driveOf(m.driver, moist, temp, light) * dd, 0, 1);
if (before < 1 && aged[k] >= 1) done++;
}
sum += aged[k];
}
st.meanAged = sum / (N * N);
st.done = done;
return st;
},
// OUT: material index + aging per cell, and the colour helper so pieces render the weathered look
fields(st) { return { N: st.N, material: st.material, aged: st.aged, meanAged: st.meanAged, matColor: matColor, MATERIALS: MAT }; },
events(st) { return st.done > 0 ? [{ kind: 'fullyWeathered', cells: st.done }] : []; }
};
}
return { makeChemistry, matColor, MATERIALS, IDX };
});