Skip to main content
Auric Artisan · Documentation

Material Under Light Developer Reference

Maintain the spectral appearance bench: the numeric core, the dataset register, the page half, the reference-illuminant rule, the shading decomposition, the sensitivity sweep, exports, URL state and Library integration — and the two rendering figures that were withdrawn.

Published: May 31, 2026 Updated: May 31, 2026 Category: Reference Author: Chirag Bansal
Back to Documentation Auric Artisan Home

Overview

The bench is a static page plus four plain scripts: a numeric core that touches no document, a register of what every figure rests on, a page half that draws, and a views half that renders the Method, Metrics, Data, Export and Reference tabs. The spectral architecture — multiply the illuminant by the reflectance by the lobe, integrate against the observer, convert — was already right and is unchanged. What was not right sat on top of it: two rendering figures whose method was not the method they were named for, a set of daylight illuminants that were blackbodies, a reflectance set that was invented and unlabelled, and an interval called a bootstrap.

Table of contents

  1. 1. Code map
  2. 2. What was withdrawn, and what it measured
  3. 3. The spectral pipeline
  4. 4. Illuminants and the reference rule
  5. 5. Reflectance and its provenance
  6. 6. Lobes, shading and exposure
  7. 7. Two white points
  8. 8. The sensitivity sweep
  9. 9. UI contract
  10. 10. Exports and URL state
  11. 11. Public API and library binding
  12. 12. Tests

1. Code map

+
FileHoldsReaches the DOM
js/tool/ml/ml-core.js Every calculation. Installs on the global as AAMLCore. No — deliberately, so the test suite runs the shipped file directly.
js/tool/ml/ml-sources.js The 17-entry dataset register, as AAMLSources. No.
js/tool/material-lighting.js State, the two canvases, the readouts, the exports, tab switching. Yes. AAMLEngine and AAMaterialLighting.
js/tool/material-lighting-views.js Method, Metrics, Data, Export and Reference, as AAMLViews. Yes.
css/material-lighting-shell.css The workbench shell, ported, plus this route’s own blocks under §V. —

Scripts load in that order, except that the register loads before the core, and none is a module, so ml-core.js must be present before material-lighting.js runs; the page half throws a clear error rather than failing silently if it is not. The shell CSS is loaded alongside css/anz-shell.css, the union slice generated by scripts/extract-anz-shell.mjs — this route is in its PAGES list, so a class used only here still survives the cut.

The engine this replaced, js/tool/material-lighting/material-lighting.v2.js, and its page script are removed. Nothing else referenced them.

2. What was withdrawn, and what it measured

+

The colour rendering index

approxCRI() compared every source against illuminantD(6504), where CIE 13.3 chooses the reference from the test source’s own correlated colour temperature. Driving the shipped page’s own illuminant control:

SourceRa printedRa by definition
D65100100
D5032.5100
D5557.0100
Illuminant A−113.7100
Blackbody 2700 K−127.9100
Blackbody 2000 K−194100
F2 fluorescent−10.7~64 published

Two further requirements were missing with it: the fourteen test colours were Gaussian bumps rather than the specified measured reflectances, and the comparison was a Euclidean distance in CIELAB rather than CIE 1964 U*V*W* after a von Kries adaptation — while keeping the 4.6 constant that belongs to the other space.

TM-30

approxTM30() derived both numbers from the same fourteen scores: Rf = clamp(Ra × 1.02 − 2, 0, 100) and Rg = clamp(mean + 2, 60, 120). Run over 51 sources both identities held to 0.000000, and among the seventeen where neither was clamped the pair correlated at r = 1.0000. Rg is a gamut area ratio and exists so it can disagree with fidelity; this one never exceeded 101.8 and sat on its lower clamp in 34 of the 51, where published values run to about 140.

The daylights

illuminantD() computed the chromaticity, then the M1 and M2 basis weights, then discarded all four and returned planckian(cct) under a comment reading “simplified S0/S1/S2 basis → just use planckian for now”. Every D50, D55, D65 and D75 the tool offered was a blackbody: 0.005 to 0.007 away in chromaticity, because daylight sits above the Planckian locus, and enough to move a skin sample by 1.19 ΔE₀₀ under D65 and 0.82 under D50. This one is fixed rather than withdrawn — the published basis is now in the core.

Both indices survive in the code as data, not functions: AAMLCore.RENDERING_WITHDRAWN is an array of {id, label, why, wouldTake} and withdrawn(id) looks one up. There is nothing to call. The Metrics tab re-runs the old CRI formula live — AAMLViews.oldWayRa() — so a reader can drive it rather than take the table on trust; because it runs over the corrected daylight, it lands within a few points of what the live page printed rather than exactly on it, and the panel says so.

3. The spectral pipeline

+

81 samples, 380 to 780 nm at 5 nm — WL_MIN, WL_MAX, WL_STEP, N, wavelengths(). Every export states this, because a spectrum without its sampling cannot be compared with one sampled differently.

The observer is the CIE 1931 2° table, published at 10 nm and carried across unaltered as CMF_10 (41 rows). cmfAt() interpolates it linearly onto the 5 nm grid at load, into OBS_X, OBS_Y, OBS_Z. That interpolation is a real precision statement and both the register and the export carry it.

