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.
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.
1. Code map
+| File | Holds | Reaches 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:
| Source | Ra printed | Ra by definition |
|---|---|---|
| D65 | 100 | 100 |
| D50 | 32.5 | 100 |
| D55 | 57.0 | 100 |
| Illuminant A | −113.7 | 100 |
| Blackbody 2700 K | −127.9 | 100 |
| Blackbody 2000 K | −194 | 100 |
| 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:
| Range | Worst measured error |
|---|---|
| 1800 – 2500 K | 18 K |
| 2500 – 4000 K | 13 K |
| 4000 – 7000 K | 11 K |
| 7000 – 10000 K | 139 K |
| 10000 – 15000 K | 1069 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.