id: saturation label: op.saturation order: 90 attributes: [colour] doc: | Saturation — every colour's distance from grey, scaled equally. The blunt instrument beside [`vibrance`](vibrance.yaml). Both are offered because they fail differently: this one is predictable and even, which is what a landscape wants, and ruinous on faces, which is what vibrance is for. placement: | Last of the per-colour controls, so it has the final word if both it and vibrance are in play. params: saturation: label: param.saturation kind: amount uniforms: factor: value: max(1 + saturation / 100, 0) doc: | -100 reaches exactly monochrome; +100 doubles the distance from grey. The floor at zero matters: a negative factor would push a colour past grey into its complement, inverting hues. helpers: [luminance, tone_position, apply_tone_gain] wgsl: | // Interpolate away from the luminance-preserving grey. A factor of 0 is // monochrome, 1 is unchanged, above 1 is more saturated. let luma = luminance(c); c = mix(vec3(luma), c, factor); c = max(c, vec3(0.0)); tests: - name: it_starts_unchanged expect: { factor: 1.0 } expect_active: false - name: full_negative_saturation_reaches_monochrome why: | The property that makes -100 meaningful: it must land exactly on grey, not merely near it. set: { saturation: -100 } expect: { factor: 0.0 } - name: positive_saturation_increases_the_factor set: { saturation: 100 } expect: { factor: 2.0 } - name: the_factor_never_goes_negative why: | A negative factor pushes a colour past grey into its complement, which inverts hues rather than desaturating them. The clamp is what makes the bottom of the slider's travel monochrome instead of a solarised image. set: { saturation: -100 } expect_range: { factor: [0.0, 2.0] }