spectrumToXYZ() integrates with the standard normalisation k = 1 / Σŷ, so an equal-energy spectrum of unit amplitude reads Y = 1 and a perfect reflector under an illuminant reads that illuminant’s own Y. xyzToSrgb(), xyzToLab(), xyzToJzAzBz() and deltaE00() follow. deltaE00 carries the RT rotation term and is checked against nine of the Sharma, Wu & Dalal (2005) published pairs to four decimals.

inSrgbGamut() reports clipping rather than hiding it, with a 1×10−3 tolerance: the published sRGB matrix and white point are each given to five decimals and do not compose exactly, so D65 white itself lands at 1.0000024 in red. A strict test would flag the space’s own white point as outside the space, which trains a reader to ignore the flag.

4. Illuminants and the reference rule

+

planckian(T) evaluates Planck’s law over the grid. illuminantA() is planckian(2856) by definition, not an approximation of it. illuminantD(cct) reconstructs a daylight from the CIE 15 Table 5 eigenvector basis.

The basis has its own grid. It is 46 values from 380 nm at 10 nm, and sAt() resamples it onto the 5 nm one. Indexing all 46 against the 5 nm grid stretches every daylight by a factor of two in wavelength; this repository has made that mistake twice and caught it twice, most recently in the first draft of this very file, where D65 came back at 5855 K. The acceptance test is that D50, D55, D65 and D75 land on their published chromaticities — they land within 0.0003.

cctFromXY() is McCamy’s cubic and cctFromSpectrum() wraps it. The 2 K accuracy usually quoted is McCamy’s fit; the whole chain is worse, and cctBand(cct) returns the measured band:

RangeWorst measured error
1800 – 2500 K18 K
2500 – 4000 K13 K
4000 – 7000 K11 K
7000 – 10000 K139 K
10000 – 15000 K1069 K

The suite re-measures every band, and fails both if the code gets worse and if the claim is padded to more than three times the truth. cctBand().usable is false above 7000 K and the readout says so.

referenceIlluminantFor(cct) implements CIE 13.3 §5.1 — a Planckian below REFERENCE_SPLIT_K (5000), a D-series at or above — and returns {spd, kind, cct, reason}. Taking the selection rule without the index built on it is deliberate: the rule can be implemented completely, the index cannot without measured samples.

5. Reflectance and its provenance

+

The twenty presets in SYNTHETIC are each a flat base plus Gaussian bumps — gold is flatR(0.05) + gaussR(590,50,0.8) + gaussR(620,40,0.5). They are plausible and they are not measurements. For the metals this matters most, because a metal’s spectral reflectance is its colour.

installReflectance(rows, name) is the only path to a measured curve. It takes [wavelength_nm, reflectance] pairs, sorts them, and refuses anything that does not span at least 420–680 nm — resampling past the measurements and calling the result measured is the same mistake in a different place. On success it returns {ok, name, span, samples} and the page calls AAMLSources.markMeasured(), which moves the register entry from synthesised to verbatim, records the file and the span, and drops the wouldTake. resetReflectance() and resetMeasured() undo both halves together.

The page parses two columns from CSV, TSV or whitespace, skips anything that is not two numbers rather than guessing, and divides by 100 if any value exceeds 1 — a file in percent is common and unambiguous. The drop zone is reachable by click and by keyboard, not only by drag.

6. Lobes, shading and exposure

+

computeBRDF(model, NdL, NdV, NdH, VdH, rough, F0) offers five published distributions — GGX, Beckmann, Ward, Ashikhmin-Shirley and Oren-Nayar — and all five are reachable from the cards. The suite asserts that no two of them return the same pair at the same geometry, which is how a menu of five options computing one thing would be caught.

A conductor has no diffuse lobe. renderSpectrum() sets kd = 0 when metal is true and tints the specular by the reflectance curve, which is where a metal’s colour comes from. GGX and Beckmann compute their diffuse as (1 − F) and so drop it on their own at F0 = 1; Ward and Oren-Nayar do not, and the conductor path is the only thing that does. The test therefore exercises Ward and Oren-Nayar, not GGX.

The sphere is shaded from two spectral integrations for the whole image, not 81 samples per pixel. The integral is linear in the lobe’s weights, so unitComponents(opts) returns the diffuse and specular unit colours once and shadeAt(comp, kd, ks) weights them per pixel. The suite checks the decomposition against a full spectral render at a spread of materials, lobes and conductor states, to 1×10−12 — if the algebra were wrong the sphere would be a plausible-looking lie.

exposureForWhite(opts) returns the scale that makes a perfect white Lambertian diffuser, in the same light at the same angles, read Y = 1. Where no light reaches the surface it returns 0 rather than a number that would invent some. The swatch carries this scale and the export states it.

7. Two white points

+

