@vitreajs/vitrea 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -17,6 +17,7 @@ var WGSL_ANALYSIS_PASS = `struct AnalysisUniforms {
17
17
  @group(0) @binding(3) var<storage, read_write> stats : array<f32>;
18
18
 
19
19
  var<workgroup> partialLum : array<f32, ${ANALYSIS_WORKGROUP}>;
20
+ var<workgroup> partialTone : array<f32, ${ANALYSIS_WORKGROUP}>;
20
21
  var<workgroup> partialSq : array<f32, ${ANALYSIS_WORKGROUP}>;
21
22
  var<workgroup> partialEdge : array<f32, ${ANALYSIS_WORKGROUP}>;
22
23
 
@@ -32,6 +33,7 @@ fn lum_at(uv : vec2f) -> f32 {
32
33
  fn cs_analysis(@builtin(local_invocation_index) lane : u32) {
33
34
  let total = u32(au.grid.x * au.grid.y);
34
35
  var sumLum = 0.0;
36
+ var sumTone = 0.0;
35
37
  var sumSq = 0.0;
36
38
  var sumEdge = 0.0;
37
39
  var n = 0.0;
@@ -47,7 +49,15 @@ fn cs_analysis(@builtin(local_invocation_index) lane : u32) {
47
49
  let dx = lum_at(uv + vec2f(au.texel.x, 0.0)) - lum_at(uv - vec2f(au.texel.x, 0.0));
48
50
  let dy = lum_at(uv + vec2f(0.0, au.texel.y)) - lum_at(uv - vec2f(0.0, au.texel.y));
49
51
 
52
+ // The published luminance accumulates ENCODED \u2014 the W9 model (claims
53
+ // 5.31): the reference's tone responses track the encoded-space mean of a
54
+ // structured backdrop, not its linear mean. The host decodes the mean once
55
+ // on readback, so every consumer downstream still speaks linear light.
56
+ // The linear sum stays, because the variance fold below needs a mean in
57
+ // the same space as its squares; variance and edge density stay linear \u2014
58
+ // nothing tonal reads them.
50
59
  sumLum = sumLum + c;
60
+ sumTone = sumTone + linear_to_srgb(vec3f(c, c, c)).x;
51
61
  sumSq = sumSq + c * c;
52
62
  sumEdge = sumEdge + length(vec2f(dx, dy)) * 0.5;
53
63
  n = n + 1.0;
@@ -56,6 +66,7 @@ fn cs_analysis(@builtin(local_invocation_index) lane : u32) {
56
66
  }
57
67
 
58
68
  partialLum[lane] = sumLum;
69
+ partialTone[lane] = sumTone;
59
70
  partialSq[lane] = sumSq;
60
71
  partialEdge[lane] = sumEdge;
61
72
  workgroupBarrier();
@@ -66,6 +77,7 @@ fn cs_analysis(@builtin(local_invocation_index) lane : u32) {
66
77
  if (stride == 0u) { break; }
67
78
  if (lane < stride) {
68
79
  partialLum[lane] = partialLum[lane] + partialLum[lane + stride];
80
+ partialTone[lane] = partialTone[lane] + partialTone[lane + stride];
69
81
  partialSq[lane] = partialSq[lane] + partialSq[lane + stride];
70
82
  partialEdge[lane] = partialEdge[lane] + partialEdge[lane + stride];
71
83
  }
@@ -76,13 +88,38 @@ fn cs_analysis(@builtin(local_invocation_index) lane : u32) {
76
88
  if (lane == 0u) {
77
89
  let count = max(f32(total), 1.0);
78
90
  let mean = partialLum[0] / count;
79
- stats[0] = mean;
91
+ // stats[0] is the ENCODED-space mean; the host decodes it once (W9).
92
+ stats[0] = partialTone[0] / count;
80
93
  stats[1] = max(partialSq[0] / count - mean * mean, 0.0);
81
94
  stats[2] = partialEdge[0] / count;
82
95
  stats[3] = count;
83
96
  }
84
97
  }`;
85
98
 
99
+ // ../policy/dist/refraction.js
100
+ var REFRACTION_LADDER = ["none", "approximate", "true"];
101
+ function refractionRank(quality) {
102
+ return REFRACTION_LADDER.indexOf(quality);
103
+ }
104
+ function accessibilityRefractionCap(policy) {
105
+ switch (policy.refraction) {
106
+ case "nominal":
107
+ return "true";
108
+ case "reduced":
109
+ return "approximate";
110
+ case "none":
111
+ return "none";
112
+ }
113
+ }
114
+ function effectiveRefraction(a, b) {
115
+ return refractionRank(a) <= refractionRank(b) ? a : b;
116
+ }
117
+ var DEFAULT_REFRACTION_SCALE = {
118
+ none: 0,
119
+ approximate: 0.45,
120
+ true: 1
121
+ };
122
+
86
123
  // ../renderer-webgpu/dist/color.js
87
124
  var BACKDROP_COLOR_SPACES = ["srgb", "display-p3"];
88
125
  var BACKDROP_ALPHA_MODES = ["premultiplied", "unpremultiplied", "opaque"];
@@ -161,26 +198,18 @@ function encodeOutputBytes(linear, alpha) {
161
198
  }
162
199
 
163
200
  // ../renderer-webgpu/dist/material.js
164
- var REFRACTION_LADDER = ["none", "approximate", "true"];
165
- function refractionRank(quality) {
166
- return REFRACTION_LADDER.indexOf(quality);
167
- }
168
- function accessibilityRefractionCap(policy) {
169
- switch (policy.refraction) {
170
- case "nominal":
171
- return "true";
172
- case "reduced":
173
- return "approximate";
174
- case "none":
175
- return "none";
176
- }
177
- }
178
- function effectiveRefraction(a, b) {
179
- return refractionRank(a) <= refractionRank(b) ? a : b;
180
- }
201
+ var NOMINAL_MATERIAL_POLICY = {
202
+ glass: "material",
203
+ frost: "nominal",
204
+ refraction: "nominal",
205
+ occlusion: "nominal",
206
+ border: "nominal",
207
+ ambientTint: "nominal",
208
+ foreground: "adaptive"
209
+ };
181
210
  var MATERIAL_VARIANTS = ["regular", "clear"];
182
211
  var SRGB_WHITE_TINT = [1, 1, 1];
