← Natura·Docs/Modules/Source

laws/interference-colour.js

107 lines · in natura-src-1.0.0.zip as natura/laws/interference-colour.js · a module · its entry in the module reference · its test: laws/interference-colour-test.js

// laws/interference-colour.js -- the FOUNDING module of the neap.framework LAWS library (2026-08-22).
//
// ONE wave-optics law, MANY physical manifestations: a perceived colour arises when white light is split
// into two beams with an OPTICAL PATH DIFFERENCE (OPD, in nanometres) that then recombine, so each
// wavelength interferes constructively or destructively by how the OPD compares to lambda. The reflected
// intensity of a thin soap film and the transmitted intensity of a birefringent crystal between crossed
// polars are the SAME function -- I(lambda) = sin^2(pi * OPD / lambda) -- black at OPD 0 (the "black film"
// / extinction), then the Newton / Michel-Levy colour series as OPD climbs. This is the "processes are as
// important as the things" idea made concrete: capture the LAW once, and the soap bubble, the oil slick,
// and the thin-section crystal all read from it.
//
// Consolidates the inline thin-film colour code discovered in The Thinnest Colour, Bubbles / What the Foam
// Keeps (reflected iridescence) and the Michel-Levy birefringence colour in the petrographic thin-section
// piece (crossed-polars). Render-free + node-tested (the framework bar). Pure: no Date.now / Math.random.
//
// The colour pipeline is real, not a hue hack: integrate I(lambda) against an illuminant and the CIE 1931
// colour-matching functions (the Wyman-Sloan-Shirley 2013 analytic multi-lobe fit) to get CIE XYZ, then the
// standard XYZ->linear-sRGB matrix + the sRGB transfer curve. UMD (node + browser).
(function (root, factory) {
  if (typeof module !== 'undefined' && module.exports) module.exports = factory();
  else (root.NaturaLaws = root.NaturaLaws || {}).interferenceColour = factory();
})(typeof self !== 'undefined' ? self : this, function () {
  'use strict';

  var LAMBDA_MIN = 380, LAMBDA_MAX = 720, STEP = 5;   // visible band, 5nm samples (69 points)

  // one asymmetric Gaussian lobe (different sigma either side of the peak) -- WSS building block
  function g(x, mu, s1, s2) { var s = x < mu ? s1 : s2; var t = (x - mu) / s; return Math.exp(-0.5 * t * t); }

  // CIE 1931 2-deg colour-matching functions, Wyman/Sloan/Shirley 2013 analytic multi-lobe fit (lambda in nm)
  function xbar(l) { return 1.056 * g(l, 599.8, 37.9, 31.0) + 0.362 * g(l, 442.0, 16.0, 26.7) - 0.065 * g(l, 501.1, 20.4, 26.2); }
  function ybar(l) { return 0.821 * g(l, 568.8, 46.9, 40.5) + 0.286 * g(l, 530.9, 16.3, 31.1); }
  function zbar(l) { return 1.217 * g(l, 437.0, 11.8, 36.0) + 0.681 * g(l, 459.0, 26.0, 13.8); }

  // equal-energy illuminant E by default (each wavelength weighted equally) -- the neutral choice for
  // structural colour, and it makes a flat reflectance spectrum map to true white. Override with opts.illum.
  function illumE() { return 1; }

  // integrate a spectral intensity function I(lambda) in [0,1] to a perceived sRGB [r,g,b] (0-255).
  // white-balanced so a flat I(lambda)=1 under the illuminant maps to (255,255,255).
  function spectrumColour(intensity, opts) {
    opts = opts || {};
    var S = opts.illum || illumE;
    var X = 0, Y = 0, Z = 0, N = 0;
    for (var l = LAMBDA_MIN; l <= LAMBDA_MAX; l += STEP) {
      var s = S(l), ii = intensity(l);
      X += ii * s * xbar(l); Y += ii * s * ybar(l); Z += ii * s * zbar(l);
      N += s * ybar(l);                                  // normaliser = the illuminant's own Y (white balance)
    }
    if (N <= 0) N = 1;
    X /= N; Y /= N; Z /= N;
    return xyzToSrgb(X, Y, Z);
  }

  // CIE XYZ (Y in [0,1]) -> sRGB 0-255, with the standard matrix, gamut clamp, and the sRGB transfer curve.
  function xyzToSrgb(X, Y, Z) {
    var r = 3.2406 * X - 1.5372 * Y - 0.4986 * Z;
    var gg = -0.9689 * X + 1.8758 * Y + 0.0415 * Z;
    var b = 0.0557 * X - 0.2040 * Y + 1.0570 * Z;
    return [enc(r), enc(gg), enc(b)];
  }
  function enc(c) {
    if (c < 0) c = 0; if (c > 1) c = 1;
    c = c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;   // linear -> sRGB
    return Math.round(c * 255);
  }

  // THE LAW: two-beam interference intensity at one wavelength for an optical path difference opd (nm).
  // sin^2 (not cos^2) so OPD 0 -> 0 (the black film / crossed-polars extinction); this is the form that
  // already carries the soap-film half-wave phase shift and the crossed-polar geometry.
  function intensityAt(lambda, opd) { var s = Math.sin(Math.PI * opd / lambda); return s * s; }

  // the perceived colour for a bare optical path difference (nm). Serves any OPD source.
  function colourFromOPD(opd, opts) { return spectrumColour(function (l) { return intensityAt(l, opd); }, opts); }

  // THIN FILM (reflected iridescence): a film of thickness d (nm) and index n, viewed at angleDeg from the
  // normal in an ambient of index ambientN. OPD = 2 n d cos(theta_t), theta_t from Snell. -> reflected colour.
  function thinFilm(o) {
    o = o || {};
    var d = o.d || 0, n = o.n || 1.33, ambientN = o.ambientN || 1.0;
    var thetaI = (o.angleDeg || 0) * Math.PI / 180;
    var sinT = (ambientN / n) * Math.sin(thetaI);
    var cosT = Math.sqrt(Math.max(0, 1 - sinT * sinT));
    var opd = 2 * n * d * cosT;
    var c = colourFromOPD(opd, o);
    c.opd = opd;                                          // handy for callers that want the OPD too
    return c;
  }

  // BIREFRINGENCE (crossed polars, Michel-Levy): retardation gamma (nm) = birefringence * thickness.
  // -> transmitted interference colour. Same law, different manifestation.
  function birefringence(o) {
    o = o || {};
    var gamma = o.retardation != null ? o.retardation : (o.birefringence || 0) * (o.thickness || 0);
    var c = colourFromOPD(gamma, o);
    c.retardation = gamma;
    return c;
  }

  return {
    LAMBDA_MIN: LAMBDA_MIN, LAMBDA_MAX: LAMBDA_MAX, STEP: STEP,
    xbar: xbar, ybar: ybar, zbar: zbar,
    spectrumColour: spectrumColour, xyzToSrgb: xyzToSrgb,
    intensityAt: intensityAt, colourFromOPD: colourFromOPD,
    thinFilm: thinFilm, birefringence: birefringence
  };
});