Repository navigation
Expand file tree
/
Copy pathlighting.ts
More file actions
469 lines (436 loc) · 16.8 KB
/
Copy pathlighting.ts
File metadata and controls
469 lines (436 loc) · 16.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
/**
* Shadow and glow color computation.
*
* Owns the shadow / glow / mix def predicates, the default lighting tuning
* and its merge, and the exact lighting solver (`computeLighting`). Both
* types emit a translucent pigment over a known surface (`bg`) whose opacity
* is solved so the composite lands an exact tone step away from it: darker
* for `shadow`, lighter for `glow`, in every scheme. Compositing is modeled
* the way browsers paint it — `source-over` on gamma-encoded sRGB — and the
* step is measured in the WCAG-shaped tone of the composite's luminance, so
* it holds on chromatic surfaces too. An optional object (`fg`) scales the
* step the way bounce light does. The resolver consumes this module per
* scheme variant; `glaze.shadow()` / `glaze.glow()` call it directly.
*
* HDR glows: with `glowTuning.headroom` above 0, a glow whose target tone
* (surface tone + step) passes 100 also gets an HDR value, computed in closed
* form after the untouched SDR solve. SDR is pinned at its brightest there —
* the pigment at opacity 1, or a white surface at opacity 0 — and the HDR
* value is an opaque exposure of that color: the base times `2^exposure`, the
* gain that lands the exact step, capped per channel at `2^headroom`
* (`MAX_HEADROOM` stops at most). A pigment that cannot lift a non-white
* surface at all gets none and keeps the SDR shortfall warning. Since the
* value lands the whole step, only a full-reach glow meets SDR at the boundary
* (`exposure` → 0); one with `reach` below 1 jumps from its pigment to the
* exact step. `hdrToLinearSrgb` / `hdrToSrgb` turn a resolved HDR value back
* into linear or extended sRGB channels for the writers (both public).
*/
import {
compositeOverGamma,
okhslToLinearSrgb,
okhslToOklab,
oklabToOkhsl,
relativeLuminanceFromLinearRgb,
sRGBLinearToGamma,
toDisplayGamma,
} from './okhsl-color-math';
import { clamp } from './hc-pair';
import {
fromTone,
okhslToOkhst,
okhstToOkhsl,
toTone,
toneFromY,
yFromTone,
} from './okhst';
import type {
ColorDef,
GlowColorDef,
GlowTuning,
LightingTuning,
MixColorDef,
ResolvedHdr,
ShadowColorDef,
} from './types';
export type LightingKind = 'shadow' | 'glow';
/** OKHSL color fed to the lighting solver (`h` 0–360, `s` / `l` 0–1). */
export interface LightingColor {
h: number;
s: number;
l: number;
/** Gamut flag for this color's own sRGB conversion. */
pastel?: boolean;
}
/** Output of {@link computeLighting}. */
export interface LightingResult {
/** Pigment hue (0–360). */
h: number;
/** Pigment OKHSL saturation (0–1). */
s: number;
/** Pigment OKHSL lightness (0–1). */
l: number;
/** Solved pigment opacity (0–1). */
alpha: number;
/** Requested tone step: `intensity × k`. */
step: number;
/** Tone step the composite actually lands. Below `step` only when unreachable. */
reached: number;
/**
* HDR value of a glow whose target tone passes 100 with `headroom` above 0,
* when SDR is pinned at its brightest (see `pinnedBase`); absent otherwise.
* The SDR fields above are unchanged by it.
*/
hdr?: LightingHdr;
}
/** The HDR value in a {@link LightingResult}. */
export interface LightingHdr {
/**
* Base hue (0–360). The base is what SDR paints at its brightest, opaque:
* the pigment, or the white surface it cannot lift.
*/
h: number;
/** Base OKHSL saturation (0–1, always non-pastel). */
s: number;
/** Base OKHSL lightness (0–1). */
l: number;
/** Gain over the base in stops, above 0. */
exposure: number;
/**
* Tone step the exposed base lands above the surface: `step`, or less when
* the `headroom` cap binds.
*/
reached: number;
}
/** Most stops of HDR headroom a glow may use; larger values clamp to it. */
export const MAX_HEADROOM = 2;
/**
* A merged tuning: every {@link LightingTuning} field, plus the glow-only
* `headroom` in stops (0 for a shadow, which never carries one).
*/
export type ResolvedLightingTuning = Required<LightingTuning> & {
headroom: number;
};
export function isShadowDef(def: ColorDef): def is ShadowColorDef {
return (def as ShadowColorDef).type === 'shadow';
}
export function isGlowDef(def: ColorDef): def is GlowColorDef {
return (def as GlowColorDef).type === 'glow';
}
/** A `shadow` or `glow` def — the translucent overlay types. */
export function isLightingDef(
def: ColorDef,
): def is ShadowColorDef | GlowColorDef {
return isShadowDef(def) || isGlowDef(def);
}
export function isMixDef(def: ColorDef): def is MixColorDef {
return (def as MixColorDef).type === 'mix';
}
export const DEFAULT_LIGHTING_TUNING: Required<LightingTuning> = {
objectInfluence: 0.5,
tint: 0,
reach: 1,
hueBleed: 0,
};
/**
* Defaults ← global (`shadowTuning` / `glowTuning`) ← per-color, field by
* field. `headroom` defaults to 0 and clamps to `0–MAX_HEADROOM`; the config
* and def validators have already rejected a non-finite or negative value and
* warned about one above the maximum.
*/
export function resolveLightingTuning(
perColor?: GlowTuning,
globalTuning?: GlowTuning,
): ResolvedLightingTuning {
// `??`, not a spread: a field set to `undefined` is unset, not a value.
const field = <K extends keyof LightingTuning>(key: K) =>
perColor?.[key] ?? globalTuning?.[key] ?? DEFAULT_LIGHTING_TUNING[key];
const headroom = perColor?.headroom ?? globalTuning?.headroom ?? 0;
return {
objectInfluence: field('objectInfluence'),
tint: field('tint'),
reach: field('reach'),
hueBleed: field('hueBleed'),
headroom: clamp(headroom, 0, MAX_HEADROOM),
};
}
export function circularLerp(a: number, b: number, t: number): number {
let diff = b - a;
if (diff > 180) diff -= 360;
else if (diff < -180) diff += 360;
return (((a + diff * t) % 360) + 360) % 360;
}
/**
* Saturation at or below which a color has no hue of its own. A gray given as
* a value parses with saturation 0; a near-gray under this has a hue too
* faint to give a tint.
*/
const HUE_EPSILON = 1e-4;
const BISECT_ITERATIONS = 40;
const GOLDEN = (Math.sqrt(5) - 1) / 2;
/** Float noise a target tone may sit above 100 without counting as HDR. */
const HDR_TRIGGER_EPS = 1e-9;
/** Float noise a pigment may move the surface by and still count as not. */
const NO_LIFT_EPS = 1e-9;
/** Float noise a surface tone may sit below 100 and still count as white. */
const WHITE_SURFACE_EPS = 1e-6;
/**
* Object factor `k = max(0, 1 + β · d · (t_bg − t_fg) / 100)`, with `d = +1`
* for a shadow and `−1` for a glow. Calibrated so an object at the surface's
* own tone — or no object at all — gives exactly the authored step: a darker
* object deepens its shadow, a lighter one strengthens its glow.
*/
export function objectFactor(
kind: LightingKind,
bgTone: number,
fgTone: number | undefined,
objectInfluence: number,
): number {
if (fgTone === undefined) return 1;
const d = kind === 'shadow' ? 1 : -1;
return Math.max(0, 1 + (objectInfluence * d * (bgTone - fgTone)) / 100);
}
/**
* The pigment: pure black (shadow) / pure white (glow) by default. `reach`
* below 1 parks it part of the way from the surface's tone to that extreme so
* a `tint` — saturation in the surface's hue, optionally pulled toward the
* object's hue by `hueBleed` — can show. A neutral surface has no hue to give:
* the tint takes the object's hue when `hueBleed` is above 0, and the pigment
* stays neutral otherwise.
*/
function pigmentFor(
kind: LightingKind,
bg: LightingColor,
fg: LightingColor | undefined,
bgTone: number,
tuning: Required<LightingTuning>,
): { h: number; s: number; l: number } {
const reach = clamp(tuning.reach, 0, 1);
const tone =
kind === 'shadow' ? bgTone * (1 - reach) : bgTone + (100 - bgTone) * reach;
const bleed = clamp(tuning.hueBleed, 0, 1);
const l = clamp(fromTone(tone), 0, 1);
const tint = clamp(tuning.tint, 0, 1);
const objectHue =
fg !== undefined && fg.s > HUE_EPSILON && bleed > 0 ? fg.h : undefined;
if (bg.s <= HUE_EPSILON) {
return objectHue === undefined
? { h: bg.h, s: 0, l }
: { h: objectHue, s: tint, l };
}
const h =
objectHue === undefined ? bg.h : circularLerp(bg.h, objectHue, bleed);
return { h, s: tint, l };
}
/**
* Solve the pigment opacity that moves `bg` exactly `intensity × k` tones
* darker (shadow) or lighter (glow) once the browser composites it.
*
* The composite's luminance is convex in alpha (each gamma channel moves
* linearly and the sRGB decode is convex), so the "has reached the step"
* predicate is monotone whenever full opacity reaches it and a bisection
* lands it exactly. When it doesn't, the step is out of reach — a glow on
* white, a deep shadow on near-black — and the result pins to the pigment's
* strongest composite, reporting what it reached in `reached`.
*
* A glow with `headroom` above 0 whose target tone passes 100 also gets
* `hdr` (see `exposeGlow`) when SDR is pinned at its brightest
* (`pinnedBase`); the SDR fields are the same with or without it.
*/
export function computeLighting(
kind: LightingKind,
bg: LightingColor,
fg: LightingColor | undefined,
intensity: number,
tuning: Required<LightingTuning> & GlowTuning,
pastel: boolean,
): LightingResult {
const bgTone = toTone(bg.l);
const k = objectFactor(
kind,
bgTone,
fg === undefined ? undefined : toTone(fg.l),
tuning.objectInfluence,
);
const step = clamp(intensity, 0, 100) * k;
const pigment = pigmentFor(kind, bg, fg, bgTone, tuning);
const bgGamma = toDisplayGamma(
okhslToLinearSrgb(bg.h, bg.s, bg.l, bg.pastel ?? pastel),
);
const pigmentGamma = toDisplayGamma(
okhslToLinearSrgb(pigment.h, pigment.s, pigment.l, pastel),
);
const toneAt = (alpha: number): number =>
toneFromY(
relativeLuminanceFromLinearRgb(
compositeOverGamma(bgGamma, pigmentGamma, alpha),
),
);
const surfaceTone = toneAt(0);
const d = kind === 'shadow' ? 1 : -1;
const gain = (alpha: number): number => d * (surfaceTone - toneAt(alpha));
const { alpha, reached } = solveOpacity(kind, gain, step);
const result: LightingResult = { ...pigment, alpha, step, reached };
const headroom = tuning.headroom ?? 0;
if (kind === 'glow' && headroom > 0 && step > 0) {
const base = pinnedBase(alpha, surfaceTone, pigment, bg, pastel);
const hdr =
base === undefined
? undefined
: exposeGlow(base, surfaceTone, step, headroom);
if (hdr !== undefined) result.hdr = hdr;
}
return result;
}
/**
* The SDR opacity solve: the smallest opacity whose composite reaches `step`,
* or — out of reach — the pigment's strongest composite.
*/
function solveOpacity(
kind: LightingKind,
gain: (alpha: number) => number,
step: number,
): { alpha: number; reached: number } {
if (step <= 0) return { alpha: 0, reached: 0 };
// The predicate is monotone on [0, hi] once `gain(hi)` reaches the step.
let hi = 1;
if (gain(1) < step) {
hi = kind === 'shadow' ? strongestShadowAlpha(gain) : 1;
const reached = gain(hi);
if (reached < step) {
// A pigment that cannot move the surface at all — or only by float
// noise, as a neutral one at the surface's own tone — stays transparent.
return reached > NO_LIFT_EPS
? { alpha: hi, reached }
: { alpha: 0, reached: 0 };
}
}
let lo = 0;
for (let i = 0; i < BISECT_ITERATIONS; i++) {
const mid = (lo + hi) / 2;
if (gain(mid) < step) lo = mid;
else hi = mid;
}
return { alpha: hi, reached: gain(hi) };
}
/** An OKHSL color in the standard (non-pastel) space the HDR base uses. */
function standardOkhsl(
c: { h: number; s: number; l: number },
pastel: boolean,
): { h: number; s: number; l: number } {
if (!pastel) return { h: c.h, s: c.s, l: c.l };
const [h, s, l] = oklabToOkhsl(okhslToOklab(c.h, c.s, c.l, true));
return { h, s, l };
}
/**
* What SDR paints once it is pinned at its brightest, as standard OKHSL — the
* only color an HDR glow exposes. Past white SDR cannot land the step, so the
* solved opacity is 0 or 1 there: at 1 that is the pigment itself; at 0 it is
* the surface, which counts only when it is white (no pigment can lift white,
* and SDR shows white). A pigment that cannot lift a non-white surface at all
* — `reach: 0` on a gray surface, say, or a `tint` that only darkens — paints
* nothing in SDR, so there is nothing to expose. (On a chromatic surface a
* `reach: 0` pigment, a gray at the surface's tone, can measure slightly
* brighter; it then pins at 1 like any pigment.) An exposure of the bare
* surface would be an opaque, brighter copy of it where SDR leaves the surface
* showing, and a neutral one clips to opaque white wherever HDR is clamped.
* `undefined` then (and for any opacity between, which only float noise at the
* trigger reaches).
*/
function pinnedBase(
alpha: number,
surfaceTone: number,
pigment: { h: number; s: number; l: number },
bg: LightingColor,
pastel: boolean,
): { h: number; s: number; l: number } | undefined {
if (alpha === 1) return standardOkhsl(pigment, pastel);
if (alpha === 0 && surfaceTone >= 100 - WHITE_SURFACE_EPS) {
return standardOkhsl(bg, bg.pastel ?? pastel);
}
return undefined;
}
/**
* HDR value of a glow past white: an opaque exposure of `base` — what SDR
* paints at its brightest (`pinnedBase`), as standard OKHSL — by the gain that
* lands the target tone `surfaceTone + step`, capped so no channel passes
* `2^headroom` (displays clip per channel; for a neutral base that is a cap on
* luminance). `undefined` when the target stays at or below 100 — the trigger
* depends on the target alone — or when the base is black and has nothing to
* expose.
*
* Tone runs past 100 unchanged (`toneFromY` / `yFromTone` are unclamped), so
* equal tone steps above white are still equal `Y + 0.05` ratios. The gain is
* measured on the base as stored, so base × `2^exposure` lands the step to
* float precision. It lands the whole step, not just the part past white: a
* base at white (full `reach`) meets SDR with `exposure` → 0 as the target
* goes to 100⁺, while a base below white (`reach` under 1) starts at
* `log2(Y(100) / Y(base))` — just under white SDR stays short at the pigment
* and warns; just past it HDR lands the exact step.
*/
function exposeGlow(
base: { h: number; s: number; l: number },
surfaceTone: number,
step: number,
headroom: number,
): LightingHdr | undefined {
const target = surfaceTone + step;
if (!(target > 100 + HDR_TRIGGER_EPS)) return undefined;
const linear = okhslToLinearSrgb(base.h, base.s, base.l);
const yBase = relativeLuminanceFromLinearRgb(linear);
const peak = Math.max(linear[0], linear[1], linear[2]);
if (!(yBase > 0) || !(peak > 0)) return undefined;
const m = Math.min(yFromTone(target) / yBase, 2 ** headroom / peak);
if (!(m > 1)) return undefined;
return {
...base,
exposure: Math.log2(m),
reached: toneFromY(yBase * m) - surfaceTone,
};
}
/** The solver's HDR value (OKHSL base) as a `ResolvedHdr` (OKHST base). */
export function toResolvedHdr(hdr: LightingHdr): ResolvedHdr {
const { h, s, t } = okhslToOkhst(hdr);
return { h, s, t, exposure: hdr.exposure };
}
/**
* Linear sRGB of a resolved HDR value: the base's channels times
* `2^exposure`, unclamped (the brightest channel can reach `2^headroom`).
* The base is non-pastel OKHSL, so no `pastel` flag applies.
*/
export function hdrToLinearSrgb(hdr: ResolvedHdr): [number, number, number] {
const { h, s, l } = okhstToOkhsl(hdr);
const [r, g, b] = okhslToLinearSrgb(h, s, l);
const m = 2 ** hdr.exposure;
return [r * m, g * m, b * m];
}
/**
* Extended sRGB of a resolved HDR value: `hdrToLinearSrgb` through the sRGB
* transfer function, continued past 1 the way CSS and the platform
* extended-sRGB spaces continue it, unclamped. These are the channels of
* `color(srgb R G B)` (`glaze.formatHdr()`), and what a WebGPU `'srgb'`
* canvas, `CGColorSpace.extendedSRGB`, and Android's `EXTENDED_SRGB` take.
*/
export function hdrToSrgb(hdr: ResolvedHdr): [number, number, number] {
const [r, g, b] = hdrToLinearSrgb(hdr);
return [sRGBLinearToGamma(r), sRGBLinearToGamma(g), sRGBLinearToGamma(b)];
}
/**
* Opacity of a shadow pigment's darkest composite. Full opacity for pure
* black; a tinted pigment whose channels pull in different directions can
* bottom out earlier, so a golden-section search over the unimodal gain finds
* it and the better of that and full opacity wins. (A glow needs no search:
* convex luminance peaks at an endpoint, and full opacity is the one that
* lightens.)
*/
function strongestShadowAlpha(gain: (alpha: number) => number): number {
let a = 0;
let b = 1;
for (let i = 0; i < BISECT_ITERATIONS; i++) {
const c = b - GOLDEN * (b - a);
const e = a + GOLDEN * (b - a);
if (gain(c) < gain(e)) a = c;
else b = e;
}
const peak = (a + b) / 2;
return gain(peak) > gain(1) ? peak : 1;
}