183
- var INCREASED_OCCLUSION_LIFT = 0.4722;
212
+ var INCREASED_OCCLUSION_LIFT = 0.75;
184
213
  function occlusionAlphaUnderPolicy(nominal, occlusion, lift = INCREASED_OCCLUSION_LIFT) {
185
214
  switch (occlusion) {
186
215
  case "nominal":
@@ -197,24 +226,53 @@ var DEFAULT_MATERIAL_PROFILE = {
197
226
  // `platform-web`'s CSS-tier blur on the same number — and makes core's 24 px
198
227
  // `samplingPadding` advisory exactly the 3σ S1 measured.
199
228
  regular: {
200
- blurSigma: 8,
229
+ /*
230
+ * REFITTED (recalibration cascade, 2026-08-31) against the ACTIVE-pose bed.
231
+ * C9a's 0.62 and σ = 8 were fitted against the inactive material and are
232
+ * kept in git history, not here.
233
+ *
234
+ * `blurSigma` 3 is the first value this constant has ever had that the
235
+ * fixtures could identify. §6.1 called it unidentifiable because the
236
+ * inactive reference passed almost no backdrop structure; the active one
237
+ * passes a great deal (interior standard deviation 0.1358 over the
238
+ * checkerboard against vitrea's 0.0176 at σ = 8), so the objective's spread
239
+ * term now bites — it triples from σ = 3 to σ = 6 at a fixed alpha.
240
+ *
241
+ * `tintAlpha` 0.46 is the minimiser over the eight tone-inert untinted rest
242
+ * calibration cells, a genuine interior optimum (0.38 → 0.16945, 0.46 →
243
+ * 0.15704, 0.54 → 0.17248) with ΔE and SSIM agreeing. It remains true that
244
+ * no single value is right for every scene: a lerp toward one tint colour
245
+ * implies an alpha of 0.24 over the checkerboard and 0.47 over the photo at
246
+ * the same 44 px span, which is the lerp-versus-multiply question C9a
247
+ * recorded and nobody has acted on. Claims §5.13.
248
+ */
249
+ // 1.25 since W11c G1 (claims §5.41): the reference's interior over
250
+ // structured content is a sharp component near σ 1.25 plus a heavy one
251
+ // the scatter facet now supplies (`sizeScatterFloor`); the cascade's 3
252
+ // (claims §5.16) was the one Gaussian that best split the difference on
253
+ // a bed with no pitch axis to tell the two apart.
254
+ blurSigma: 1.25,
201
255
  tint: srgbToLinear(SRGB_WHITE_TINT),
202
- // MEASURED (C9a), against apple-macos-26.5-1x-light-standard. The advisory
203
- // 0.28 made vitrea's regular material roughly half as opaque as the
204
- // reference: regressing interior level against backdrop level across the
205
- // calibration scenes puts Apple's transmission at 0.26 of the backdrop
206
- // where vitrea's was 0.70. 0.62 is the minimiser of the declared tuning
207
- // objective; see docs/doperpowers/specs/c9a-fidelity-claims.md, and note
208
- // that no single value can be right for every size — Apple's opacity falls
209
- // with surface span (0.88 at 32 px to 0.56 at 96 px) and this renderer has
210
- // no size term on the tint.
211
- tintAlpha: 0.62,
256
+ tintAlpha: 0.46,
212
257
  rimWidth: 1.5,
213
258
  rimAlpha: 0.18,
214
259
  specularPower: 6,
215
260
  specularGain: 0.55,
216
261
  shadowDepth: 0.35,
217
- shadowAlpha: 0.55,
262
+ /*
263
+ * REFITTED 0.55 → 0.05 (2026-08-31), and it is the largest single
264
+ * improvement in the cascade: the objective falls 0.14615 → 0.09333 with
265
+ * ΔE and SSIM both improving.
266
+ *
267
+ * The active reference's contour is BRIGHTER than its own body — a rim peak
268
+ * of 0.025…0.129 above baseline, always at one pixel deep. vitrea's read
269
+ * 0.0000 with its peak 8-12 px in, meaning it had no edge feature at all:
270
+ * the inner shadow at 0.55 was darkening the contour faster than the rim lit
271
+ * it. The rim constants themselves did not move; the thing suppressing them
272
+ * did. On the inactive bed the reference's rim was 0.0000…0.0041 and this
273
+ * was invisible, which is what §6.2 recorded as "below quantisation".
274
+ */
275
+ shadowAlpha: 0.05,
218
276
  highlight: srgbToLinear(SRGB_WHITE_TINT)
219
277
  },
220
278
  // Persistently more transparent, so it frosts less and tints less.
@@ -260,20 +318,311 @@ var DEFAULT_MATERIAL_PROFILE = {
260
318
  adaptiveTintLight: srgbToLinear(SRGB_WHITE_TINT),
261
319
  adaptiveLuminanceLow: 0.12,
262
320
  adaptiveLuminanceHigh: 0.42,
263
- refractionScale: {
264
- none: 0,
265
- approximate: 0.45,
266
- true: 1
267
- },
268
- lensSpanMin: 28,
269
- lensSpanMax: 420,
321
+ // Authored in `@vitrea/policy` alongside the ladder it is keyed by, because the
322
+ // CSS tier needs the same table and used to hold two more copies of it
323
+ // (Decision Log #23(d)). The profile is still what the shaders read and still
324
+ // what `withMaterialOverrides` replaces — only the *default* literal moved, and
325
+ // it moved unchanged.
326
+ refractionScale: DEFAULT_REFRACTION_SCALE,
327
+ /*
328
+ * MEASURED (W2), against the settled apple-macos-26.5 bed, and set from the
329
+ * REFERENCE's own size-dependence rather than from the objective's minimum.
330
+ *
331
+ * The band was 28…420 while it served the lens alone and nothing had measured
332
+ * it, which put the whole canonical range (32…160 px) inside the first 17% of
333
+ * the curve. What the settled reference actually does, over a fixed checkerboard
334
+ * backdrop: it passes 0.244 of the backdrop's contrast at a 32 px span, 0.230 at
335
+ * 44 px and 0.144 at 96 px. So 6% of the movement happens between 32 and 44 and
336
+ * the rest between 44 and 96, and it is finished by 96 — which is what these two
337
+ * numbers are. `sizeSpanMin` is also the smallest canonical component's span, so
338
+ * that component is the law's exact zero by construction.
339
+ *
340
+ * The tuning objective would rather have 64 (0.1568 against 0.1637 on the rest
341
+ * cells, a 4% win inside a grid that spans 1.07×). Declined, and the reason is
342
+ * recorded rather than the win taken: the gain comes entirely from the interior
343
+ * *level* term, whose residual is the backdrop tone-adaptation gap that wave
344
+ * child W7 is chartered to close — so a band fitted to 64 would be using the
345
+ * size law as a proxy for a mechanism vitrea does not have yet, against the
346
+ * reference's own measurement of where its size-dependence lives, and W7 would
347
+ * have to unpick it. See the claims doc's size-law section.
348
+ */
349
+ sizeSpanMin: 32,
350
+ sizeSpanMax: 96,
270
351
  lensSizeGainMax: 2.6,
271
- lensBodyLodPerPx: 0.16,
272
- lensRimLodBias: 2.5,
352
+ /*
353
+ * MEASURED (W2), each with its own status — see the fields' notes.
354
+ *
355
+ * `sizeScatterGainMax` = 1: implemented on both tiers and inert, because the
356
+ * canonical fixtures cannot resolve it. The objective is flat to 1.00× over
357
+ * gains 1…6, and that is not the backdrop chain's depth talking: re-run with the
358
+ * chain deepened (MIN_LEVEL_EXTENT 8 → 4, which lifts the reachable σ on the
359
+ * 320×200 canvas from 1.2× to 2.4×) the grid is still flat to 1.00× and still
360
+ * best at 1. A number the fixtures cannot see is a number that will be met by
361
+ * accident, so this ships at the identity with the mechanism in place.
362
+ *
363
+ * `sizeOcclusionGain` = 0: fitted, and the fit is a boundary optimum with real
364
+ * leverage against it — the objective rises monotonically, 0.168 → 0.224 across
365
+ * 0…0.5, a 1.33× spread. The diagnosis is not a tuning failure: vitrea's
366
+ * interior sits 0.16–0.19 above the reference's at every span because the
367
+ * reference's level is set by backdrop tone adaptation toward roughly 0.63 while
368
+ * vitrea lerps toward a white tint, so making a large surface *more* opaque can
369
+ * only take it further from the reference. The facet is Apple's ("a larger size
370
+ * is more opaque"); the axis that would let it fit is W7's.
371
+ *
372
+ * `sizeShadowGainMax` = 1.4: fitted on the calibration REST cells, where the
373
+ * grid 1.0/1.2/1.4/1.6/1.8/2.2 reads 0.1577/0.1551/0.1533/0.1530/0.1529/0.1531.
374
+ * 1.4 through 2.2 are one flat region (0.25% apart); 1.4 is the point inside it
375
+ * that costs the checks least (ΔE 0.01282 against the baseline's 0.01290, SSIM
376
+ * 0.9671 against 0.9689) and it is the per-cell minimum on both well-conditioned
377
+ * span-96 rest cells. The pressed cells prefer 2.4 monotonically and are
378
+ * excluded from the fit on §6.3's grounds — their native side carries no press
379
+ * pose, so they compare two different states and cannot arbitrate a material
380
+ * constant.
381
+ *
382
+ * ## Re-fitted against the ACTIVE bed (2026-08-31)
383
+ *
384
+ * `sizeScatterGainMax` stays 1 and is now POSITIVELY identified rather than
385
+ * flat: with `blurSigma` refitted to 3 the objective rises 6-7% at gains 2 and
386
+ * 4, so the identity is the optimum instead of a tie.
387
+ *
388
+ * `sizeOcclusionGain` stays 0, a boundary result for the fourth time and for a
389
+ * fourth distinct reason. The reasoning above is retired: the per-cell residual
390
+ * now points the gain's way in every backdrop (web − reference falls with span
391
+ * — checkerboard +0.105/+0.112/+0.046 at 32/44/96 px, photo −0.004/−0.087,
392
+ * light-solid −0.028/+0.001), so the facet finally has the right sign. What it
393
+ * does not have is magnitude: over 0/0.05/0.10/0.15 × spans ending 96/128/160
394
+ * the whole grid is flat to 1.04×, and a 0.2% preference is not evidence. It
395
+ * ships at the identity because the bed cannot identify it, not because the
396
+ * mechanism is wrong.
397
+ *
398
+ * ## Re-fitted against the FROZEN bed (2026-09-01)
399
+ *
400
+ * `sizeOcclusionGain` 0 → **0.05**, and this is the first time the bed could
401
+ * see it. Every earlier fit was a boundary result because calibration held no
402
+ * span above `sizeSpanMax`, so the curve this gain rides was saturated at every
403
+ * cell that could vote. The frozen bed adds three span-128 `rrect-ml`
404
+ * calibration cells, and with them the grid stops being flat: 0.05 is the
405
+ * optimum at **both** backing scales independently — 1× 0.08288 against the
406
+ * identity's 0.08374, 2× 0.09338 against 0.09557 — and ΔE and SSIM move the
407
+ * same way at both.
408
+ *
409
+ * It is still a small number and it is reported as one: a 1.0% preference at
410
+ * 1×, against the 0.2% this doc rightly refused a round ago. What changed is
411
+ * not the margin's size but that it now reproduces on an independent profile
412
+ * instead of resting on one flat grid.
413
+ *
414
+ * `sizeSpanMax` stays 96, and that is now a measurement rather than an
415
+ * assumption. With a span-128 cell voting, the grid rises monotonically —
416
+ * 96/112/128/144/160/192 reading 0.0837/0.0858/0.0943/0.1060/0.1171/0.1300 —
417
+ * so the band's top is where it was, and the suspicion that the bed simply
418
+ * could not see above it is answered rather than inherited.
419
+ *
420
+ * `sizeShadowGainMax` 1.4 → 1. Its evidence dissolved rather than reversing:
421
+ * this is a gain on the INNER SHADOW, and the inner shadow was refitted from
422
+ * `shadowAlpha` 0.55 to 0.05, so there is almost nothing left for it to gain
423
+ * on. The grid 1.0/1.4/2.2 is flat to 0.2%. Carrying a fitted-looking 1.4 whose
424
+ * measurement no longer exists would be the worse of the two errors.
425
+ */
426
+ // MEASURED (W11c G1, claims §5.41): the scatter facet off the identity, on
427
+ // its own curve — see `MaterialProfile.sizeScatterFloor`. The paragraph
428
+ // above records why the identity was the right answer while `blurSigma`
429
+ // was 3 and the bed had no pitch axis; the W9 probe's pitch axis is what
430
+ // identified the two-component interior this expresses.
431
+ sizeScatterGainMax: 8,
432
+ sizeScatterFloor: 0.4,
433
+ sizeScatterSpanMax: 256,
434
+ sizeOcclusionGain: 0.05,
435
+ sizeShadowGainMax: 1,
436
+ // MEASURED (W11c G2, claims §5.43): the reference band's inward source
437
+ // displacement is 33 px at the contour on a 20.8 px lens depth — 1.6 lens
438
+ // depths — following the shader's own (1 − depth)² profile (per-shell fits
439
+ // on the probe's pitch-32/64 cells, spans 96/128 fitted, 160 held out; the
440
+ // gain's sweep 1.4/1.6/1.8/2.4 reads 0.0487/0.0478/0.0516/0.0567 at the
441
+ // shader's depth and profile). The band folds the plate from 1.6 lens depths
442
+ // inward; nothing in it is sharper or darker than the interior.
443
+ lensRefractionGain: 1.6,
273
444
  reducedTransparencyFrost: 1.75,
274
445
  increasedOcclusionLift: INCREASED_OCCLUSION_LIFT,
275
446
  strongBorderRim: { rimWidth: 2, rimAlpha: 0.95 },
276
447
  reducedTintAdaptation: 0.35,
448
+ /*
449
+ * MEASURED (W10, 2026-09-02) — per-pixel least squares of the reference's
450
+ * tinted pixel against its own untinted pixel on the five W9-probe tinted
451
+ * checkerboard cells (pitch 4…64 px, 17 700 px): shade = 0.5289 + 0.4886·u,
452
+ * RMS 0.0035. Out of sample on the canonical bed the orange cells fit at
453
+ * RMS 0.003 with zero bias; blue sits 0.011 darker (the second hue's
454
+ * residual, recorded and unmodelled). The light end extrapolates past 1 and
455
+ * is clamped in `tintShade`. Claims §5.36.
456
+ *
457
+ * §5.13's earlier "the curve is the identity" fit was made on the material's
458
+ * MEAN over solid backdrops, where u is either ~0.97 (shade 1.0) or the
459
+ * collapse has already folded the shade out — the identity was the law's
460
+ * two endpoints, seen without anything between them.
461
+ */
462
+ tintShadeDark: 0.5289,
463
+ tintShadeLight: 1.0175,
464
+ tintShadeStrength: 1,
465
+ /*
466
+ * MEASURED (W7), against the settled apple-macos-26.5 bed, on the light and
467
+ * dark standard profiles jointly and at both scales.
468
+ *
469
+ * The observable these four are set from is the one quantity in this bed that
470
+ * isolates adaptation from everything else: the *separation* between the light
471
+ * and dark references over the same backdrop, on the same component. Under this
472
+ * mechanism that separation is `(1 − tintAlpha)(1 − a)(tintLight − tintDark)`,
473
+ * so the material's transmission, the backdrop's structure and each scheme's
474
+ * own tint all cancel and what is left is `a`. Normalised at the checkerboard,
475
+ * where nothing adapts, it reads:
476
+ *
477
+ * span 44: 0.000 at backdrop 0.500, 0.030 at 0.205, 1.000 at 0.0117, 1.000 at 0.0049
478
+ * span 96: 0.000 at backdrop 0.500, 0.028 at 0.216, 0.256 at 0.0117
479
+ *
480
+ * and the 2× bed reproduces every one of those to three decimals (0.2553
481
+ * against 0.2556 on the one that is not a boundary). Two facts follow. The
482
+ * adaptation is off across the whole ordinary range and turns on only below
483
+ * roughly a fifth of the backdrop scale — so it cannot disturb a cell that
484
+ * already passes. And it is size-gated hard: same backdrop, same material, 1.000
485
+ * against 0.256.
486
+ *
487
+ * The band's dark end is not identifiable from this bed and the fit says so:
488
+ * the reference's backdrops jump from 0.0117 to 0.205 with nothing in between,
489
+ * so `backdropToneLow` is bounded only by "at or below the darkest calibration
490
+ * backdrop". What the two dark backdrops DO pin is the curve's slope near zero —
491
+ * the 96 px surface reads 0.256 at 0.0117 and 0.356 at 0.0039, which is a
492
+ * measured intermediate rather than a step, and it is what fixes
493
+ * `backdropToneSizeBias` against `backdropToneHigh`. `impulse__rrect-md__rest`
494
+ * is a VALIDATION cell and was not fitted to: the calibration set predicts
495
+ * 0.34 there and the cell reads 0.356.
496
+ */
497
+ /*
498
+ * RE-SCOPED (W9, claims §5.33). The four mix constants now own TEXTURE
499
+ * COLLAPSE alone — the interior mean moved to the response law below — and
500
+ * their band contracts to the one domain the collapse is real in: full at
501
+ * `dark-solid` (0.0117) for thin surfaces including the 44 px capsule
502
+ * (byte-identical collapse, arg 0.0164 ≤ low with margin), zero by
503
+ * `mid-dark-solid` (0.0595), where the reference's small surface keeps a
504
+ * textured body at 0.4561 and the old band's partial collapse (k = 0.81)
505
+ * was the measured 0.1375-vs-0.4561 overshoot. `impulse__rrect-md`
506
+ * (arg 0.0539) keeps k ≈ 0.003 against the old band's 0.008 — the same
507
+ * unadapted render that cell validated.
508
+ */
509
+ backdropToneMax: 1,
510
+ backdropToneLow: 0.02,
511
+ backdropToneHigh: 0.055,
512
+ /*
513
+ * REFITTED 0.09 → 0.13 (2026-08-31, active bed). The law's SHAPE is untouched
514
+ * (Decision Log 13 stands; the two-axis rework is next wave) and `max`, `low`
515
+ * and `high` are unmoved — only the size gate widened.
516
+ *
517
+ * What it changes is one cell: a 96 pt surface over the darkest backdrop now
518
+ * barely adapts, where at 0.09 it adapted by a quarter. Two independent reads
519
+ * of the reference disagreed about that and the validation set broke the tie.
520
+ * The light-versus-dark separation estimator §5.8 fitted on says the 96 pt
521
+ * surface adapts by 0.30; the reference's own interior LEVEL says it does not
522
+ * (0.4844, against 0.466 for an unadapted surface at the refitted tint alpha
523
+ * and 0.3566 for an adapted one). Measured once on
524
+ * `impulse__rrect-md__rest`, which is validation and was fitted to by
525
+ * neither: 0.09 renders 0.2858 against a reference of 0.4358 at ΔE 0.02344;
526
+ * 0.13 renders 0.4594 at ΔE 0.00378, six times better. The separation
527
+ * estimator's algebra assumes the two colour schemes share one tint alpha,
528
+ * and this profile pair does not (0.46 against 0.97) — recorded in §5.13 as
529
+ * the reason it is no longer the primary evidence for this constant.
530
+ *
531
+ * RE-SCOPED 0.13 → 0.05 with the band above (W9): the bias's one remaining
532
+ * job is keeping the thick surface's collapse argument above `high` at the
533
+ * dark anchor (0.0117 + 0.05 = 0.0617 > 0.055) while the thin capsule's
534
+ * stays below `low` — the near-binary size snap the probe measured
535
+ * (claims §5.33). The smooth size trend on structured backdrops, which the
536
+ * old wide band tried and failed to carry, belongs to the response law's
537
+ * thickness axis now.
538
+ */
539
+ backdropToneSizeBias: 0.05,
540
+ /*
541
+ * MEASURED (W9 probe, claims §5.30–§5.33): the anchors are the probe bed's
542
+ * settled reference levels, frequency-settled over seven attested runs,
543
+ * under the probe's own native-mask interior. Thick rows pool the 96 px
544
+ * and 160 px rrects (±0.012). Not tuned; re-measured only by a new probe.
545
+ */
546
+ backdropToneAnchorX: [0.1104, 0.2706, 0.9505],
547
+ backdropToneResponseThin: [0.0126, 0.4561, 0.9713],
548
+ backdropToneResponseThick: [0.4953, 0.5744, 0.9358],
549
+ backdropToneResponseStrength: 1,
550
+ /*
551
+ * FITTED (recalibration cascade, 2026-08-31). W8's geometry SURVIVES the fit
552
+ * unchanged — `sigmaPx` 15.55, `offsetPx` 7.95, `spreadPx` 3.10 — and only the
553
+ * two amplitudes moved: `occlusion` 0.33 → 0.285 and
554
+ * `reducedTransparencyOcclusion` 0.566 → 0.70. The dark scheme's amplitude
555
+ * (0.09) lands as a profile patch, not as a branch here.
556
+ *
557
+ * The fit is `scripts/sweep.ts --objective shadow`, whose term is the mean over
558
+ * calibration cells of |Δ meanDeparture| — the light each side removes from the
559
+ * whole exterior, in linear light, which is the one commensurate quantity the
560
+ * facet has. The amplitude is a genuine interior optimum over 0.18…0.44 (a
561
+ * 2.23× spread) and flat to 1.04% across 0.255…0.315, which is exactly the
562
+ * scene-to-scene amplitude spread the reference itself shows. The geometry is
563
+ * NOT identifiable against this objective — σ ∈ {13.5, 15.55, 17.5} × offset ∈
564
+ * {6.5, 7.95, 9.4} spans 1.04× with 15.55 the argmin — because an integral over
565
+ * the exterior is insensitive to how the darkening is distributed within it.
566
+ * W8's own two-dimensional fit against the occlusion field measures the
567
+ * geometry far more powerfully (RMS 0.0021 over 142,550 pixels), and the
568
+ * instrument's independent shadow axis agrees, so the geometry stands on those
569
+ * two and this objective is not asked to re-decide it.
570
+ *
571
+ * `reducedTransparencyOcclusion` 0.70 is sharp where the amplitude is flat: a
572
+ * 3.83× spread over 0.45…0.95 with a clear minimum. It also reconciles two
573
+ * routes — 0.285 × 0.70 = 0.1995 against the reference's directly measured
574
+ * reduce-transparency amplitude of 0.203 at a 44 px span. W8's 0.566 was the
575
+ * ratio of the reference's two amplitudes; this is the ratio that makes
576
+ * vitrea's shadow match the reference's under the preference, and the two
577
+ * differ because the base amplitude is a compromise across scenes whose spread
578
+ * is much wider in the standard profile than under reduced transparency.
579
+ *
580
+ * Extracted from the active bed's native fixtures directly —
581
+ * `results/2026-08-31-active-bed-stage0.json` measured the gap, these numbers
582
+ * measure the facet — and left provisional deliberately: X1 gives the fit to
583
+ * the recalibration cascade, which owns the holdout discipline. This child owns
584
+ * the mechanism.
585
+ *
586
+ * Method: for each `rest` fixture, the occlusion field `1 − L_capture/L_background`
587
+ * in linear Rec.709 luminance, over every pixel outside the declared component
588
+ * geometry with a backdrop bright enough to carry a signal; fitted in two
589
+ * dimensions against `occlusion · Φ(−sd/σ)`, where `sd` is the signed distance
590
+ * to the component's own rounded silhouette translated down by `offsetPx` and
591
+ * outset by `spreadPx`.
592
+ *
593
+ * The fit and its residual, on `1x-light-standard` and `2x-light-standard`:
594
+ *
595
+ * backdrop span occlusion σ (1×/2×) offset (1×/2×) RMS
596
+ * photo 44 0.323 15.5/31.1 7.9/15.8 0.0021
597
+ * checkerboard 44 0.338 15.7/31.0 8.0/15.9 0.0016
598
+ * hc-text 44 0.339 15.7/31.1 8.0/15.9 0.0014
599
+ * photo 32 0.321 15.5/30.9 8.1/16.2 0.0018
600
+ * mid-dark-solid 44 0.310 — — 0.0079
601
+ *
602
+ * — against a peak occlusion of 0.24, so the model carries the facet to under
603
+ * 1% of its own amplitude, over up to 142,550 pixels per cell. The three
604
+ * lengths are the consensus across every well-conditioned cell; `occlusion` is
605
+ * the light-standard amplitude at a span the size law leaves alone.
606
+ *
607
+ * What the amplitude does NOT yet have is a mechanism for its scene-to-scene
608
+ * spread. Over the flat near-white `light-solid` backdrop the same fit reads
609
+ * 0.123 rather than 0.33, reproducibly and at both scales, while the other flat
610
+ * backdrop (`mid-dark-solid`, linear 0.0595) reads 0.310 — so it is not a
611
+ * function of the backdrop's luminance, its structure, or the material's
612
+ * interior level, and no compositing model in either colour space produces both.
613
+ * The dark-scheme profile is a separate amplitude entirely (0.046…0.061 at a
614
+ * 44 px span — the dark material's shadow is nearly invisible) and lands as a
615
+ * profile patch, not as a branch here. Both are stated in the claims doc as the
616
+ * open question the cascade's fit inherits.
617
+ */
618
+ outerShadow: {
619
+ offsetPx: 7.95,
620
+ sigmaPx: 15.55,
621
+ spreadPx: 3.1,
622
+ occlusion: 0.285,
623
+ reducedTransparencyOcclusion: 0.7,
624
+ sizeGain: 0
625
+ },
277
626
  lightDirection: [-0.3714, -0.9285],
278
627
  sweepBandRadians: 0.55,
279
628
  glowRadiusCss: 44,
@@ -286,11 +635,24 @@ var ADAPTIVE_TINT_DARK = DEFAULT_MATERIAL_PROFILE.adaptiveTintDark;
286
635
  var ADAPTIVE_TINT_LIGHT = DEFAULT_MATERIAL_PROFILE.adaptiveTintLight;
287
636
  var ADAPTIVE_LUMINANCE_LOW = DEFAULT_MATERIAL_PROFILE.adaptiveLuminanceLow;
288
637
  var ADAPTIVE_LUMINANCE_HIGH = DEFAULT_MATERIAL_PROFILE.adaptiveLuminanceHigh;
289
- var LENS_SPAN_MIN = DEFAULT_MATERIAL_PROFILE.lensSpanMin;
290
- var LENS_SPAN_MAX = DEFAULT_MATERIAL_PROFILE.lensSpanMax;
638
+ var SIZE_SPAN_MIN = DEFAULT_MATERIAL_PROFILE.sizeSpanMin;
639
+ var SIZE_SPAN_MAX = DEFAULT_MATERIAL_PROFILE.sizeSpanMax;
291
640
  var LENS_SIZE_GAIN_MAX = DEFAULT_MATERIAL_PROFILE.lensSizeGainMax;
292
- var LENS_BODY_LOD_PER_PX = DEFAULT_MATERIAL_PROFILE.lensBodyLodPerPx;
293
- var LENS_RIM_LOD_BIAS = DEFAULT_MATERIAL_PROFILE.lensRimLodBias;
641
+ var SIZE_SCATTER_GAIN_MAX = DEFAULT_MATERIAL_PROFILE.sizeScatterGainMax;
642
+ var SIZE_SCATTER_FLOOR = DEFAULT_MATERIAL_PROFILE.sizeScatterFloor;
643
+ var SIZE_SCATTER_SPAN_MAX = DEFAULT_MATERIAL_PROFILE.sizeScatterSpanMax;
644
+ var SIZE_OCCLUSION_GAIN = DEFAULT_MATERIAL_PROFILE.sizeOcclusionGain;
645
+ var SIZE_SHADOW_GAIN_MAX = DEFAULT_MATERIAL_PROFILE.sizeShadowGainMax;
646
+ var LENS_REFRACTION_GAIN = DEFAULT_MATERIAL_PROFILE.lensRefractionGain;
647
+ var BACKDROP_TONE_MAX = DEFAULT_MATERIAL_PROFILE.backdropToneMax;
648
+ var BACKDROP_TONE_LOW = DEFAULT_MATERIAL_PROFILE.backdropToneLow;
649
+ var BACKDROP_TONE_HIGH = DEFAULT_MATERIAL_PROFILE.backdropToneHigh;
650
+ var BACKDROP_TONE_SIZE_BIAS = DEFAULT_MATERIAL_PROFILE.backdropToneSizeBias;
651
+ DEFAULT_MATERIAL_PROFILE.backdropToneAnchorX;
652
+ DEFAULT_MATERIAL_PROFILE.backdropToneResponseThin;
653
+ DEFAULT_MATERIAL_PROFILE.backdropToneResponseThick;
654
+ DEFAULT_MATERIAL_PROFILE.backdropToneResponseStrength;
655
+ var OUTER_SHADOW = DEFAULT_MATERIAL_PROFILE.outerShadow;
294
656
  function withMaterialOverrides(base, patch) {
295
657
  const optics = {};
296
658
  for (const variant of MATERIAL_VARIANTS) {
@@ -307,15 +669,31 @@ function withMaterialOverrides(base, patch) {
307
669
  adaptiveLuminanceLow: patch.adaptiveLuminanceLow ?? base.adaptiveLuminanceLow,
308
670
  adaptiveLuminanceHigh: patch.adaptiveLuminanceHigh ?? base.adaptiveLuminanceHigh,
309
671
  refractionScale,
310
- lensSpanMin: patch.lensSpanMin ?? base.lensSpanMin,
311
- lensSpanMax: patch.lensSpanMax ?? base.lensSpanMax,
672
+ sizeSpanMin: patch.sizeSpanMin ?? base.sizeSpanMin,
673
+ sizeSpanMax: patch.sizeSpanMax ?? base.sizeSpanMax,
312
674
  lensSizeGainMax: patch.lensSizeGainMax ?? base.lensSizeGainMax,
313
- lensBodyLodPerPx: patch.lensBodyLodPerPx ?? base.lensBodyLodPerPx,
314
- lensRimLodBias: patch.lensRimLodBias ?? base.lensRimLodBias,
675
+ sizeScatterGainMax: patch.sizeScatterGainMax ?? base.sizeScatterGainMax,
676
+ sizeScatterFloor: patch.sizeScatterFloor ?? base.sizeScatterFloor,
677
+ sizeScatterSpanMax: patch.sizeScatterSpanMax ?? base.sizeScatterSpanMax,
678
+ sizeOcclusionGain: patch.sizeOcclusionGain ?? base.sizeOcclusionGain,
679
+ sizeShadowGainMax: patch.sizeShadowGainMax ?? base.sizeShadowGainMax,
680
+ lensRefractionGain: patch.lensRefractionGain ?? base.lensRefractionGain,
315
681
  reducedTransparencyFrost: patch.reducedTransparencyFrost ?? base.reducedTransparencyFrost,
316
682
  increasedOcclusionLift: patch.increasedOcclusionLift ?? base.increasedOcclusionLift,
317
683
  strongBorderRim: { ...base.strongBorderRim, ...patch.strongBorderRim },
318
684
  reducedTintAdaptation: patch.reducedTintAdaptation ?? base.reducedTintAdaptation,
685
+ tintShadeDark: patch.tintShadeDark ?? base.tintShadeDark,
686
+ tintShadeLight: patch.tintShadeLight ?? base.tintShadeLight,
687
+ tintShadeStrength: patch.tintShadeStrength ?? base.tintShadeStrength,
688
+ backdropToneMax: patch.backdropToneMax ?? base.backdropToneMax,
689
+ backdropToneLow: patch.backdropToneLow ?? base.backdropToneLow,
690
+ backdropToneHigh: patch.backdropToneHigh ?? base.backdropToneHigh,
691
+ backdropToneSizeBias: patch.backdropToneSizeBias ?? base.backdropToneSizeBias,
692
+ backdropToneAnchorX: patch.backdropToneAnchorX ?? base.backdropToneAnchorX,
693
+ backdropToneResponseThin: patch.backdropToneResponseThin ?? base.backdropToneResponseThin,
694
+ backdropToneResponseThick: patch.backdropToneResponseThick ?? base.backdropToneResponseThick,
695
+ backdropToneResponseStrength: patch.backdropToneResponseStrength ?? base.backdropToneResponseStrength,
696
+ outerShadow: { ...base.outerShadow, ...patch.outerShadow },
319
697
  lightDirection: patch.lightDirection ?? base.lightDirection,
320
698
  sweepBandRadians: patch.sweepBandRadians ?? base.sweepBandRadians,
321
699
  glowRadiusCss: patch.glowRadiusCss ?? base.glowRadiusCss,
@@ -350,21 +728,162 @@ function adaptationStrength(policy, analysisExact, profile = DEFAULT_MATERIAL_PR
350
728
  return 0;
351
729
  }
352
730
  }
731
+ function tintToneAdaptation(policy, profile = DEFAULT_MATERIAL_PROFILE) {
732
+ switch (policy.ambientTint) {
733
+ case "nominal":
734
+ return 1;
735
+ case "reduced":
736
+ return profile.reducedTintAdaptation;
737
+ case "none":
738
+ return 0;
739
+ }
740
+ }
741
+ function tintShade(materialLuminance, grip, profile = DEFAULT_MATERIAL_PROFILE) {
742
+ const u = Math.min(1, Math.max(0, materialLuminance));
743
+ const shade = Math.min(1, Math.max(0, profile.tintShadeDark + (profile.tintShadeLight - profile.tintShadeDark) * u));
744
+ const k = Math.min(1, Math.max(0, grip));
745
+ return 1 + (shade - 1) * k;
746
+ }
747
+ function tintShadeLayer(seed, materialLuminance, grip, profile = DEFAULT_MATERIAL_PROFILE) {
748
+ const shade = tintShade(materialLuminance, grip, profile);
749
+ return [seed[0] * shade, seed[1] * shade, seed[2] * shade];
750
+ }
751
+ function tintedMaterialColour(material, tint, grip, profile = DEFAULT_MATERIAL_PROFILE) {
752
+ if (tint === void 0 || tint.strength <= 0)
753
+ return material;
754
+ const s = Math.min(1, Math.max(0, tint.strength));
755
+ const layer = tintShadeLayer(tint.color, relativeLuminance(material), grip, profile);
756
+ const clamp01 = (value) => Math.min(1, Math.max(0, value));
757
+ const channel = (index) => {
758
+ const from = linearToSrgbChannel(clamp01(material[index]));
759
+ const to = linearToSrgbChannel(clamp01(layer[index]));
760
+ return srgbToLinearChannel(from + (to - from) * s);
761
+ };
762
+ return [channel(0), channel(1), channel(2)];
763
+ }
764
+ function backdropToneAdaptation(backdropLuminance, thickness, profile = DEFAULT_MATERIAL_PROFILE) {
765
+ const x = backdropLuminance + profile.backdropToneSizeBias * Math.min(1, Math.max(0, thickness));
766
+ const span = Math.max(profile.backdropToneHigh - profile.backdropToneLow, 1e-6);
767
+ const t = Math.min(1, Math.max(0, (x - profile.backdropToneLow) / span));
768
+ return Math.min(1, Math.max(0, profile.backdropToneMax)) * (1 - t * t * (3 - 2 * t));
769
+ }
770
+ function backdropToneUnderPolicy(policy, profile = DEFAULT_MATERIAL_PROFILE) {
771
+ return tintToneAdaptation(policy, profile) * profile.refractionScale[accessibilityRefractionCap(policy)];
772
+ }
773
+ function backdropToneSizeBiasUnderPolicy(policy, profile = DEFAULT_MATERIAL_PROFILE) {
774
+ const cap = profile.refractionScale[accessibilityRefractionCap(policy)];
775
+ return cap <= 0 ? 0 : profile.backdropToneSizeBias / cap;
776
+ }
777
+ function adaptedTintColour(neutral, backdrop, adaptation, tintAlpha) {
778
+ const k = Math.min(1, Math.max(0, adaptation));
779
+ if (k === 0)
780
+ return neutral;
781
+ const alpha = adaptedTintAlpha(tintAlpha, k);
782
+ if (alpha <= 0)
783
+ return neutral;
784
+ const wNeutral = (1 - k) * tintAlpha;
785
+ return [
786
+ (neutral[0] * wNeutral + backdrop[0] * k) / alpha,
787
+ (neutral[1] * wNeutral + backdrop[1] * k) / alpha,
788
+ (neutral[2] * wNeutral + backdrop[2] * k) / alpha
789
+ ];
790
+ }
791
+ function adaptedTintAlpha(tintAlpha, adaptation) {
792
+ const k = Math.min(1, Math.max(0, adaptation));
793
+ return tintAlpha + k * (1 - tintAlpha);
794
+ }
353
795
  var smoothstep = (edge0, edge1, x) => {
354
796
  if (edge1 <= edge0)
355
797
  return x < edge0 ? 0 : 1;
356
798
  const t = Math.min(1, Math.max(0, (x - edge0) / (edge1 - edge0)));
357
799
  return t * t * (3 - 2 * t);
358
800
  };
801
+ function sizeThickness(spanPx, profile = DEFAULT_MATERIAL_PROFILE) {
802
+ return smoothstep(profile.sizeSpanMin, profile.sizeSpanMax, spanPx);
803
+ }
804
+ function sizeThicknessUnderPolicy(spanPx, policy, profile = DEFAULT_MATERIAL_PROFILE) {
805
+ return sizeThickness(spanPx, profile) * profile.refractionScale[accessibilityRefractionCap(policy)];
806
+ }
359
807
  function lensSizeGain(spanPx, profile = DEFAULT_MATERIAL_PROFILE) {
360
- return 1 + (profile.lensSizeGainMax - 1) * smoothstep(profile.lensSpanMin, profile.lensSpanMax, spanPx);
808
+ return 1 + (profile.lensSizeGainMax - 1) * sizeThickness(spanPx, profile);
809
+ }
810
+ function lensSizeGainFromThickness(thickness, profile = DEFAULT_MATERIAL_PROFILE) {
811
+ return 1 + (profile.lensSizeGainMax - 1) * thickness;
812
+ }
813
+ function sizeScatterSigma(sigmaPx, spanPx, profile = DEFAULT_MATERIAL_PROFILE) {
814
+ return sizeScatterSigmaAt(sigmaPx, scatterThickness(spanPx, 1, profile), profile);
815
+ }
816
+ function scatterThickness(spanPx, fold, profile = DEFAULT_MATERIAL_PROFILE) {
817
+ const floor = Math.min(1, Math.max(0, profile.sizeScatterFloor));
818
+ return floor + (1 - floor) * smoothstep(profile.sizeSpanMin, profile.sizeScatterSpanMax, spanPx) * fold;
819
+ }
820
+ function sizeScatterSigmaAt(sigmaPx, scatter, profile = DEFAULT_MATERIAL_PROFILE) {
821
+ return sigmaPx * (1 + (profile.sizeScatterGainMax - 1) * scatter);
822
+ }
823
+ function sizeOcclusionAlpha(alpha, spanPx, profile = DEFAULT_MATERIAL_PROFILE) {
824
+ return sizeOcclusionAlphaAt(alpha, sizeThickness(spanPx, profile), profile);
825
+ }
826
+ function sizeOcclusionAlphaAt(alpha, thickness, profile = DEFAULT_MATERIAL_PROFILE) {
827
+ return Math.min(1, alpha + profile.sizeOcclusionGain * thickness * (1 - alpha));
828
+ }
829
+ function sizeShadowDepth(shadowDepth, spanPx, profile = DEFAULT_MATERIAL_PROFILE) {
830
+ return sizeShadowDepthAt(shadowDepth, sizeThickness(spanPx, profile), profile);
831
+ }
832
+ function sizeShadowDepthAt(shadowDepth, thickness, profile = DEFAULT_MATERIAL_PROFILE) {
833
+ return shadowDepth * (1 + (profile.sizeShadowGainMax - 1) * thickness);
834
+ }
835
+ function sizeOuterShadowOcclusion(occlusion, spanPx, profile = DEFAULT_MATERIAL_PROFILE) {
836
+ return sizeOuterShadowOcclusionAt(occlusion, sizeThickness(spanPx, profile), profile);
837
+ }
838
+ function sizeOuterShadowOcclusionAt(occlusion, thickness, profile = DEFAULT_MATERIAL_PROFILE) {
839
+ return Math.min(1, occlusion + profile.outerShadow.sizeGain * thickness * (1 - occlusion));
840
+ }
841
+ function outerShadowUnderPolicy(policy, profile = DEFAULT_MATERIAL_PROFILE) {
842
+ const shadow = profile.outerShadow;
843
+ if (policy.glass === "none" || policy.frost === "none")
844
+ return { ...shadow, occlusion: 0 };
845
+ if (policy.frost === "increased") {
846
+ return { ...shadow, occlusion: shadow.occlusion * shadow.reducedTransparencyOcclusion };
847
+ }
848
+ return shadow;
849
+ }
850
+ var SRGB_ENCODING_EXPONENT = 2.4;
851
+ function outerShadowFalloff(signedDistancePx, sigmaPx) {
852
+ const x = -signedDistancePx / Math.max(sigmaPx, 1e-4);
853
+ return 0.5 * (1 + Math.tanh(0.7978845608028654 * (x + 0.044715 * x * x * x)));
854
+ }
855
+ function outerShadowAlpha(occlusion) {
856
+ const occ = Math.min(1, Math.max(0, occlusion));
857
+ return 1 - Math.pow(1 - occ, 1 / SRGB_ENCODING_EXPONENT);
858
+ }
859
+ function outerShadowReachPx(shadow) {
860
+ const alpha = outerShadowAlpha(shadow.occlusion);
861
+ if (!(alpha > 0))
862
+ return 0;
863
+ const cutoff = 1 / 255 / alpha;
864
+ if (cutoff >= 1)
865
+ return 0;
866
+ const sigma = Math.max(shadow.sigmaPx, 1e-4);
867
+ let lo = -(8 * sigma + Math.abs(shadow.offsetPx) + Math.abs(shadow.spreadPx));
868
+ let hi = 8 * sigma;
869
+ for (let i = 0; i < 48; i += 1) {
870
+ const mid = (lo + hi) / 2;
871
+ if (outerShadowFalloff(mid, sigma) > cutoff)
872
+ lo = mid;
873
+ else
874
+ hi = mid;
875
+ }
876
+ return Math.max(0, hi + Math.abs(shadow.offsetPx) + shadow.spreadPx);
361
877
  }
362
878
  function lensDepthPx(thicknessPx, spanPx, profile = DEFAULT_MATERIAL_PROFILE) {
363
879
  const gain = lensSizeGain(spanPx, profile);
364
880
  return Math.min(Math.max(thicknessPx, 0) * gain, spanPx * 0.5);
365
881
  }
366
- function bodyLod(lensDepth, maxLod, profile = DEFAULT_MATERIAL_PROFILE) {
367
- return Math.min(Math.max(lensDepth * profile.lensBodyLodPerPx, 0), maxLod);
882
+ function lensDisplacementPx(depthPx, lensDepthPx2, refractionScale, profile = DEFAULT_MATERIAL_PROFILE) {
883
+ if (lensDepthPx2 <= 0)
884
+ return 0;
885
+ const depth = Math.min(Math.max(depthPx / lensDepthPx2, 0), 1);
886
+ return lensDepthPx2 * (1 - depth) * (1 - depth) * refractionScale * profile.lensRefractionGain;
368
887
  }
369
888
 
370
889
  // ../renderer-webgpu/dist/analysis.js
@@ -376,7 +895,11 @@ var ZERO_STATS = {
376
895
  };
377
896
  function statsFromBuffer(values) {
378
897
  return {
379
- luminance: values[0] ?? 0,
898
+ // The reduction publishes the ENCODED-space mean (the W9 model, claims
899
+ // §5.31) and this is its one decode: every consumer downstream — the
900
+ // drivers, `adaptiveTint`'s fitted thresholds — still speaks linear light,
901
+ // and a solid backdrop reads identically under either convention.
902
+ luminance: srgbToLinearChannel(values[0] ?? 0),
380
903
  variance: values[1] ?? 0,
381
904
  edgeDensity: values[2] ?? 0,
382
905
  sampleCount: values[3] ?? 0
@@ -850,6 +1373,44 @@ function createAppTextureProvider(options) {
850
1373
  };
851
1374
  }
852
1375
 
1376
+ // ../renderer-webgpu/dist/backdrop-fit.js
1377
+ var IDENTITY_FIT = [1, 1, 0, 0];
1378
+ function isUsablePlacement(placement) {
1379
+ return placement !== void 0 && Number.isFinite(placement.x) && Number.isFinite(placement.y) && placement.width > 0 && placement.height > 0;
1380
+ }
1381
+ function coverFit(sourceWidth, sourceHeight, viewportWidthCss, viewportHeightCss) {
1382
+ const viewportAspect = viewportHeightCss > 0 ? viewportWidthCss / viewportHeightCss : 1;
1383
+ const sourceAspect = sourceHeight > 0 ? sourceWidth / sourceHeight : 1;
1384
+ if (sourceAspect > viewportAspect) {
1385
+ const scaleX = viewportAspect / sourceAspect;
1386
+ return [scaleX, 1, (1 - scaleX) / 2, 0];
1387
+ }
1388
+ const scaleY = sourceAspect / viewportAspect;
1389
+ return [1, scaleY, 0, (1 - scaleY) / 2];
1390
+ }
1391
+ function placementFit(placement, viewportWidthCss, viewportHeightCss) {
1392
+ return [
1393
+ viewportWidthCss / placement.width,
1394
+ viewportHeightCss / placement.height,
1395
+ -placement.x / placement.width,
1396
+ -placement.y / placement.height
1397
+ ];
1398
+ }
1399
+ function backdropFit(sourceWidth, sourceHeight, placement, viewportWidthCss, viewportHeightCss) {
1400
+ return isUsablePlacement(placement) ? placementFit(placement, viewportWidthCss, viewportHeightCss) : coverFit(sourceWidth, sourceHeight, viewportWidthCss, viewportHeightCss);
1401
+ }
1402
+ function texelsPerCssPx(sourceWidth, sourceHeight, placement, viewportWidthCss, viewportHeightCss) {
1403
+ if (isUsablePlacement(placement)) {
1404
+ return sourceWidth > 0 ? sourceWidth / placement.width : 1;
1405
+ }
1406
+ return viewportWidthCss > 0 && viewportHeightCss > 0 ? Math.max(sourceWidth / viewportWidthCss, sourceHeight / viewportHeightCss) : 1;
1407
+ }
1408
+ function samePlacement(a, b) {
1409
+ if (a === void 0 || b === void 0)
1410
+ return a === b;
1411
+ return a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height;
1412
+ }
1413
+
853
1414
  // ../renderer-webgpu/dist/device.js
854
1415
  function createDeviceHost(options = {}) {
855
1416
  let status = {
@@ -1252,7 +1813,7 @@ var IDLE_CHANNELS = {
1252
1813
  };
1253
1814
 
1254
1815
  // ../renderer-webgpu/dist/instances.js
1255
- var INSTANCE_FLOATS = 16;
1816
+ var INSTANCE_FLOATS = 18;
1256
1817
  var INSTANCE_BYTES = INSTANCE_FLOATS * 4;
1257
1818
  var channelsOf = (input) => ({
1258
1819
  ...IDLE_CHANNELS,
@@ -1264,7 +1825,7 @@ function compressedChannels(shape, press) {
1264
1825
  const scale = 1 - Math.min(1, Math.max(0, press)) * DEFAULT_MOTION_PROFILE.pressCompressionScale;
1265
1826
  return { ...shape, size: [shape.size[0] * scale, shape.size[1] * scale] };
1266
1827
  }
1267
- function resolveSurfaces(group, family, profile = DEFAULT_MATERIAL_PROFILE) {
1828
+ function resolveSurfaces(group, family, profile = DEFAULT_MATERIAL_PROFILE, policy = NOMINAL_MATERIAL_POLICY) {
1268
1829
  const byId = /* @__PURE__ */ new Map();
1269
1830
  for (const surface of group.surfaces) {
1270
1831
  if (byId.has(surface.nodeId)) {
@@ -1308,6 +1869,7 @@ function resolveSurfaces(group, family, profile = DEFAULT_MATERIAL_PROFILE) {
1308
1869
  const fieldSource = surface.concentricOf === void 0 ? shape : resolveShapeOf(byId.get(surface.concentricOf.nodeId));
1309
1870
  const inset = surface.concentricOf?.inset ?? 0;
1310
1871
  const spanPx = Math.min(shape.channels.size[0], shape.channels.size[1]);
1872
+ const thickness = sizeThicknessUnderPolicy(spanPx, policy, profile);
1311
1873
  return {
1312
1874
  nodeId: surface.nodeId,
1313
1875
  shape,
@@ -1316,11 +1878,13 @@ function resolveSurfaces(group, family, profile = DEFAULT_MATERIAL_PROFILE) {
1316
1878
  centre: [fieldSource.channels.center[0], fieldSource.channels.center[1]],
1317
1879
  channels,
1318
1880
  spanPx,
1319
- lensDepthPx: lensDepthPx(shape.channels.thickness, spanPx, profile)
1881
+ lensDepthPx: Math.min(Math.max(shape.channels.thickness, 0) * lensSizeGainFromThickness(thickness, profile), spanPx * 0.5),
1882
+ sizeThickness: thickness,
1883
+ tintStrength: Math.min(1, Math.max(0, surface.tint?.strength ?? 0))
1320
1884
  };
1321
1885
  });
1322
1886
  }
1323
- function groupFieldRect(surfaces, union = DEFAULT_GROUP_UNION, rimWidthPx = 2) {
1887
+ function groupFieldRect(surfaces, union = DEFAULT_GROUP_UNION, rimWidthPx = 2, outerShadowReachPx2 = 0) {
1324
1888
  if (surfaces.length === 0)
1325
1889
  return { x: 0, y: 0, width: 0, height: 0 };
1326
1890
  let minX = Number.POSITIVE_INFINITY;
@@ -1335,7 +1899,7 @@ function groupFieldRect(surfaces, union = DEFAULT_GROUP_UNION, rimWidthPx = 2) {
1335
1899
  maxX = Math.max(maxX, cx + w / 2);
1336
1900
  maxY = Math.max(maxY, cy + h / 2);
1337
1901
  }
1338
- const pad = rimWidthPx + union.maxBulge + 1;
1902
+ const pad = Math.max(rimWidthPx + union.maxBulge + 1, Math.max(outerShadowReachPx2, 0));
1339
1903
  return {
1340
1904
  x: minX - pad,
1341
1905
  y: minY - pad,
@@ -1380,7 +1944,9 @@ function packInstances(surfaces, origin, into) {
1380
1944
  data[o + 12] = s.channels.press;
1381
1945
  data[o + 13] = s.channels.glow;
1382
1946
  data[o + 14] = s.lensDepthPx * Math.min(1, Math.max(0, s.channels.lensStrength));
1383
- data[o + 15] = 0;
1947
+ data[o + 15] = s.tintStrength;
1948
+ data[o + 16] = s.spanPx;
1949
+ data[o + 17] = 0;
1384
1950
  }
1385
1951
  return { data, count: surfaces.length };
1386
1952
  }
@@ -1656,7 +2222,9 @@ var WGSL_INSTANCE_STRUCT = `struct Instance {
1656
2222
  press : f32, // 48 interaction channel value, 0..1
1657
2223
  glow : f32, // 52 interaction channel value, 0..1
1658
2224
  lensDepth : f32, // 56 CPU-resolved lens depth in CSS px (material.ts), already scaled by the lensStrength channel
1659
- _pad : f32, // 60
2225
+ tintK : f32, // 60 author tint strength, 0..1 \u2014 the seed itself is a group uniform
2226
+ span : f32, // 64 the surface's shorter extent, CSS px \u2014 the size law's input, both curves (W11c)
2227
+ _pad : f32, // 68 stride padding: vec2f aligns the struct to 8, so 72 is the next legal size
1660
2228
  };`;
1661
2229
  var WGSL_FIELD_SAMPLE = `struct FieldSample {
1662
2230
  d : f32,
@@ -1821,7 +2389,40 @@ var WGSL_FIELD_PASS = `struct FieldUniforms {
1821
2389
  // field but its members are not one size, and carrying them per pixel is what
1822
2390
  // lets a 40 px button and a 320 px platter share a field pass and still lens by
1823
2391
  // their own depth \u2014 parent acceptance #2 inside a GlassEffectContainer.
1824
- // aux = (lensDepthPx, glow, thicknessPx, press)
2392
+ // aux = (lensDepthPx, glow, spanPx, tintStrength)
2393
+ //
2394
+ // The third and fourth slots both changed hands, and neither of the values they
2395
+ // used to carry was ever read by a fragment stage. 'thick' left because
2396
+ // 'lensDepth' is the thickness times the size gain, so the raw number told the
2397
+ // shader nothing the depth had not already said; 'press' left because press
2398
+ // compression is resolved on the CPU, as a transform on the surface's own size
2399
+ // (instances.ts). Both are still packed into the instance buffer, unchanged, for
2400
+ // a future consumer that wants them per pixel; they simply no longer ride here.
2401
+ //
2402
+ // What replaced them are the two quantities that genuinely have to be per pixel,
2403
+ // because a group is one field pass and one optics pass while its members are
2404
+ // not one thing:
2405
+ //
2406
+ // 'spanPx' \u2014 the size law's input (W2): the surface's shorter extent in
2407
+ // CSS px. A 40 px button and a 320 px platter in one container
2408
+ // lens, scatter, occlude and shadow by their own size. Until
2409
+ // W11c this slot carried the folded thickness factor itself;
2410
+ // the scatter facet now rides a curve with a different band
2411
+ // top, so the span travels and each consumer evaluates its own
2412
+ // curve from it with the bands and the fold as uniforms.
2413
+ // 'tintStrength' \u2014 the author tint's strength (W3). One emphasised control
2414
+ // among plain ones is the composition Apple's guidance
2415
+ // describes; the seed itself is a group uniform.
2416
+ //
2417
+ // They compose rather than compete: the size law moves what the material does to
2418
+ // the backdrop, the tint moves what colour it does it in.
2419
+ //
2420
+ // One consequence of carrying the span rather than the thickness, stated so it
2421
+ // is not mistaken for a drift: the union below blends 'aux' linearly between two
2422
+ // members, so a pixel in the neck between a button and a platter now blends
2423
+ // their SPANS and evaluates the curves on the blend, where it used to blend the
2424
+ // evaluated thicknesses. Inside either member the two are identical; only the
2425
+ // smooth-min neck differs, and nothing measured claims a value there.
1825
2426
 
1826
2427
  @group(0) @binding(0) var<uniform> fu : FieldUniforms;
1827
2428
  @group(0) @binding(1) var<storage, read> instances : array<Instance>;
@@ -1842,7 +2443,7 @@ fn eval_instance(i : u32, p : vec2f) -> Member {
1842
2443
  var m : Member;
1843
2444
  m.d = f.d + s.inset;
1844
2445
  m.g = f.g;
1845
- m.aux = vec4f(s.lensDepth, s.glow, s.thick, s.press);
2446
+ m.aux = vec4f(s.lensDepth, s.glow, s.span, s.tintK);
1846
2447
  return m;
1847
2448
  }
1848
2449
 
@@ -1925,6 +2526,24 @@ var WGSL_HIGHLIGHT_PASS = `struct HighlightUniforms {
1925
2526
  colour : vec4f,
1926
2527
  /// fieldSize.xy, fieldUpsampled (z), unused (w)
1927
2528
  flags : vec4f,
2529
+ /// backdrop tone adaptation (W7), exactly as the optics pass takes it:
2530
+ /// backdropToneLow, backdropToneHigh, the size bias already divided by the
2531
+ /// accessibility refraction cap, and the resolved strength \u2014 zero where there
2532
+ /// is no measured backdrop tone, which stands the fade down
2533
+ toneAdapt : vec4f,
2534
+ /// the backdrop source's own average luminance (x), and the size law's band
2535
+ /// and fold \u2014 sizeSpanMin (y), sizeSpanMax (z), the accessibility fold (w) \u2014
2536
+ /// so this pass evaluates the thickness curve off the per-pixel span exactly
2537
+ /// as the optics pass does (W11c: the span rides the field, not the factor)
2538
+ toneLevel : vec4f,
2539
+ /// this pass's uv into the FIELD texture's uv: scale (xy), offset (zw).
2540
+ ///
2541
+ /// The identity for a pass whose scissor is the field's own rect, and not the
2542
+ /// identity since W8: the outer shadow made the field rect much larger than
2543
+ /// anything this pass draws in, so the highlight is scoped back to the rect it
2544
+ /// had before and reads the field through this remap instead. Derived from the
2545
+ /// two rects by highlightPass in passes.ts.
2546
+ fieldFit : vec4f,
1928
2547
  };
1929
2548
 
1930
2549
  @group(0) @binding(0) var<uniform> hu : HighlightUniforms;
@@ -1946,13 +2565,14 @@ fn fs_highlight(in : FullscreenOut) -> @location(0) vec4f {
1946
2565
  // Exact load nominally; filtered when the governor's resolution knob had the
1947
2566
  // field rasterised below the group's rect. The sweep rides the rim, which is
1948
2567
  // the one place a nearest read of a coarse field would show its grid.
2568
+ let fieldUv = in.uv * hu.fieldFit.xy + hu.fieldFit.zw;
1949
2569
  var field : vec4f;
1950
2570
  var aux : vec4f;
1951
2571
  if (hu.flags.z > 0.5) {
1952
- field = textureSampleLevel(fieldTexture, fieldSampler, in.uv, 0.0);
1953
- aux = textureSampleLevel(auxTexture, fieldSampler, in.uv, 0.0);
2572
+ field = textureSampleLevel(fieldTexture, fieldSampler, fieldUv, 0.0);
2573
+ aux = textureSampleLevel(auxTexture, fieldSampler, fieldUv, 0.0);
1954
2574
  } else {
1955
- let texel = vec2i(in.uv * hu.flags.xy);
2575
+ let texel = vec2i(fieldUv * hu.flags.xy);
1956
2576
  field = textureLoad(fieldTexture, texel, 0);
1957
2577
  aux = textureLoad(auxTexture, texel, 0);
1958
2578
  }
@@ -1981,7 +2601,39 @@ fn fs_highlight(in : FullscreenOut) -> @location(0) vec4f {
1981
2601
  // whose members glow independently does not need a pass each.
1982
2602
  let press = radial * radial * hu.glow.w * aux.y;
1983
2603
 
1984
- let intensity = clamp(sweep + press, 0.0, 1.0) * coverage;
2604
+ /*
2605
+ * Backdrop tone adaptation (W7) fades this pass with the rest of the surface's
2606
+ * own appearance. A highlight is the material catching light, and a material
2607
+ * that has taken its backdrop's tone is not there to catch any \u2014 the settled
2608
+ * reference's capsule over a near-black backdrop is byte-identical to that
2609
+ * background, sweep included.
2610
+ *
2611
+ * The curve is the optics pass's, evaluated the same way off the same uniforms
2612
+ * and the same per-pixel 'sizeK', because two statements of one curve is how
2613
+ * they drift. This pass would otherwise have gone on drawing a bright crescent
2614
+ * around a surface the optics pass had made invisible \u2014 measured at 60 pixels
2615
+ * past 2/255, peaking at 97/255, on exactly that cell.
2616
+ */
2617
+ // The thickness curve off the per-pixel span, written out as the optics pass
2618
+ // writes it, so the two passes fade on one curve and not on two copies.
2619
+ let spanT = clamp(
2620
+ (aux.z - hu.toneLevel.y) / max(hu.toneLevel.z - hu.toneLevel.y, 1e-6),
2621
+ 0.0,
2622
+ 1.0,
2623
+ );
2624
+ let sizeK = clamp(spanT * spanT * (3.0 - 2.0 * spanT) * hu.toneLevel.w, 0.0, 1.0);
2625
+ var toneAdapt = 0.0;
2626
+ if (hu.toneAdapt.w > 0.0) {
2627
+ let toneX = hu.toneLevel.x + hu.toneAdapt.z * sizeK;
2628
+ let toneT = clamp(
2629
+ (toneX - hu.toneAdapt.x) / max(hu.toneAdapt.y - hu.toneAdapt.x, 1e-6),
2630
+ 0.0,
2631
+ 1.0,
2632
+ );
2633
+ toneAdapt = clamp(hu.toneAdapt.w, 0.0, 1.0) * (1.0 - toneT * toneT * (3.0 - 2.0 * toneT));
2634
+ }
2635
+
2636
+ let intensity = clamp(sweep + press, 0.0, 1.0) * coverage * (1.0 - toneAdapt);
1985
2637
  if (intensity <= 0.0) {
1986
2638
  return vec4f(0.0);
1987
2639
  }
@@ -1994,18 +2646,63 @@ var WGSL_OPTICS_PASS = `struct OpticsUniforms {
1994
2646
  screen : vec4f,
1995
2647
  /// backdrop uv transform on viewport-normalised coords: scale (xy), offset (zw)
1996
2648
  fit : vec4f,
1997
- /// refractionScale, bodyLodPerPx, rimLodBias, chainMaxLod
2649
+ /// refractionScale, lensRefractionGain (W11c G2), unused, chainMaxLod
1998
2650
  lens : vec4f,
1999
2651
  /// fixed tint colour, linear light (xyz), tint alpha (w)
2000
2652
  tint : vec4f,
2001
2653
  /// adapted tint colour, linear light (xyz), adaptation strength (w)
2002
2654
  adapt : vec4f,
2655
+ /// author tint seed, linear light (xyz), tone adaptation under the contrast regime (w)
2656
+ seed : vec4f,
2657
+ /// the author tint's shade law (W10): tintShadeDark, tintShadeLight,
2658
+ /// tintShadeStrength (the profile's provenance gate); and (w) the size law's
2659
+ /// accessibility fold \u2014 the refraction ladder read at the preference's cap,
2660
+ /// which every facet's span-dependent rise is multiplied by (W11c)
2661
+ tone : vec4f,
2003
2662
  /// rimWidthPx, rimAlpha, specularPower, specularGain
2004
2663
  rim : vec4f,
2005
2664
  /// light direction, unit (xy), shadowDepth (z), shadowAlpha (w)
2006
2665
  light : vec4f,
2007
2666
  /// hasBackdrop, fieldSize.xy, fieldUpsampled
2008
2667
  flags : vec4f,
2668
+ /// the size law's gains (W2): scatterGainMax, occlusionGain, shadowGainMax,
2669
+ /// bodyChainLod \u2014 the last being the chain level whose blur already matches the
2670
+ /// body texture, which is what the scattering term measures its octaves from
2671
+ size : vec4f,
2672
+ /// backdrop tone adaptation (W7): backdropToneLow, backdropToneHigh, the size
2673
+ /// bias ALREADY divided by the accessibility refraction cap (so that it may be
2674
+ /// multiplied by the policy-folded 'sizeK' below and still mean the geometric
2675
+ /// thickness), and the strength the accessibility policy resolved \u2014 zero where
2676
+ /// the host could not measure a backdrop tone, which stands the axis down
2677
+ toneAdapt : vec4f,
2678
+ /// the backdrop source's own average colour, linear (xyz), and its luminance (w)
2679
+ toneColour : vec4f,
2680
+ /// the outer shadow (W8): its compositing-space alpha before the size law (x),
2681
+ /// the blur's sigma in group-local CSS px (y), the silhouette's outward spread
2682
+ /// in the same units (z), and the downward offset expressed in field-texture UV
2683
+ /// (w) \u2014 a UV so the offset silhouette is one texture read rather than a
2684
+ /// gradient extrapolation, which is exact only on a straight edge and a capsule
2685
+ /// is mostly not one
2686
+ shadow : vec4f,
2687
+ /// the outer shadow's size-law gain (x), read against the casting surface's own
2688
+ /// thickness, and the field rect's height in CSS px (y) \u2014 the conversion the
2689
+ /// shadow's shift needs when it lands outside the texture; zw are the padding
2690
+ /// a uniform's vec4 alignment requires
2691
+ shadowSize : vec4f,
2692
+ /// the backdrop tone response's anchors (W9): the three solid anchors'
2693
+ /// ENCODED-space means (xyz), and the backdrop's linear-space mean (w) \u2014 the
2694
+ /// quantity the response solve composites against
2695
+ toneAnchor : vec4f,
2696
+ /// the response law's thin row: the reference's settled interior levels at the
2697
+ /// three anchors for a surface of sizeThickness 0 (xyz); w is the law's
2698
+ /// per-profile authority \u2014 0 on dark profiles, whose response is unmeasured
2699
+ toneRowThin : vec4f,
2700
+ /// the thick row (sizeThickness saturated), same layout
2701
+ toneRowThick : vec4f,
2702
+ /// the size law's bands (W11c): the scatter facet's floor (x) and its own
2703
+ /// band top (y), and the thickness curve's sizeSpanMin (z) and sizeSpanMax
2704
+ /// (w) \u2014 both curves start at z; the fold they multiply by is tone.w
2705
+ scatter : vec4f,
2009
2706
  };
2010
2707
 
2011
2708
  @group(0) @binding(0) var<uniform> ou : OpticsUniforms;
@@ -2016,6 +2713,42 @@ var WGSL_OPTICS_PASS = `struct OpticsUniforms {
2016
2713
  @group(0) @binding(5) var backdropBody : texture_2d<f32>;
2017
2714
  @group(0) @binding(6) var fieldSampler : sampler;
2018
2715
 
2716
+ /// One encoded sRGB channel from a linear one \u2014 the space the backdrop tone
2717
+ /// response's anchors live in (W9). Mirrors material.ts's 'linearToSrgbChannel'.
2718
+ fn srgb_encode(c : f32) -> f32 {
2719
+ let x = clamp(c, 0.0, 1.0);
2720
+ return select(1.055 * pow(x, 1.0 / 2.4) - 0.055, x * 12.92, x <= 0.0031308);
2721
+ }
2722
+
2723
+ /// The backdrop tone response R(encodedInput, sizeK) (W9): monotone
2724
+ /// (Fritsch\u2013Carlson) interpolation through the three anchors, clamped to their
2725
+ /// span; smoothstep between the thin and thick rows. Mirrors material.ts's
2726
+ /// 'backdropToneResponse' term for term \u2014 the constants are authored there.
2727
+ fn tone_response(x : f32, sizeK : f32) -> f32 {
2728
+ let f = sizeK * sizeK * (3.0 - 2.0 * sizeK);
2729
+ let ys = mix(ou.toneRowThin.xyz, ou.toneRowThick.xyz, vec3f(f));
2730
+ let xs = ou.toneAnchor.xyz;
2731
+ let xc = clamp(x, xs.x, xs.z);
2732
+ let h0 = max(xs.y - xs.x, 1e-4);
2733
+ let h1 = max(xs.z - xs.y, 1e-4);
2734
+ let d0 = (ys.y - ys.x) / h0;
2735
+ let d1 = (ys.z - ys.y) / h1;
2736
+ // The interior slope: the harmonic mean where the secants agree, 0 across a
2737
+ // sign change \u2014 what keeps the curve monotone between monotone anchors.
2738
+ var m1 = 0.0;
2739
+ if (d0 * d1 > 0.0) { m1 = 2.0 * d0 * d1 / (d0 + d1); }
2740
+ var h = h0; var t = (xc - xs.x) / h0;
2741
+ var y0 = ys.x; var y1 = ys.y; var s0 = d0; var s1 = m1;
2742
+ if (xc > xs.y) {
2743
+ h = h1; t = (xc - xs.y) / h1;
2744
+ y0 = ys.y; y1 = ys.z; s0 = m1; s1 = d1;
2745
+ }
2746
+ return y0 * (1.0 + 2.0 * t) * (1.0 - t) * (1.0 - t)
2747
+ + s0 * h * t * (1.0 - t) * (1.0 - t)
2748
+ + y1 * t * t * (3.0 - 2.0 * t)
2749
+ + s1 * h * t * t * (t - 1.0);
2750
+ }
2751
+
2019
2752
  /// Rim proximity: 1 exactly on the contour, falling to 0 by 'width' on either
2020
2753
  /// side. Symmetric, so the rim is a band on the boundary rather than a plateau
2021
2754
  /// that keeps burning outward where coverage has already faded.
@@ -2024,6 +2757,87 @@ fn rim_weight(d : f32, width : f32) -> f32 {
2024
2757
  return t * t;
2025
2758
  }
2026
2759
 
2760
+ /// The Gaussian CDF the outer shadow's edge falls off by: 1 deep inside the
2761
+ /// shadow's silhouette, 0.5 exactly on it, 0 far outside. The tanh form of the
2762
+ /// normal CDF \u2014 WGSL has no erf, and this is within 1.8e-4 of it everywhere,
2763
+ /// which is 0.015 of one 8-bit code at the shipped occlusion. Mirrors
2764
+ /// material.ts's 'outerShadowFalloff' term for term.
2765
+ fn outer_shadow_falloff(signedDistance : f32, sigma : f32) -> f32 {
2766
+ let x = -signedDistance / max(sigma, 1e-4);
2767
+ return 0.5 * (1.0 + tanh(0.7978845608028654 * (x + 0.044715 * x * x * x)));
2768
+ }
2769
+
2770
+ /// The outer shadow's alpha at this pixel (W8).
2771
+ ///
2772
+ /// The shadow is the group's OWN field, translated down by 'shadow.w' in field
2773
+ /// UV and outset by 'shadow.z', then blurred. Reading the offset silhouette from
2774
+ /// the field texture rather than extrapolating the local distance along the
2775
+ /// normal is what keeps a corner a corner: the first-order estimate is exact only
2776
+ /// on a straight edge, and a capsule is mostly not one.
2777
+ ///
2778
+ /// The value is an ALPHA on pure black, so what it composites to is the backdrop
2779
+ /// times '1 - alpha' \u2014 multiplicative occlusion, and exactly zero over black,
2780
+ /// with no branch for it.
2781
+ fn outer_shadow_alpha(uv : vec2f, upsampled : f32, fieldSize : vec2f) -> f32 {
2782
+ if (ou.shadow.x <= 0.0) {
2783
+ return 0.0;
2784
+ }
2785
+ /*
2786
+ * The shift can leave the field texture, and clamping alone reads a lie there.
2787
+ *
2788
+ * Two ways it happens, and the second is not an edge case: the rect is clipped
2789
+ * to the canvas, so a surface within the shadow's reach of the viewport's TOP
2790
+ * has no rows above it to shift into; and even unclipped, the rect's pad is the
2791
+ * shadow's reach, so the topmost band of every group needs rows a further
2792
+ * 'offset' above that. Clamped, both repeat the edge texel \u2014 a distance that is
2793
+ * too SMALL, which reads as a flat, too-dark falloff exactly where the shadow
2794
+ * should be fading out, and diverges from the CSS tier, which has no texture to
2795
+ * run out of.
2796
+ *
2797
+ * Reconstructed instead of clamped. A signed distance field is 1-Lipschitz, and
2798
+ * in the region this happens in \u2014 directly above the surface \u2014 moving away from
2799
+ * it increases the distance by exactly the displacement. So adding back the
2800
+ * distance that was clamped off is exact there, and past the surface's corners
2801
+ * it over-estimates, which errs toward LESS shadow rather than more.
2802
+ */
2803
+ let shiftedY = uv.y - ou.shadow.w;
2804
+ let clampedOffCss =
2805
+ (max(0.0, -shiftedY) + max(0.0, shiftedY - 1.0)) * ou.shadowSize.y;
2806
+ let shadowUv = clamp(vec2f(uv.x, shiftedY), vec2f(0.0), vec2f(1.0));
2807
+ var shadowField : vec4f;
2808
+ var shadowAux : vec4f;
2809
+ if (upsampled > 0.5) {
2810
+ shadowField = textureSampleLevel(fieldTexture, fieldSampler, shadowUv, 0.0);
2811
+ shadowAux = textureSampleLevel(auxTexture, fieldSampler, shadowUv, 0.0);
2812
+ } else {
2813
+ let texel = clamp(vec2i(shadowUv * fieldSize), vec2i(0), vec2i(fieldSize) - vec2i(1));
2814
+ shadowField = textureLoad(fieldTexture, texel, 0);
2815
+ shadowAux = textureLoad(auxTexture, texel, 0);
2816
+ }
2817
+
2818
+ // The size law reaches the amplitude and nothing else \u2014 the reference's three
2819
+ // lengths are span-invariant across 32\u2026160 px. The gain rides the thickness of
2820
+ // the surface that CAST the shadow, which is the one read at the offset
2821
+ // position rather than the one under the pixel being shaded.
2822
+ //
2823
+ // Applied on '1 - alpha' rather than on the alpha: the gain is a fraction of
2824
+ // the remaining transparency in LINEAR light ('sizeOuterShadowOcclusionAt'),
2825
+ // and (1 - occ) is exactly what raising to 1/2.4 turns into (1 - alpha), so the
2826
+ // two forms are the same law and neither needs the other's space.
2827
+ // The thickness curve off the casting surface's span (W11c: the span rides
2828
+ // the field), written as the body's own is below.
2829
+ let castT = clamp(
2830
+ (shadowAux.z - ou.scatter.z) / max(ou.scatter.w - ou.scatter.z, 1e-6),
2831
+ 0.0,
2832
+ 1.0,
2833
+ );
2834
+ let sizeK = clamp(castT * castT * (3.0 - 2.0 * castT) * clamp(ou.tone.w, 0.0, 1.0), 0.0, 1.0);
2835
+ let sizeFold = pow(max(1.0 - ou.shadowSize.x * sizeK, 0.0), 1.0 / 2.4);
2836
+ let alpha = 1.0 - (1.0 - ou.shadow.x) * sizeFold;
2837
+
2838
+ return alpha * outer_shadow_falloff(shadowField.x + clampedOffCss - ou.shadow.z, ou.shadow.y);
2839
+ }
2840
+
2027
2841
  @fragment
2028
2842
  fn fs_optics(in : FullscreenOut) -> @location(0) vec4f {
2029
2843
  // Nominally the field is one texel per device pixel, so the read is an exact
@@ -2045,8 +2859,19 @@ fn fs_optics(in : FullscreenOut) -> @location(0) vec4f {
2045
2859
  let d = field.x;
2046
2860
  let normal = field.yz;
2047
2861
  let coverage = field.w;
2862
+
2863
+ /*
2864
+ * The outer shadow (W8) sits UNDER the material, so it is resolved before the
2865
+ * body and carried into the output's alpha rather than into its colour: the
2866
+ * pass writes premultiplied black there, and premultiplied black over the page
2867
+ * IS the multiplication. Outside the contour that is the whole of what this
2868
+ * pixel is; inside it the surface covers the shadow, exactly as a 'box-shadow'
2869
+ * is clipped out of its own border box, and the two meet across the coverage
2870
+ * ramp with no seam because it is one expression.
2871
+ */
2872
+ let shadowAlpha = outer_shadow_alpha(in.uv, ou.flags.w, ou.flags.yz);
2048
2873
  if (coverage <= 0.0) {
2049
- return vec4f(0.0);
2874
+ return vec4f(0.0, 0.0, 0.0, shadowAlpha);
2050
2875
  }
2051
2876
 
2052
2877
  let viewport01 = in.position.xy / ou.screen.xy;
@@ -2054,51 +2879,326 @@ fn fs_optics(in : FullscreenOut) -> @location(0) vec4f {
2054
2879
 
2055
2880
  // Per-pixel, unioned through the field pass. See the module note.
2056
2881
  let lensDepth = max(aux.x, 1e-4);
2882
+ // The size law's two curves off the span of whichever surface owns this pixel
2883
+ // (W11c). 'sizeK' is the thickness factor, 0..1: zero on anything at or below
2884
+ // the profile's 'sizeSpanMin', saturated at 'sizeSpanMax', folded under the
2885
+ // preference \u2014 the occlusion, the inner shadow and the tone response multiply
2886
+ // by it, so a small control takes the pre-law path exactly. 'kScatter' is the
2887
+ // scatter facet's own mix, floor + (1 - floor) \xB7 smoothstep to
2888
+ // 'sizeScatterSpanMax' \xB7 fold: the floor is the material's frost at any size
2889
+ // and is not folded, the rise is depth and is. Both written out as
2890
+ // smoothsteps with a guarded denominator, so a profile that collapses a band
2891
+ // degrades to a step rather than to NaN.
2892
+ let span = aux.z;
2893
+ let fold = clamp(ou.tone.w, 0.0, 1.0);
2894
+ let thickT = clamp((span - ou.scatter.z) / max(ou.scatter.w - ou.scatter.z, 1e-6), 0.0, 1.0);
2895
+ let sizeK = clamp(thickT * thickT * (3.0 - 2.0 * thickT) * fold, 0.0, 1.0);
2896
+ let scatterFloor = clamp(ou.scatter.x, 0.0, 1.0);
2897
+ let scatterT = clamp((span - ou.scatter.z) / max(ou.scatter.y - ou.scatter.z, 1e-6), 0.0, 1.0);
2898
+ let kScatter = clamp(
2899
+ scatterFloor + (1.0 - scatterFloor) * scatterT * scatterT * (3.0 - 2.0 * scatterT) * fold,
2900
+ 0.0,
2901
+ 1.0,
2902
+ );
2057
2903
  // '-d' is depth inside the surface, so the profile runs 1 at the contour to 0
2058
2904
  // at 'lensDepth' inward, and the square makes the falloff read as curvature
2059
2905
  // rather than as a linear ramp.
2060
2906
  let depth = clamp(-d / lensDepth, 0.0, 1.0);
2061
2907
  let profile = (1.0 - depth) * (1.0 - depth);
2062
2908
 
2063
- let displaceCss = -normal * lensDepth * profile * ou.lens.x;
2909
+ // W11c G2: the lens is the body read from further inside. 'lens.y' is the
2910
+ // profile's refraction gain, the contour displacement in lens depths.
2911
+ let displaceCss = -normal * lensDepth * profile * ou.lens.x * ou.lens.y;
2064
2912
  let refracted01 = viewport01 + displaceCss / viewportCss;
2065
2913
 
2066
- let straightUv = clamp(viewport01 * ou.fit.xy + ou.fit.zw, vec2f(0.0), vec2f(1.0));
2067
2914
  let refractedUv = clamp(refracted01 * ou.fit.xy + ou.fit.zw, vec2f(0.0), vec2f(1.0));
2068
2915
 
2069
- let bodyLod = clamp(lensDepth * ou.lens.y, 0.0, ou.lens.w);
2070
- let lod = clamp(bodyLod - ou.lens.z * profile, 0.0, ou.lens.w);
2916
+ /*
2917
+ * The scattering facet of the size law: "a softer scattering of light".
2918
+ *
2919
+ * The body texture is one blur for the whole backdrop source \u2014 it is built once
2920
+ * per source per frame, so it cannot be per-surface. The chain beside it can:
2921
+ * 'size.w' is the chain level whose blur already matches that body, so
2922
+ * 'size.w + log2(sizeScatterGainMax)' is the level whose blur is the gain times
2923
+ * wider \u2014 the HEAVY component, at one fixed level \u2014 and lerping the body toward
2924
+ * it by 'kScatter' is what the reference's interior measures (W11c, claims
2925
+ * \xA75.41): a sharp component near the body's \u03C3 and a heavy one near \u03C3 10, mixed
2926
+ * by a share that is \u2248 0.4 on a small control and rises with the span. The
2927
+ * mix, not the level, is what the span moves.
2928
+ */
2929
+ let scatterLod = clamp(ou.size.w + log2(max(ou.size.x, 1e-4)), 0.0, ou.lens.w);
2071
2930
 
2072
2931
  var backdrop = vec3f(0.0);
2073
2932
  if (ou.flags.x > 0.5) {
2074
- let lensSample = textureSampleLevel(backdropChain, backdropSampler, refractedUv, lod);
2075
- let bodySample = textureSampleLevel(backdropBody, backdropSampler, straightUv, 0.0);
2933
+ // Both components at the refracted position (W11c G2): the band and the
2934
+ // interior are one body, and the displacement alone is the lens.
2935
+ let bodySample = textureSampleLevel(backdropBody, backdropSampler, refractedUv, 0.0);
2936
+ let scatterSample = textureSampleLevel(backdropChain, backdropSampler, refractedUv, scatterLod);
2076
2937
  // Premultiplied linear in, straight colour out: the material composites over
2077
2938
  // whatever is behind it, so a partially transparent backdrop must not darken
2078
2939
  // the glass.
2079
- let lensColour = lensSample.rgb / max(lensSample.a, 1e-6);
2080
- let bodyColour = bodySample.rgb / max(bodySample.a, 1e-6);
2081
- backdrop = mix(bodyColour, lensColour, profile);
2940
+ let scatterColour = scatterSample.rgb / max(scatterSample.a, 1e-6);
2941
+ backdrop = mix(bodySample.rgb / max(bodySample.a, 1e-6), scatterColour, kScatter);
2082
2942
  }
2083
2943
 
2084
2944
  // Adaptive tint. 'adapt.w' is the strength the accessibility policy and the
2085
2945
  // group's analysis quality already agreed on; at 0 the fixed tint stands, which
2086
2946
  // is what a 'hint' or 'none' group gets.
2087
- let tintColour = mix(ou.tint.rgb, ou.adapt.rgb, ou.adapt.w);
2088
- var colour = mix(backdrop, tintColour, ou.tint.w);
2947
+ let neutral = mix(ou.tint.rgb, ou.adapt.rgb, ou.adapt.w);
2948
+
2949
+ /*
2950
+ * Backdrop tone adaptation (W7) \u2014 step two of the composition contract, between
2951
+ * the colour scheme's neutral and the author's tint.
2952
+ *
2953
+ * Read against 'toneColour': the backdrop SOURCE's own average, measured by the
2954
+ * host from the pixels it supplied and handed to both tiers as one number. Not a
2955
+ * per-pixel sample, and that is the load-bearing choice \u2014 see
2956
+ * 'GroupRenderInput.backdropTone' for the cross-tier measurement that settled
2957
+ * it, and note that the reference agrees: its capsule over a sparse bright grid
2958
+ * is a flat body rather than a window onto the grid.
2959
+ *
2960
+ * 'sizeK' is still per pixel, so a container holding a small control and a large
2961
+ * platter adapts each of them by its own thickness out of one pass.
2962
+ *
2963
+ * Strength is zero where the host measured no tone, and the whole axis stands
2964
+ * down where there is no backdrop at all rather than reading the zero vector as
2965
+ * a black backdrop and dissolving the surface into nothing.
2966
+ *
2967
+ * Written out instead of calling smoothstep() so that a profile patched with
2968
+ * low >= high degrades to a step rather than to NaN.
2969
+ */
2970
+ var toneAdapt = 0.0;
2971
+ if (ou.flags.x > 0.5 && ou.toneAdapt.w > 0.0) {
2972
+ let toneX = ou.toneColour.w + ou.toneAdapt.z * sizeK;
2973
+ let toneT = clamp(
2974
+ (toneX - ou.toneAdapt.x) / max(ou.toneAdapt.y - ou.toneAdapt.x, 1e-6),
2975
+ 0.0,
2976
+ 1.0,
2977
+ );
2978
+ toneAdapt = clamp(ou.toneAdapt.w, 0.0, 1.0) * (1.0 - toneT * toneT * (3.0 - 2.0 * toneT));
2979
+ }
2980
+ /*
2981
+ * Both the colour and the alpha move, together and not separately. What the
2982
+ * adaptation means is that the INTERIOR converges on the backdrop's tone \u2014
2983
+ * mix(interior, tone, k) \u2014 and this is the (colour, alpha) pair that composites
2984
+ * to exactly that. Lerping the two independently makes a partially adapted
2985
+ * surface lighter than it started (more opaque toward a tint still mostly
2986
+ * neutral), which the 96 px cells caught at once: interior 0.4545 \u2192 0.5179
2987
+ * against a reference of 0.4542. The alpha half is not a colour axis reaching
2988
+ * the occlusion axis \u2014 an adapting material stops transmitting, which is what
2989
+ * the reference's flat body over the impulse grid is. See 'adaptedTintColour'
2990
+ * and 'adaptedTintAlpha' in material.ts.
2991
+ */
2992
+ let sizedAlpha = ou.tint.w + ou.size.y * sizeK * (1.0 - ou.tint.w);
2993
+
2994
+ /*
2995
+ * The backdrop tone response solve (W9) \u2014 the law that owns the interior
2996
+ * MEAN, where the collapse below owns texture and nothing else (claims
2997
+ * \xA75.33). The composite under this mechanism reduces exactly to
2998
+ * mean = (1 \u2212 k)\xB7M\u2080 + k\xB7toneLuma with M\u2080 = (1 \u2212 \u03B1)\xB7bgLinear + \u03B1\xB7L(neutral),
2999
+ * so the neutral's tone is solved in closed form: shift its luma so the
3000
+ * post-collapse mean lands on R(encodedInput, sizeK), the reference's own
3001
+ * measured response. Chroma is untouched \u2014 the shift is achromatic \u2014 and
3002
+ * the author tint still displaces the result per the composition contract.
3003
+ *
3004
+ * Three stand-downs, each measured rather than defensive: the whole axis is
3005
+ * off where no backdrop tone was measured (same gate as the collapse); the
3006
+ * solve's authority fades to zero below the dark anchor, where the only
3007
+ * evidence is the impulse cell the collapse constants were fitted on; and
3008
+ * at k \u2192 1 the collapse owns the pixel outright, so the solve's
3009
+ * extrapolation is never evaluated against a vanishing (1 \u2212 k).
3010
+ */
3011
+ var solvedNeutral = neutral;
3012
+ var solvedAlpha = sizedAlpha;
3013
+ if (ou.flags.x > 0.5 && ou.toneAdapt.w > 0.0 && ou.toneRowThin.w > 0.0 &&
3014
+ sizedAlpha > 1e-3 && toneAdapt < 0.995) {
3015
+ let encodedInput = srgb_encode(ou.toneColour.w);
3016
+ let anchor = max(ou.toneAnchor.x, 1e-4);
3017
+ let authority =
3018
+ smoothstep(anchor * 0.5, anchor, encodedInput) * clamp(ou.toneRowThin.w, 0.0, 1.0);
3019
+ if (authority > 0.0) {
3020
+ let response = tone_response(encodedInput, sizeK);
3021
+ // The collapse's mean pull is toward L(toneColour.rgb) \u2014 the LINEAR
3022
+ // mean, which toneAnchor.w carries \u2014 not toward the encoded level.
3023
+ let preCollapse = (response - toneAdapt * ou.toneAnchor.w) / (1.0 - toneAdapt);
3024
+ let neutralLuma = dot(neutral, vec3f(0.2126, 0.7152, 0.0722));
3025
+ let nominal = (1.0 - sizedAlpha) * ou.toneAnchor.w + sizedAlpha * neutralLuma;
3026
+ let shift = (preCollapse - nominal) / sizedAlpha * authority * ou.toneAdapt.w;
3027
+ solvedNeutral = clamp(neutral + vec3f(shift), vec3f(0.0), vec3f(1.0));
3028
+ /*
3029
+ * The light attractor needs OPACITY. The light scheme's neutral is
3030
+ * already at white, so an upward shift clamps to nothing \u2014 and the
3031
+ * reference's light-adapted state is the material gone opaque-bright,
3032
+ * the same "an adapting material stops transmitting" the collapse's
3033
+ * alpha half was measured on. Whatever the clamp truncated is carried
3034
+ * by the alpha, solved against the same composite and folded by the
3035
+ * same authority. One-sided by design: darkward opacity is the
3036
+ * collapse's own axis with its own fitted constants.
3037
+ */
3038
+ let solvedLuma = dot(solvedNeutral, vec3f(0.2126, 0.7152, 0.0722));
3039
+ let achieved = (1.0 - sizedAlpha) * ou.toneAnchor.w + sizedAlpha * solvedLuma;
3040
+ if (preCollapse > achieved + 1e-4 && solvedLuma > ou.toneAnchor.w + 1e-3) {
3041
+ let alphaTarget = clamp(
3042
+ (preCollapse - ou.toneAnchor.w) / (solvedLuma - ou.toneAnchor.w),
3043
+ sizedAlpha,
3044
+ 1.0,
3045
+ );
3046
+ solvedAlpha = mix(sizedAlpha, alphaTarget, authority * ou.toneAdapt.w);
3047
+ }
3048
+ }
3049
+ }
3050
+
3051
+ let adaptedAlpha = solvedAlpha + toneAdapt * (1.0 - solvedAlpha);
3052
+ var adapted = solvedNeutral;
3053
+ if (toneAdapt > 0.0 && adaptedAlpha > 0.0) {
3054
+ adapted =
3055
+ (solvedNeutral * ((1.0 - toneAdapt) * solvedAlpha) + ou.toneColour.rgb * toneAdapt) /
3056
+ adaptedAlpha;
3057
+ }
3058
+
3059
+ // The material, untinted: the adapted neutral over what this pixel looks
3060
+ // through, at the material's own occlusion. That alpha is what reduced
3061
+ // transparency lifts and what the size law thickens ("a larger size is more
3062
+ // opaque" is a statement about how much material there is), and the backdrop
3063
+ // adaptation moved it above. The author's colour never touches it \u2014 but not
3064
+ // for the reason the composition contract first gave (claims \xA75.36).
3065
+ var colour = mix(backdrop, adapted, adaptedAlpha);
3066
+ /*
3067
+ * How much of this pixel the SURFACE owns, as the canvas will composite it.
3068
+ *
3069
+ * With a backdrop the composite above is the whole pixel \u2014 the pass sampled
3070
+ * what is behind the surface and mixed it in, so the output is opaque inside
3071
+ * the contour and the page beneath the canvas never shows. With no backdrop
3072
+ * the composite is not here to make: a 'css-backdrop' group's blurred
3073
+ * backdrop is a DOM proxy under this canvas, a 'none' group's is the page
3074
+ * itself, and the browser is the compositor. So the material leaves as a
3075
+ * LAYER \u2014 the adapted colour at the material's own alpha, premultiplied on
3076
+ * the way out \u2014 and every term below that would have shaped the composite
3077
+ * shapes the layer instead, in the form that composites to the same thing.
3078
+ *
3079
+ * Before W11a this path mixed the material over a black backdrop and wrote
3080
+ * it opaque: a nested surface over glass rendered as a flat 0.468 where the
3081
+ * reference reads 0.89 (claims \xA75.38 \xA75). The rows that floored were that
3082
+ * grey. The pair the layer is written at is the host's
3083
+ * ('GroupRenderInput.unsampledMaterial'): the browser composites this canvas
3084
+ * in encoded sRGB, so the alpha is the CSS tier's, not the linear profile's.
3085
+ */
3086
+ var bodyAlpha = 1.0;
3087
+ if (ou.flags.x <= 0.5) {
3088
+ colour = adapted;
3089
+ bodyAlpha = adaptedAlpha;
3090
+ }
3091
+
3092
+ // The author tint (W10). 'aux.w' is the per-pixel strength, unioned in the
3093
+ // field pass, so a toolbar can carry one tinted control among plain ones; the
3094
+ // seed is a group uniform. At strength 0 this is the identity and the material
3095
+ // is the one the calibration bed measures, byte for byte.
3096
+ //
3097
+ // Apple's mechanism, measured per pixel: the tinted material is an OPAQUE
3098
+ // layer of the seed at a shade that is linear in the luminance the untinted
3099
+ // material shows at this pixel \u2014 about half the seed's light over black
3100
+ // content, the seed itself over white, the seed's chromaticity intact
3101
+ // throughout. That layer composites over the material at the AUTHOR's
3102
+ // opacity in the encoded space, which is what a CALayer with 'opacity' does
3103
+ // and how the reference's half-strength cell measures. So a tinted surface
3104
+ // over a checkerboard shows the checker as light and dark ORANGE, not as
3105
+ // orange glass with the checker behind it \u2014 the material's own alpha is not
3106
+ // what a tinted surface shows.
3107
+ //
3108
+ // 'seed.w' is the contrast regime's grip on the excursion, never on the hue;
3109
+ // 'tone.z' is the profile's provenance gate (the dark scheme renders the pure
3110
+ // seed); and '1 \u2212 toneAdapt' folds the shade out where the collapse has made
3111
+ // the material a dark body, where the reference renders the pure seed too.
3112
+ // At zero grip the layer is the bare seed \u2014 the author's colour, flat.
3113
+ if (aux.w > 0.0) {
3114
+ // The untinted material's luminance at this pixel. Over a backdrop that is
3115
+ // the composite; as a layer it is the layer over the tone the host measured
3116
+ // for the group \u2014 zero where nothing was measured, the same reference-level
3117
+ // convention the CSS tier's 'materialLuminance' takes (within its 0.02).
3118
+ let u = bodyAlpha * dot(colour, vec3f(0.2126, 0.7152, 0.0722)) + (1.0 - bodyAlpha) * ou.toneColour.w;
3119
+ let grip = clamp(ou.seed.w, 0.0, 1.0) * clamp(ou.tone.z, 0.0, 1.0) * (1.0 - toneAdapt);
3120
+ let shade = mix(1.0, clamp(mix(ou.tone.x, ou.tone.y, clamp(u, 0.0, 1.0)), 0.0, 1.0), grip);
3121
+ let layer = ou.seed.rgb * shade;
3122
+ let s = clamp(aux.w, 0.0, 1.0);
3123
+ let encodedMaterial = linear_to_srgb(clamp(colour, vec3f(0.0), vec3f(1.0)));
3124
+ let encodedLayer = linear_to_srgb(layer);
3125
+ if (ou.flags.x > 0.5) {
3126
+ colour = srgb_to_linear(mix(encodedMaterial, encodedLayer, vec3f(s)));
3127
+ } else {
3128
+ // The opaque layer at the author's opacity over the material's own layer,
3129
+ // premultiplied: the fold 'tintedCssOptics' makes into one rgba.
3130
+ let premultiplied = (1.0 - s) * bodyAlpha * encodedMaterial + s * encodedLayer;
3131
+ bodyAlpha = 1.0 - (1.0 - s) * (1.0 - bodyAlpha);
3132
+ colour = srgb_to_linear(premultiplied / max(bodyAlpha, 1e-6));
3133
+ }
3134
+ }
3135
+
3136
+ /*
3137
+ * Everything below is the surface's own APPEARANCE \u2014 the marks that say a
3138
+ * surface is here rather than what is behind it \u2014 and all of it fades with the
3139
+ * adaptation, on the one factor.
3140
+ *
3141
+ * That is not symmetry for its own sake; it is what the reference does, and it
3142
+ * is a calibration cell rather than an inference: the reference's capsule over
3143
+ * the dark-solid backdrop is byte-identical to that background, rim included. A
3144
+ * material that has taken its backdrop's tone has no lit edge to show, because
3145
+ * there is no light in front of it to show one with.
3146
+ *
3147
+ * The gap was invisible until this axis existed. A rim of up to 130/255 sat
3148
+ * unnoticed inside a bright capsule body; with the body gone it is a white
3149
+ * outline around a surface that should not be there at all \u2014 595 pixels past
3150
+ * 2/255 on that one cell, and OKLab \u0394E max 0.47 where p95 already read 0.0000.
3151
+ * The same lesson W3 recorded from the other direction: a term that happens to
3152
+ * be hidden is one feature away from being visible.
3153
+ */
3154
+ let present = 1.0 - toneAdapt;
2089
3155
 
2090
3156
  // Inner shadow: the material's own occlusion, deepest where the lens is
2091
- // strongest, which is what makes a thicker surface read as heavier.
2092
- colour = colour * (1.0 - profile * ou.light.z * ou.light.w);
3157
+ // strongest, which is what makes a thicker surface read as heavier \u2014 and the
3158
+ // shadow facet of the size law deepens it further with the span.
3159
+ let shadowDepth = ou.light.z * (1.0 + (ou.size.z - 1.0) * sizeK);
3160
+ // A multiplicative occlusion of the whole composite. As a layer that is the
3161
+ // colour scaled and the alpha raised \u2014 (k\xB7a\xB7c, 1 \u2212 k\xB7(1 \u2212 a)) composites to
3162
+ // k times what (a\xB7c, a) would \u2014 and at bodyAlpha 1 it is the plain product.
3163
+ let shadowKeep = 1.0 - profile * shadowDepth * ou.light.w * present;
3164
+ let shadowedAlpha = 1.0 - shadowKeep * (1.0 - bodyAlpha);
3165
+ colour = colour * (shadowKeep * bodyAlpha / max(shadowedAlpha, 1e-6));
3166
+ bodyAlpha = shadowedAlpha;
2093
3167
 
2094
3168
  // Rim and specular from the gradient. The rim is unlit ambient edge brightness;
2095
3169
  // the specular term is the same edge lit from 'light.xy'.
2096
3170
  let rw = rim_weight(d, ou.rim.x);
2097
3171
  let facing = dot(normal, ou.light.xy);
2098
3172
  let spec = pow(clamp(facing, 0.0, 1.0), max(ou.rim.z, 1e-3)) * ou.rim.w;
2099
- colour = colour + vec3f(rw * (ou.rim.y + spec));
3173
+ let rim = rw * (ou.rim.y + spec) * present;
3174
+ if (ou.flags.x > 0.5) {
3175
+ colour = colour + vec3f(rim);
3176
+ } else {
3177
+ // Added light has no premultiplied form of its own \u2014 a canvas colour may
3178
+ // not exceed its alpha \u2014 so the layer carries the light in its opacity:
3179
+ // (a\xB7c + rim, a + rim) composites to dst\xB7(1 \u2212 a) + a\xB7c + rim \u2212 rim\xB7dst,
3180
+ // the additive term short of rim \xD7 dst. Exact wherever the layer is
3181
+ // opaque (an author tint at full strength, or a lit contour pixel whose
3182
+ // alpha the rim fills), and never further off than the rim times what
3183
+ // shows through \u2014 where the mix form ("white over the layer at the rim's
3184
+ // weight") is short by the rim times the whole composite, which halved
3185
+ // the rim on a tinted panel.
3186
+ let carried = clamp(bodyAlpha + rim, 0.0, 1.0);
3187
+ colour = min((colour * bodyAlpha + vec3f(rim)) / max(carried, 1e-6), vec3f(1.0));
3188
+ bodyAlpha = carried;
3189
+ }
2100
3190
 
2101
- return encode_output(max(colour, vec3f(0.0)), coverage);
3191
+ // The material over its own shadow over the page, in one premultiplied vector:
3192
+ // the colour is the surface's, weighted by its coverage and by however much of
3193
+ // the pixel the layer owns, and the alpha is what the two of them together
3194
+ // leave the page. The shadow fills only what the surface's COVERAGE leaves \u2014
3195
+ // clipped out of the silhouette exactly as a 'box-shadow' is clipped out of
3196
+ // its border box \u2014 never what the layer's own transparency leaves: a
3197
+ // translucent surface shows the page through it, not its own shadow. (With
3198
+ // an opaque body the two are the same quantity, which is how the shadow was
3199
+ // first written and why the difference only surfaced with the layer form.)
3200
+ let body = encode_output(max(colour, vec3f(0.0)), coverage * bodyAlpha);
3201
+ return vec4f(body.rgb, body.a + shadowAlpha * (1.0 - coverage));
2102
3202
  }`;
2103
3203
 
2104
3204
  // ../renderer-webgpu/dist/wgsl/prelude.js
@@ -2345,7 +3445,7 @@ function createPassRunner(context) {
2345
3445
  };
2346
3446
  },
2347
3447
  opticsPass(encoder, args) {
2348
- const slot = uniformSlot(`optics:${args.groupId}`, 32);
3448
+ const slot = uniformSlot(`optics:${args.groupId}`, 76);
2349
3449
  const d = slot.data;
2350
3450
  d[0] = args.viewportDevice[0];
2351
3451
  d[1] = args.viewportDevice[1];
@@ -2356,8 +3456,8 @@ function createPassRunner(context) {
2356
3456
  d[6] = args.fit[2];
2357
3457
  d[7] = args.fit[3];
2358
3458
  d[8] = args.refractionScale;
2359
- d[9] = args.bodyLodPerPx;
2360
- d[10] = args.rimLodBias;
3459
+ d[9] = args.lensRefractionGain;
3460
+ d[10] = 0;
2361
3461
  d[11] = args.chainMaxLod;
2362
3462
  d[12] = args.tint[0];
2363
3463
  d[13] = args.tint[1];
@@ -2367,18 +3467,62 @@ function createPassRunner(context) {
2367
3467
  d[17] = args.adaptTint[1];
2368
3468
  d[18] = args.adaptTint[2];
2369
3469
  d[19] = args.adaptStrength;
2370
- d[20] = args.rimWidth;
2371
- d[21] = args.rimAlpha;
2372
- d[22] = args.specularPower;
2373
- d[23] = args.specularGain;
2374
- d[24] = args.lightDirection[0];
2375
- d[25] = args.lightDirection[1];
2376
- d[26] = args.shadowDepth;
2377
- d[27] = args.shadowAlpha;
2378
- d[28] = args.backdrop === void 0 ? 0 : 1;
2379
- d[29] = args.fields.width;
2380
- d[30] = args.fields.height;
2381
- d[31] = args.fields.upsampled ? 1 : 0;
3470
+ d[20] = args.tintSeed[0];
3471
+ d[21] = args.tintSeed[1];
3472
+ d[22] = args.tintSeed[2];
3473
+ d[23] = args.tintToneAdaptation;
3474
+ d[24] = args.tintShade[0];
3475
+ d[25] = args.tintShade[1];
3476
+ d[26] = args.tintShade[2];
3477
+ d[27] = args.sizeFold;
3478
+ d[28] = args.rimWidth;
3479
+ d[29] = args.rimAlpha;
3480
+ d[30] = args.specularPower;
3481
+ d[31] = args.specularGain;
3482
+ d[32] = args.lightDirection[0];
3483
+ d[33] = args.lightDirection[1];
3484
+ d[34] = args.shadowDepth;
3485
+ d[35] = args.shadowAlpha;
3486
+ d[36] = args.backdrop === void 0 ? 0 : 1;
3487
+ d[37] = args.fields.width;
3488
+ d[38] = args.fields.height;
3489
+ d[39] = args.fields.upsampled ? 1 : 0;
3490
+ d[40] = args.sizeScatterGainMax;
3491
+ d[41] = args.sizeOcclusionGain;
3492
+ d[42] = args.sizeShadowGainMax;
3493
+ d[43] = args.bodyChainLod;
3494
+ d[44] = args.backdropTone[0];
3495
+ d[45] = Math.max(args.backdropTone[1], args.backdropTone[0] + 1e-4);
3496
+ d[46] = args.backdropTone[2];
3497
+ d[47] = args.backdropTone[3];
3498
+ d[48] = args.backdropToneColour[0];
3499
+ d[49] = args.backdropToneColour[1];
3500
+ d[50] = args.backdropToneColour[2];
3501
+ d[51] = args.backdropToneColour[3];
3502
+ d[52] = args.outerShadow[0];
3503
+ d[53] = args.outerShadow[1];
3504
+ d[54] = args.outerShadow[2];
3505
+ d[55] = args.outerShadow[3];
3506
+ d[56] = args.outerShadowSizeGain;
3507
+ d[57] = args.outerShadowRectCssHeight;
3508
+ d[58] = 0;
3509
+ d[59] = 0;
3510
+ d[60] = args.backdropToneAnchorX[0];
3511
+ d[61] = args.backdropToneAnchorX[1];
3512
+ d[62] = args.backdropToneAnchorX[2];
3513
+ d[63] = args.backdropToneLinearMean;
3514
+ d[64] = args.backdropToneResponseThin[0];
3515
+ d[65] = args.backdropToneResponseThin[1];
3516
+ d[66] = args.backdropToneResponseThin[2];
3517
+ d[67] = args.backdropToneResponseStrength;
3518
+ d[68] = args.backdropToneResponseThick[0];
3519
+ d[69] = args.backdropToneResponseThick[1];
3520
+ d[70] = args.backdropToneResponseThick[2];
3521
+ d[71] = 0;
3522
+ d[72] = args.sizeScatterFloor;
3523
+ d[73] = args.sizeScatterSpanMax;
3524
+ d[74] = args.sizeSpanMin;
3525
+ d[75] = args.sizeSpanMax;
2382
3526
  slot.write();
2383
3527
  const chain = args.backdrop?.chain ?? placeholderView;
2384
3528
  const body = args.backdrop?.body ?? placeholderView;
@@ -2408,7 +3552,7 @@ function createPassRunner(context) {
2408
3552
  pass.end();
2409
3553
  },
2410
3554
  highlightPass(encoder, args) {
2411
- const slot = uniformSlot(`highlight:${args.groupId}`, 20);
3555
+ const slot = uniformSlot(`highlight:${args.groupId}`, 32);
2412
3556
  const d = slot.data;
2413
3557
  d[0] = args.viewportDevice[0];
2414
3558
  d[1] = args.viewportDevice[1];
@@ -2430,6 +3574,19 @@ function createPassRunner(context) {
2430
3574
  d[17] = args.fields.height;
2431
3575
  d[18] = args.fields.upsampled ? 1 : 0;
2432
3576
  d[19] = 0;
3577
+ d[20] = args.backdropTone[0];
3578
+ d[21] = Math.max(args.backdropTone[1], args.backdropTone[0] + 1e-4);
3579
+ d[22] = args.backdropTone[2];
3580
+ d[23] = args.backdropTone[3];
3581
+ d[24] = args.backdropToneLevel;
3582
+ d[25] = args.sizeSpanMin;
3583
+ d[26] = args.sizeSpanMax;
3584
+ d[27] = args.sizeFold;
3585
+ const fieldRect = args.fieldRectDevice;
3586
+ d[28] = args.rectDevice.width / Math.max(fieldRect.width, 1e-6);
3587
+ d[29] = args.rectDevice.height / Math.max(fieldRect.height, 1e-6);
3588
+ d[30] = (args.rectDevice.x - fieldRect.x) / Math.max(fieldRect.width, 1e-6);
3589
+ d[31] = (args.rectDevice.y - fieldRect.y) / Math.max(fieldRect.height, 1e-6);
2433
3590
  slot.write();
2434
3591
  const pipeline = highlightPipeline(args.targetFormat);
2435
3592
  const pass = encoder.beginRenderPass({
@@ -2540,6 +3697,13 @@ function planPyramid(sourceWidth, sourceHeight, policy) {
2540
3697
  };
2541
3698
  }
2542
3699
  var CHAIN_SIGMA_AT_LEVEL_1 = 1.2;
3700
+ function chainLodForSigma(sigmaTexels) {
3701
+ if (!(sigmaTexels > 0))
3702
+ return 0;
3703
+ if (sigmaTexels <= CHAIN_SIGMA_AT_LEVEL_1)
3704
+ return sigmaTexels / CHAIN_SIGMA_AT_LEVEL_1;
3705
+ return 1 + Math.log2(sigmaTexels / CHAIN_SIGMA_AT_LEVEL_1);
3706
+ }
2543
3707
  function bodyBlurPlan(sigmaPx, plan) {
2544
3708
  if (sigmaPx <= 0)
2545
3709
  return { level: 0, residualSigmaTexels: 0 };
@@ -2608,6 +3772,11 @@ function createRebuildLedger() {
2608
3772
 
2609
3773
  // ../renderer-webgpu/dist/pyramid.js
2610
3774
  var chainUsage = () => GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING;
3775
+ var densityOf = (source, request) => texelsPerCssPx(source.width, source.height, request.placement, request.viewportCss[0], request.viewportCss[1]);
3776
+ var sameDensity = (existing, request) => {
3777
+ const next = densityOf({ width: existing.sourceWidth, height: existing.sourceHeight }, request);
3778
+ return Math.abs(next - existing.texelsPerCss) <= 1e-6 * Math.max(1, existing.texelsPerCss);
3779
+ };
2611
3780
  function createPyramidStore(context) {
2612
3781
  const { device, pool, cache } = context;
2613
3782
  const resources = /* @__PURE__ */ new Map();
@@ -2683,7 +3852,7 @@ function createPyramidStore(context) {
2683
3852
  return target;
2684
3853
  };
2685
3854
  const mipView = (texture, level) => texture.createView({ baseMipLevel: level, mipLevelCount: 1, dimension: "2d" });
2686
- function allocate(sourceId, plan, bodyLevel, sizeEpoch, builtEpoch) {
3855
+ function allocate(sourceId, plan, bodyLevel, sizeEpoch, builtEpoch, bodySigmaTexels, density) {
2687
3856
  const existing = resources.get(sourceId);
2688
3857
  const bodyWidth = (plan.levels[bodyLevel] ?? plan.levels[0]).width;
2689
3858
  const bodyHeight = (plan.levels[bodyLevel] ?? plan.levels[0]).height;
@@ -2720,7 +3889,11 @@ function createPyramidStore(context) {
2720
3889
  body,
2721
3890
  stats,
2722
3891
  sizeEpoch,
2723
- builtEpoch
3892
+ builtEpoch,
3893
+ bodySigmaTexels,
3894
+ texelsPerCss: density.texelsPerCss,
3895
+ sourceWidth: density.sourceWidth,
3896
+ sourceHeight: density.sourceHeight
2724
3897
  };
2725
3898
  resources.set(sourceId, next);
2726
3899
  return next;
@@ -2908,7 +4081,7 @@ function createPyramidStore(context) {
2908
4081
  },
2909
4082
  build(request, provider, encoder) {
2910
4083
  const existing = liveResources(request.sourceId);
2911
- if (existing !== void 0 && existing.builtEpoch >= request.epoch && !provider.isDirty()) {
4084
+ if (existing !== void 0 && existing.builtEpoch >= request.epoch && !provider.isDirty() && sameDensity(existing, request)) {
2912
4085
  ledger.recordClean();
2913
4086
  return { status: "clean", resources: existing };
2914
4087
  }
@@ -2926,11 +4099,11 @@ function createPyramidStore(context) {
2926
4099
  }
2927
4100
  pendingRelease.push(provider);
2928
4101
  const plan = planPyramid(frame.width, frame.height, request.resolution);
2929
- const [viewportW, viewportH] = request.viewportCss;
2930
- const cover = viewportW > 0 && viewportH > 0 ? Math.max(frame.width / viewportW, frame.height / viewportH) : 1;
4102
+ const texelsPerCss = densityOf(frame, request);
2931
4103
  const planScale = frame.width > 0 ? plan.width / frame.width : 1;
2932
- const bodyPlan = bodyBlurPlan(request.bodySigmaCss * cover * planScale, plan);
2933
- const target = allocate(request.sourceId, plan, bodyPlan.level, frame.sizeEpoch, request.epoch);
4104
+ const bodySigmaTexels = request.bodySigmaCss * texelsPerCss * planScale;
4105
+ const bodyPlan = bodyBlurPlan(bodySigmaTexels, plan);
4106
+ const target = allocate(request.sourceId, plan, bodyPlan.level, frame.sizeEpoch, request.epoch, bodySigmaTexels, { texelsPerCss, sourceWidth: frame.width, sourceHeight: frame.height });
2934
4107
  runImport(encoder, request.sourceId, frame, target.chain);
2935
4108
  runChain(encoder, request.sourceId, plan, target.chain);
2936
4109
  runBodyBlur(encoder, request.sourceId, plan, target.chain, target.body, bodyPlan.level, bodyPlan.residualSigmaTexels);
@@ -3046,17 +4219,11 @@ var RENDERER_PASS_IDS = [
3046
4219
  OPTICS_PASS_ID,
3047
4220
  HIGHLIGHT_PASS_ID
3048
4221
  ];
3049
- var NOMINAL_MATERIAL_POLICY = {
3050
- glass: "material",
3051
- frost: "nominal",
3052
- refraction: "nominal",
3053
- occlusion: "nominal",
3054
- border: "nominal",
3055
- ambientTint: "nominal",
3056
- foreground: "adaptive"
3057
- };
4222
+ var STANDALONE_RESOLUTION = { scale: 1, maxDimension: 2048 };
3058
4223
  function createWebGPURenderer(options = {}) {
3059
4224
  const providers = /* @__PURE__ */ new Map();
4225
+ const placements = /* @__PURE__ */ new Map();
4226
+ const lastResolution = /* @__PURE__ */ new Map();
3060
4227
  const groups = /* @__PURE__ */ new Map();
3061
4228
  const adaptation = /* @__PURE__ */ new Map();
3062
4229
  const lastReadbackAt = /* @__PURE__ */ new Map();
@@ -3145,18 +4312,34 @@ function createWebGPURenderer(options = {}) {
3145
4312
  }
3146
4313
  return state;
3147
4314
  };
3148
- const coverFit = (sourceWidth, sourceHeight) => {
3149
- const viewportAspect = viewport.heightCss > 0 ? viewport.widthCss / viewport.heightCss : 1;
3150
- const sourceAspect = sourceHeight > 0 ? sourceWidth / sourceHeight : 1;
3151
- if (sourceAspect > viewportAspect) {
3152
- const scaleX = viewportAspect / sourceAspect;
3153
- return [scaleX, 1, (1 - scaleX) / 2, 0];
4315
+ const fitFor = (sourceId, pyramid) => {
4316
+ const placement = placements.get(sourceId);
4317
+ return isUsablePlacement(placement) ? placementFit(placement, viewport.widthCss, viewport.heightCss) : coverFit(pyramid.plan.width, pyramid.plan.height, viewport.widthCss, viewport.heightCss);
4318
+ };
4319
+ const densityRebuilds = (explicit, pyramids) => {
4320
+ const requests = [];
4321
+ const named = new Set(explicit.map((request) => request.sourceId));
4322
+ for (const sourceId of providers.keys()) {
4323
+ if (named.has(sourceId))
4324
+ continue;
4325
+ const existing = pyramids.resources(sourceId);
4326
+ if (existing === void 0)
4327
+ continue;
4328
+ const next = texelsPerCssPx(existing.sourceWidth, existing.sourceHeight, placements.get(sourceId), viewport.widthCss, viewport.heightCss);
4329
+ if (Math.abs(next - existing.texelsPerCss) <= 1e-6 * Math.max(1, existing.texelsPerCss))
4330
+ continue;
4331
+ requests.push({
4332
+ sourceId,
4333
+ epoch: existing.builtEpoch,
4334
+ resolution: lastResolution.get(sourceId) ?? STANDALONE_RESOLUTION,
4335
+ groupIds: [...groups.values()].filter((entry) => entry.input.backdropSourceId === sourceId).map((entry) => entry.input.groupId)
4336
+ });
3154
4337
  }
3155
- const scaleY = sourceAspect / viewportAspect;
3156
- return [1, scaleY, 0, (1 - scaleY) / 2];
4338
+ return requests;
3157
4339
  };
3158
4340
  const unionOf = (input) => input.union ?? DEFAULT_GROUP_UNION;
3159
4341
  const variantOf = (input) => input.variant ?? "regular";
4342
+ const groupTintSeed = (input) => input.surfaces.find((surface) => surface.fieldReferenceOnly !== true && (surface.tint?.strength ?? 0) > 0)?.tint?.color;
3160
4343
  const stateOf = (input, resolution) => {
3161
4344
  const resolved = resolution?.groups.find((group) => group.groupId === input.groupId);
3162
4345
  if (resolved === void 0) {
@@ -3168,8 +4351,12 @@ function createWebGPURenderer(options = {}) {
3168
4351
  };
3169
4352
  };
3170
4353
  function rebuildRequests(explicit) {
3171
- if (explicit !== void 0)
3172
- return explicit;
4354
+ const base = explicit ?? standaloneRequests();
4355
+ const { store: pyramids } = ensureContext();
4356
+ const density = densityRebuilds(base, pyramids);
4357
+ return density.length === 0 ? base : [...base, ...density];
4358
+ }
4359
+ function standaloneRequests() {
3173
4360
  const sampled = /* @__PURE__ */ new Set();
3174
4361
  for (const entry of groups.values()) {
3175
4362
  const id = entry.input.backdropSourceId;
@@ -3187,7 +4374,7 @@ function createWebGPURenderer(options = {}) {
3187
4374
  // keeping the books, so a monotonically rising number is the honest
3188
4375
  // stand-in and it keeps `build`'s clean-skip from firing spuriously.
3189
4376
  epoch: framesDrawn + 1,
3190
- resolution: { scale: 1, maxDimension: 2048 },
4377
+ resolution: STANDALONE_RESOLUTION,
3191
4378
  groupIds: [...groups.values()].filter((entry) => entry.input.backdropSourceId === sourceId).map((entry) => entry.input.groupId)
3192
4379
  });
3193
4380
  }
@@ -3205,12 +4392,15 @@ function createWebGPURenderer(options = {}) {
3205
4392
  }
3206
4393
  const variant = variantOf([...groups.values()].find((entry) => entry.input.backdropSourceId === request.sourceId)?.input ?? { });
3207
4394
  const optics = opticsUnderPolicy(material.optics[variant], accessibility, material);
4395
+ lastResolution.set(request.sourceId, request.resolution);
4396
+ const placement = placements.get(request.sourceId);
3208
4397
  const outcome = pyramids.build({
3209
4398
  sourceId: request.sourceId,
3210
4399
  epoch: request.epoch,
3211
4400
  resolution: request.resolution,
3212
4401
  bodySigmaCss: optics.blurSigma,
3213
- viewportCss: [viewport.widthCss, viewport.heightCss]
4402
+ viewportCss: [viewport.widthCss, viewport.heightCss],
4403
+ ...isUsablePlacement(placement) ? { placement } : {}
3214
4404
  }, provider, encoder);
3215
4405
  if (outcome.status === "built")
3216
4406
  built += 1;
@@ -3246,9 +4436,10 @@ function createWebGPURenderer(options = {}) {
3246
4436
  const input = entry.input;
3247
4437
  if (input.surfaces.length === 0)
3248
4438
  continue;
4439
+ const policy = resolution?.accessibility.material ?? accessibility;
3249
4440
  let surfaces;
3250
4441
  try {
3251
- surfaces = resolveSurfaces(input, governor.knobs.fieldFamily, material);
4442
+ surfaces = resolveSurfaces(input, governor.knobs.fieldFamily, material, policy);
3252
4443
  } catch (error) {
3253
4444
  skipped.push({
3254
4445
  groupId: input.groupId,
@@ -3257,10 +4448,17 @@ function createWebGPURenderer(options = {}) {
3257
4448
  continue;
3258
4449
  }
3259
4450
  const union = unionOf(input);
3260
- const snapped = snapRectToDevicePixels(groupFieldRect(surfaces, union), dpr);
4451
+ const shadow = outerShadowUnderPolicy(policy, material);
4452
+ let reachOcclusion = shadow.occlusion;
4453
+ for (const surface of surfaces) {
4454
+ reachOcclusion = Math.max(reachOcclusion, sizeOuterShadowOcclusionAt(shadow.occlusion, surface.sizeThickness, material));
4455
+ }
4456
+ const shadowReachPx = outerShadowReachPx({ ...shadow, occlusion: reachOcclusion });
4457
+ const snapped = snapRectToDevicePixels(groupFieldRect(surfaces, union, void 0, shadowReachPx), dpr);
3261
4458
  const rectDevice = clipFieldRectToCanvas(snapped, dpr, viewportDevice);
3262
4459
  if (rectDevice === void 0)
3263
4460
  continue;
4461
+ const surfaceRectDevice = shadowReachPx === 0 ? rectDevice : clipFieldRectToCanvas(snapRectToDevicePixels(groupFieldRect(surfaces, union), dpr), dpr, viewportDevice) ?? rectDevice;
3264
4462
  const packed = packInstances(surfaces, [
3265
4463
  rectDevice.x * cssPerDevice,
3266
4464
  rectDevice.y * cssPerDevice
@@ -3279,14 +4477,25 @@ function createWebGPURenderer(options = {}) {
3279
4477
  union
3280
4478
  });
3281
4479
  const state = stateOf(input, resolution);
3282
- const policy = resolution?.accessibility.material ?? accessibility;
3283
4480
  const variant = variantOf(input);
3284
- const optics = opticsUnderPolicy(material.optics[variant], policy, material);
3285
- const refraction = effectiveRefraction(accessibilityRefractionCap(policy), state.refraction);
3286
- const refractionScale = material.refractionScale[refraction];
3287
4481
  const sourceId = input.backdropSourceId;
3288
4482
  const pyramid = sourceId === void 0 ? void 0 : pyramids.resources(sourceId);
3289
4483
  const adapt = sourceId === void 0 ? void 0 : adaptationFor(sourceId).values;
4484
+ const nominalOptics = material.optics[variant];
4485
+ const optics = opticsUnderPolicy(pyramid === void 0 && input.unsampledMaterial !== void 0 ? {
4486
+ ...nominalOptics,
4487
+ tint: input.unsampledMaterial.tint,
4488
+ tintAlpha: input.unsampledMaterial.tintAlpha
4489
+ } : nominalOptics, policy, material);
4490
+ const backdropTone = [
4491
+ material.backdropToneLow,
4492
+ material.backdropToneHigh,
4493
+ backdropToneSizeBiasUnderPolicy(policy, material),
4494
+ input.backdropTone === void 0 ? 0 : backdropToneUnderPolicy(policy, material) * material.backdropToneMax
4495
+ ];
4496
+ const backdropToneLevel = input.backdropTone === void 0 ? 0 : input.backdropToneLevel ?? relativeLuminance(input.backdropTone);
4497
+ const refraction = effectiveRefraction(accessibilityRefractionCap(policy), state.refraction);
4498
+ const refractionScale = material.refractionScale[refraction];
3290
4499
  passes.opticsPass(encoder, {
3291
4500
  groupId: input.groupId,
3292
4501
  target: active.optics,
@@ -3296,15 +4505,29 @@ function createWebGPURenderer(options = {}) {
3296
4505
  viewportDevice,
3297
4506
  cssPerDevice,
3298
4507
  coverageRampCss: cssPerDevice,
3299
- fit: pyramid === void 0 ? [1, 1, 0, 0] : coverFit(pyramid.plan.width, pyramid.plan.height),
4508
+ fit: pyramid === void 0 || sourceId === void 0 ? [1, 1, 0, 0] : fitFor(sourceId, pyramid),
3300
4509
  refractionScale,
3301
- bodyLodPerPx: material.lensBodyLodPerPx,
3302
- rimLodBias: material.lensRimLodBias,
4510
+ lensRefractionGain: material.lensRefractionGain,
3303
4511
  chainMaxLod: pyramid?.plan.maxLod ?? 0,
3304
4512
  tint: optics.tint,
3305
4513
  tintAlpha: optics.tintAlpha,
3306
4514
  adaptTint: adapt?.tint ?? optics.tint,
3307
4515
  adaptStrength: adapt?.observed === true ? adaptationStrength(policy, state.analysisExact, material) : 0,
4516
+ /*
4517
+ * `glass: "none"` is forced colours, and it takes the author's tint with
4518
+ * the rest of the material.
4519
+ *
4520
+ * The pass keeps running — it is what paints the surface at all on this
4521
+ * tier — but with no backdrop and an opaque occlusion, so whatever colour
4522
+ * sits here IS the surface. An author tint arriving into that lands as a
4523
+ * flat fill of the author's colour where the platform's palette is
4524
+ * required, which is the one composition forced colours exists to
4525
+ * prevent. Falling back to the material's own tint reproduces exactly
4526
+ * what an untinted surface draws.
4527
+ */
4528
+ tintSeed: (policy.glass === "none" ? void 0 : groupTintSeed(input)) ?? optics.tint,
4529
+ tintToneAdaptation: tintToneAdaptation(policy, material),
4530
+ tintShade: [material.tintShadeDark, material.tintShadeLight, material.tintShadeStrength],
3308
4531
  rimWidth: optics.rimWidth,
3309
4532
  rimAlpha: optics.rimAlpha,
3310
4533
  specularPower: optics.specularPower,
@@ -3312,6 +4535,60 @@ function createWebGPURenderer(options = {}) {
3312
4535
  lightDirection: material.lightDirection,
3313
4536
  shadowDepth: optics.shadowDepth,
3314
4537
  shadowAlpha: optics.shadowAlpha,
4538
+ // The size law's gains, per group (W2); the per-pixel factor they
4539
+ // multiply rides the field pass's aux channel, so one uniform serves a
4540
+ // group whose members are not one size. `bodyChainLod` is where the body
4541
+ // blur already sits on the chain — only the pyramid that built it knows
4542
+ // the CSS-px-to-texel conversion, so it publishes the σ and this converts.
4543
+ sizeScatterGainMax: material.sizeScatterGainMax,
4544
+ sizeOcclusionGain: material.sizeOcclusionGain,
4545
+ sizeShadowGainMax: material.sizeShadowGainMax,
4546
+ bodyChainLod: chainLodForSigma(pyramid?.bodySigmaTexels ?? 0),
4547
+ // The size law's curves (W11c): the shader evaluates the thickness and
4548
+ // the scatter mix per pixel from the span the field carries, under the
4549
+ // same fold `sizeThicknessUnderPolicy` applies on the CPU.
4550
+ sizeSpanMin: material.sizeSpanMin,
4551
+ sizeSpanMax: material.sizeSpanMax,
4552
+ sizeScatterFloor: material.sizeScatterFloor,
4553
+ sizeScatterSpanMax: material.sizeScatterSpanMax,
4554
+ sizeFold: material.refractionScale[accessibilityRefractionCap(policy)],
4555
+ backdropTone,
4556
+ backdropToneColour: [
4557
+ input.backdropTone?.[0] ?? 0,
4558
+ input.backdropTone?.[1] ?? 0,
4559
+ input.backdropTone?.[2] ?? 0,
4560
+ backdropToneLevel
4561
+ ],
4562
+ // The response law's anchors (W9) and the linear backdrop mean its
4563
+ // solve composites against. The shader re-derives the encoded input
4564
+ // from `backdropToneLevel`, so the fourth slot carries the linear
4565
+ // mean — the one quantity the solve needs that the tone colour does
4566
+ // not already hold.
4567
+ backdropToneAnchorX: material.backdropToneAnchorX,
4568
+ backdropToneResponseThin: material.backdropToneResponseThin,
4569
+ backdropToneResponseThick: material.backdropToneResponseThick,
4570
+ // The response law rides only the UN-DEGRADED regime, for the same
4571
+ // provenance reason the dark profiles set its strength to 0: the
4572
+ // anchors are standard-light-reference measurements, and the
4573
+ // accessibility references behave differently (nearly opaque, flat in
4574
+ // span — W2). Measured before this gate existed: reduced-transparency
4575
+ // photo ΔE p95 0.007 → 0.048 and cross-tier ratio 0.99 → 0.86. Where
4576
+ // any policy fold touches the tone axis, the solve stands down and the
4577
+ // collapse alone runs, exactly the pre-W9 behaviour those profiles
4578
+ // were fitted on.
4579
+ backdropToneResponseStrength: backdropToneUnderPolicy(policy, material) >= 0.999 ? material.backdropToneResponseStrength : 0,
4580
+ backdropToneLinearMean: input.backdropToneLinearLuminance ?? backdropToneLevel,
4581
+ outerShadow: [
4582
+ outerShadowAlpha(shadow.occlusion),
4583
+ shadow.sigmaPx,
4584
+ shadow.spreadPx,
4585
+ // The offset in field-texture UV. The field spans exactly this rect, so
4586
+ // one CSS px is one over the rect's CSS height however the governor
4587
+ // scaled the rasterisation.
4588
+ shadow.offsetPx / Math.max(rectDevice.height * cssPerDevice, 1e-6)
4589
+ ],
4590
+ outerShadowSizeGain: shadow.sizeGain,
4591
+ outerShadowRectCssHeight: rectDevice.height * cssPerDevice,
3315
4592
  backdrop: pyramid === void 0 || policy.glass === "none" ? void 0 : {
3316
4593
  chain: pyramid.chain.createView(),
3317
4594
  body: pyramid.body.createView()
@@ -3323,7 +4600,9 @@ function createWebGPURenderer(options = {}) {
3323
4600
  groupId: input.groupId,
3324
4601
  target: active.highlight,
3325
4602
  targetFormat: active.format,
3326
- rectDevice,
4603
+ // The surface's own rect, not the shadow's — see `surfaceRectDevice`.
4604
+ rectDevice: surfaceRectDevice,
4605
+ fieldRectDevice: rectDevice,
3327
4606
  fields,
3328
4607
  viewportDevice,
3329
4608
  cssPerDevice,
@@ -3335,7 +4614,15 @@ function createWebGPURenderer(options = {}) {
3335
4614
  pressPointCss: lead.channels.pressPoint ?? lead.centre,
3336
4615
  glowRadiusCss: material.glowRadiusCss,
3337
4616
  glowGain: material.glowGain,
3338
- colour: optics.highlight
4617
+ colour: optics.highlight,
4618
+ // The same four numbers the optics pass took, so the two passes fade
4619
+ // the surface on one curve rather than on two copies of it — and the
4620
+ // same band and fold, since the curve is evaluated off the span.
4621
+ backdropTone,
4622
+ backdropToneLevel,
4623
+ sizeSpanMin: material.sizeSpanMin,
4624
+ sizeSpanMax: material.sizeSpanMax,
4625
+ sizeFold: material.refractionScale[accessibilityRefractionCap(policy)]
3339
4626
  });
3340
4627
  }
3341
4628
  drawn += 1;
@@ -3400,6 +4687,8 @@ function createWebGPURenderer(options = {}) {
3400
4687
  providers.set(provider.id, provider);
3401
4688
  },
3402
4689
  unregisterBackdrop(sourceId) {
4690
+ placements.delete(sourceId);
4691
+ lastResolution.delete(sourceId);
3403
4692
  const provider = providers.get(sourceId);
3404
4693
  if (provider === void 0)
3405
4694
  return;
@@ -3412,6 +4701,17 @@ function createWebGPURenderer(options = {}) {
3412
4701
  backdrop(sourceId) {
3413
4702
  return providers.get(sourceId);
3414
4703
  },
4704
+ setBackdropPlacement(sourceId, placement) {
4705
+ if (samePlacement(placements.get(sourceId), placement))
4706
+ return;
4707
+ if (placement === void 0)
4708
+ placements.delete(sourceId);
4709
+ else
4710
+ placements.set(sourceId, { ...placement });
4711
+ },
4712
+ backdropPlacement(sourceId) {
4713
+ return placements.get(sourceId);
4714
+ },
3415
4715
  setViewport(next) {
3416
4716
  const changed = next.widthCss !== viewport.widthCss || next.heightCss !== viewport.heightCss || next.devicePixelRatio !== viewport.devicePixelRatio;
3417
4717
  viewport = next;
@@ -3568,6 +4868,6 @@ function createWebGPURenderer(options = {}) {
3568
4868
  };
3569
4869
  }
3570
4870
 
3571
- export { ADAPTIVE_LUMINANCE_HIGH, ADAPTIVE_LUMINANCE_LOW, ADAPTIVE_TINT_DARK, ADAPTIVE_TINT_LIGHT, ANALYSIS_DISPATCH, ANALYSIS_GRID, ANALYSIS_PASS_ID, ANALYSIS_STATS_FLOATS, ANALYSIS_TARGET_EXTENT, ANALYSIS_WORKGROUP, BACKDROP_ALPHA_MODES, BACKDROP_COLOR_SPACES, BACKDROP_KINDS, BACKDROP_PASS_ID, CANVAS_FORMAT, CHAIN_SIGMA_AT_LEVEL_1, CROSS_CHECK_SHAPE_FLOATS, CROSS_CHECK_WORKGROUP, DEFAULT_MATERIAL_PROFILE, FAMILY_C_CROSS_CHECK, FIELD_PASS_ID, GOVERNOR_LADDER, HIGHLIGHT_PASS_ID, IDLE_CHANNELS, INCREASED_OCCLUSION_LIFT, INSTANCE_BYTES, INSTANCE_FLOATS, LENS_BODY_LOD_PER_PX, LENS_RIM_LOD_BIAS, LENS_SIZE_GAIN_MAX, LENS_SPAN_MAX, LENS_SPAN_MIN, LUMINANCE_WEIGHTS, MATERIAL_OPTICS, MATERIAL_VARIANTS, MAX_CHAIN_LEVELS, MIN_LEVEL_EXTENT, NOMINAL_GOVERNOR, NOMINAL_MATERIAL_POLICY, OPTICS_PASS_ID, OUTPUT_TEXTURE_FORMAT, PASS_LABEL, REFRACTION_LADDER, REFRACTION_SCALE, RENDERER_ERROR_CODES, RENDERER_PASS_IDS, RendererError, SUPPORTED_APP_TEXTURE_FORMATS, WGSL_ANALYSIS_PASS, WGSL_CROSS_CHECK_PASS, WGSL_DOWNSAMPLE_PASS, WGSL_FIELD_KERNELS, WGSL_FIELD_PASS, WGSL_FIELD_SAMPLE, WGSL_HIGHLIGHT_PASS, WGSL_IMPORT_PASS, WGSL_INSTANCE_STRUCT, WGSL_OPTICS_PASS, WGSL_PRELUDE, WGSL_RSUPN_GRAD, WGSL_RSUP_GRAD, WGSL_SMOOTH_UNION, WORKING_TEXTURE_FORMAT, ZERO_STATS, accessibilityRefractionCap, adaptationStrength, adaptiveTint, allShaderSource, alphaNormalisationMode, analysisModule, bodyBlurPlan, bodyLod, chainModule, clipFieldRectToCanvas, createAdaptationState, createAppTextureProvider, createCopyProvider, createDeviceHost, createGovernor, createGpuContext, createGradientProvider, createPassRunner, createPipelineCache, createPyramidStore, createRebuildLedger, createStorageSlot, createTexturePool, createTimingCollector, createUniformSlot, createVideoProvider, createWebGPURenderer, crossCheckKernelModule, displayP3ToSrgbLinear, effectiveRefraction, encodeOutput, encodeOutputBytes, fieldModule, fieldPassSource, groupFieldRect, highlightModule, importColorMatrix, importModule, importPassSource, lensDepthPx, lensSizeGain, linearGradientStops, linearToSrgb, linearToSrgbChannel, occlusionAlphaUnderPolicy, opticsModule, opticsUnderPolicy, packInstances, pipelineKey, planPyramid, poolKey, readbackDue, refractionRank, relativeLuminance, rendererError, resolveSurfaces, snapRectToDevicePixels, srgbToLinear, srgbToLinearChannel, statsFromBuffer, supportsTimestamps, validateAppTexture, withMaterialOverrides };
3572
- //# sourceMappingURL=dist-FGJI5LQM.js.map
3573
- //# sourceMappingURL=dist-FGJI5LQM.js.map
4871
+ export { ADAPTIVE_LUMINANCE_HIGH, ADAPTIVE_LUMINANCE_LOW, ADAPTIVE_TINT_DARK, ADAPTIVE_TINT_LIGHT, ANALYSIS_DISPATCH, ANALYSIS_GRID, ANALYSIS_PASS_ID, ANALYSIS_STATS_FLOATS, ANALYSIS_TARGET_EXTENT, ANALYSIS_WORKGROUP, BACKDROP_ALPHA_MODES, BACKDROP_COLOR_SPACES, BACKDROP_KINDS, BACKDROP_PASS_ID, BACKDROP_TONE_HIGH, BACKDROP_TONE_LOW, BACKDROP_TONE_MAX, BACKDROP_TONE_SIZE_BIAS, CANVAS_FORMAT, CHAIN_SIGMA_AT_LEVEL_1, CROSS_CHECK_SHAPE_FLOATS, CROSS_CHECK_WORKGROUP, DEFAULT_MATERIAL_PROFILE, FAMILY_C_CROSS_CHECK, FIELD_PASS_ID, GOVERNOR_LADDER, HIGHLIGHT_PASS_ID, IDENTITY_FIT, IDLE_CHANNELS, INCREASED_OCCLUSION_LIFT, INSTANCE_BYTES, INSTANCE_FLOATS, LENS_REFRACTION_GAIN, LENS_SIZE_GAIN_MAX, LUMINANCE_WEIGHTS, MATERIAL_OPTICS, MATERIAL_VARIANTS, MAX_CHAIN_LEVELS, MIN_LEVEL_EXTENT, NOMINAL_GOVERNOR, NOMINAL_MATERIAL_POLICY, OPTICS_PASS_ID, OUTER_SHADOW, OUTPUT_TEXTURE_FORMAT, PASS_LABEL, REFRACTION_LADDER, REFRACTION_SCALE, RENDERER_ERROR_CODES, RENDERER_PASS_IDS, RendererError, SIZE_OCCLUSION_GAIN, SIZE_SCATTER_FLOOR, SIZE_SCATTER_GAIN_MAX, SIZE_SCATTER_SPAN_MAX, SIZE_SHADOW_GAIN_MAX, SIZE_SPAN_MAX, SIZE_SPAN_MIN, SRGB_ENCODING_EXPONENT, SUPPORTED_APP_TEXTURE_FORMATS, WGSL_ANALYSIS_PASS, WGSL_CROSS_CHECK_PASS, WGSL_DOWNSAMPLE_PASS, WGSL_FIELD_KERNELS, WGSL_FIELD_PASS, WGSL_FIELD_SAMPLE, WGSL_HIGHLIGHT_PASS, WGSL_IMPORT_PASS, WGSL_INSTANCE_STRUCT, WGSL_OPTICS_PASS, WGSL_PRELUDE, WGSL_RSUPN_GRAD, WGSL_RSUP_GRAD, WGSL_SMOOTH_UNION, WORKING_TEXTURE_FORMAT, ZERO_STATS, accessibilityRefractionCap, adaptationStrength, adaptedTintAlpha, adaptedTintColour, adaptiveTint, allShaderSource, alphaNormalisationMode, analysisModule, backdropFit, backdropToneAdaptation, backdropToneSizeBiasUnderPolicy, backdropToneUnderPolicy, bodyBlurPlan, chainModule, clipFieldRectToCanvas, coverFit, createAdaptationState, createAppTextureProvider, createCopyProvider, createDeviceHost, createGovernor, createGpuContext, createGradientProvider, createPassRunner, createPipelineCache, createPyramidStore, createRebuildLedger, createStorageSlot, createTexturePool, createTimingCollector, createUniformSlot, createVideoProvider, createWebGPURenderer, crossCheckKernelModule, displayP3ToSrgbLinear, effectiveRefraction, encodeOutput, encodeOutputBytes, fieldModule, fieldPassSource, groupFieldRect, highlightModule, importColorMatrix, importModule, importPassSource, isUsablePlacement, lensDepthPx, lensDisplacementPx, lensSizeGain, lensSizeGainFromThickness, linearGradientStops, linearToSrgb, linearToSrgbChannel, occlusionAlphaUnderPolicy, opticsModule, opticsUnderPolicy, outerShadowAlpha, outerShadowFalloff, outerShadowReachPx, outerShadowUnderPolicy, packInstances, pipelineKey, placementFit, planPyramid, poolKey, readbackDue, refractionRank, relativeLuminance, rendererError, resolveSurfaces, samePlacement, scatterThickness, sizeOcclusionAlpha, sizeOcclusionAlphaAt, sizeOuterShadowOcclusion, sizeOuterShadowOcclusionAt, sizeScatterSigma, sizeScatterSigmaAt, sizeShadowDepth, sizeShadowDepthAt, sizeThickness, sizeThicknessUnderPolicy, snapRectToDevicePixels, srgbToLinear, srgbToLinearChannel, statsFromBuffer, supportsTimestamps, texelsPerCssPx, tintShade, tintShadeLayer, tintToneAdaptation, tintedMaterialColour, validateAppTexture, withMaterialOverrides };
4872
+ //# sourceMappingURL=dist-MVSXUP3K.js.map
4873
+ //# sourceMappingURL=dist-MVSXUP3K.js.map