colourOf(spd, white) takes an optional white point and reports whiteUsed and adapted so a caller cannot forget which it asked for.

  • The swatch is un-adapted. A white card under a 2700 K lamp is supposed to render warm, and that is the question a tool called material under light exists to answer.
  • L*a*b* and every ΔE are against the illuminant’s own white, from whitePointOf(illuminant). Without that, a comparison between two sources mostly measures the fact that the two sources are different colours.

renderingShift(opts) renders the material under the source and under referenceIlluminantFor()’s choice, takes each against its own white, and returns the ΔE₀₀ between them with isRenderingIndex: false on the result. Under its own reference a source reads about zero — D65 gives 0.005, a 2700 K blackbody 0.002, illuminant A 0.048, the residual being McCamy’s error and nothing else. That is the check the withdrawn index failed by 213.7 points.

8. The sensitivity sweep

+

sensitivitySweep(opts) takes n, amplitude, correlationNm and an optional random, and returns median, p95, max, mean and every parameter it used. It is not a bootstrap and no longer called one: a bootstrap resamples observed data, and the reflectance here is synthesised.

The perturbation is correlated across neighbouring wavelengths — white noise through a moving average of width correlationNm — because that is how an instrument’s error behaves. The old page applied independent ±0.05 noise at each of the 81 wavelengths, over a size that appeared nowhere on screen; independent noise largely cancels in the integral and gives an interval narrower than a real measurement would. Both the size and the correlation length are controls, and the suite measures that the correlated sweep really is wider than the independent one rather than asserting it.

9. UI contract

+

<main> must carry class="anz-main ax-lab": the shell scopes every slider, select and button to .ax-lab, and without it the controls lose all styling. Rails are class="panel ml-rail" and content columns class="panel ml-stage", inside a div.ml-band (add ml-band--wide for a full-width panel).

.ml-row__head and .ml-cite are four-column grids and need exactly four direct children. Wrapping two of them in a div collapses them into one cell and overflows it.

.ml-cap is a flex-laid amber callout for a warning and expects a <p> inside it. A bare paragraph given that class makes every text node and every <em> a separate flex item. Plain section prose uses .ml-caption.

Tab switching lives in material-lighting.js (wireTabs()), not in a generated inline script, and data-ml-goto anywhere in the document opens a tab and scrolls to the app. The spectrum canvas has no width while its panel is hidden, so opening the Lab tab schedules a redraw.

Any run of text carrying a Greek letter or a units string needs class="ml-sym": the shell uppercases labels, and uppercasing σ gives Σ, which is a different symbol.

10. Exports and URL state

+

csv() writes 81 rows — wavelength, illuminant, reflectance, radiance and the three observer functions — under a header from headerLines(). The header is the point. It carries the sampling, the fact that the 10 nm observer was interpolated onto it, the illuminant and its measured temperature with its band, the chosen reference and why, the reflectance’s provenance in capitals, the surface, the geometry, the exposure, which white point each colour used, and a NOT_A_RENDERING_INDEX line explaining why there is no CRI column.

sceneObject() is the same information as JSON, plus the full spectrum arrays, both withdrawal entries with what each would take, the sensitivity result if one has been run, and the register’s status for every entry.

shareLink() serialises the eight Lab controls plus the lobe and the conductor flag; loadURL() restores them at boot. Nothing leaves the page until a download or copy button is pressed.

11. Public API and library binding

+

window.AAMLEngine exposes core, getState(), last(), sweep(), lobes, headerLines(), csv(), sceneObject(), runSweep() and refresh().

window.AAMaterialLighting is the shape the Library integration and js/common/url-scroll-state.js already expect: getState() returning {config}, restoreState(saved) and refresh(). Restoring sets the control values, the lobe and the conductor flag, rebuilds both segmented controls and schedules a redraw.

window.AAMLViews exposes init(), refresh(), oldWayRa(), REFS and TCS_PEAKS. oldWayRa() is the only arithmetic in the views file; it is the arithmetic being argued against, and it lives there rather than in the core because the core is what the tool actually computes.

12. Tests

+

npm run test:colorimetry runs the whole colorimetry suite, including tests/colorimetry/ml-reference.test.js and ml-sources.test.js — 56 assertions between them. ml-harness.js loads the shipped core and register in a vm context under just enough of a global for the IIFE to reach its export, so every assertion runs the code the page runs rather than a copy of its maths.

Each assertion checks a published value or a property the maths must have, never a number the suite produced. Where a value is approximate, the tolerance is the approximation’s own measured error. The tests that matter most when changing this file:

  • the D-series against published chromaticities — the one that catches a basis read on the wrong grid;
  • a daylight is not a blackbody at the same temperature — the regression guard for the defect this rebuild found;
  • a source judged against its own reference reads about zero;
  • the nine Sharma pairs for ΔE₀₀;
  • the two-integration shading decomposition against a full spectral render;
  • the CCT bands, which fail both if the code gets worse and if the claim is padded.

The suite was proved by injecting 54 defects — a stretched daylight basis, a reference hard-coded back to D65, a dropped rotation term, a metal that keeps its diffuse lobe, a stand-in relabelled verbatim, a sweep that reports its median as its 95th percentile — and all 54 were caught. Four survived the first run, every one of them a gap in the tests rather than in the code, and the assertions that close them are marked in place.