Tool tabs need a taxonomy, and the taxonomy was the problem: a table in `ui/` mapping operation to tab breaks FR-DEV-3a, and a `group:` field risks what `ui-refinement.md` condemned `starts-group` for — the core deciding where the panel draws things. `Attribute` threads the needle. It says what an operation *is* — tone, colour, detail, optics, geometry, effect — which is the same category as `ParamKind` and squarely on the core's side of ARCH §4.3a's line. What is drawn, where it sits and whether it is visible stay the frontend's. There is no attribute for "the third tab", the enum's order is declaration order rather than screen order, and a frontend may render these as tabs, as headings, or ignore them. The payoff is that a tab strip can be *derived*: the groups are the attributes present in the capability list, so the interface names no operation and needs no table to keep in step. An operation joins the right group by declaring what it is, which is the one thing its author is well placed to say. Plural, because the tone curve is genuinely both — an RGB curve is tonal and the per-channel curves are chromatic, and filing it under one would hide it from half the people looking for it. Required and non-empty, enforced in `build.rs`, and the failure was checked by removing the line rather than assumed. An operation with no attribute is invisible to a panel that groups by them; a build that stops costs ten seconds, a control nobody can find costs more. The vocabulary is closed for the same reason: a typo would otherwise invent a category holding exactly one operation, which looks like a deliberate one until somebody counts. Six tests over the real chain, including the hand-written operations that `build.rs` never sees and so cannot check.
88 lines
3.1 KiB
YAML
88 lines
3.1 KiB
YAML
id: white_balance
|
|
label: op.white_balance
|
|
order: 10
|
|
attributes: [colour]
|
|
|
|
doc: |
|
|
White balance — temperature and tint, relative to as-shot.
|
|
|
|
Expressed as an offset from what the camera chose rather than an absolute
|
|
kelvin value. Neutral means "as shot", so the control starts where the
|
|
image already is and a reset returns there. An absolute scale would make
|
|
the neutral position depend on the file, which is exactly the confusion
|
|
Lightroom's temperature slider creates on non-raw files.
|
|
|
|
The as-shot multipliers themselves are applied by the composer's preamble
|
|
rather than here — every image has them even when this operation is
|
|
neutral, so they cannot live in a fragment that vanishes at neutral.
|
|
|
|
placement: |
|
|
First. It is a correction to how the scene was captured, and every tonal
|
|
operation after it should act on a correctly balanced image.
|
|
|
|
params:
|
|
temperature:
|
|
label: param.temperature
|
|
kind: amount
|
|
doc: |
|
|
Warmer is positive, matching every other raw developer: dragging right
|
|
makes the image warmer, even though that means *lowering* the colour
|
|
temperature being corrected for.
|
|
tint:
|
|
label: param.tint
|
|
kind: amount
|
|
|
|
# Temperature trades red against blue; tint trades green against magenta.
|
|
# Both are scaled so the full range is a strong but not destructive
|
|
# correction: ±0.5 in log2 at the extremes — half a stop of channel shift,
|
|
# which covers ordinary illuminant error without letting the slider blow a
|
|
# channel on its own.
|
|
uniforms:
|
|
mul_r: exp2(temperature / 100 * 0.5)
|
|
mul_g:
|
|
value: exp2(-(tint / 100 * 0.5))
|
|
doc: |
|
|
Green is held at unity by temperature, so the control does not double as
|
|
an exposure slider — green carries most of the luminance.
|
|
mul_b:
|
|
value: exp2(-(temperature / 100 * 0.5))
|
|
doc: Blue moves opposite red, so a neutral grey stays grey as the control moves.
|
|
|
|
wgsl: |
|
|
c = c * vec3<f32>(mul_r, mul_g, mul_b);
|
|
|
|
tests:
|
|
- name: neutral_is_as_shot
|
|
expect: { mul_r: 1.0, mul_g: 1.0, mul_b: 1.0 }
|
|
expect_active: false
|
|
|
|
- name: warming_raises_red_and_lowers_blue
|
|
set: { temperature: 100 }
|
|
expect: { mul_r: 1.4142135, mul_b: 0.70710677 }
|
|
|
|
- name: temperature_leaves_green_alone
|
|
why: |
|
|
Otherwise the control doubles as an exposure slider, because green
|
|
carries most of the luminance.
|
|
set: { temperature: 100 }
|
|
expect: { mul_g: 1.0 }
|
|
|
|
- name: tint_moves_green_against_magenta
|
|
why: Positive tint reduces green, and must not touch red or blue.
|
|
set: { tint: 100 }
|
|
expect: { mul_g: 0.70710677, mul_r: 1.0, mul_b: 1.0 }
|
|
|
|
- name: cooling_is_the_inverse_of_warming
|
|
why: |
|
|
Warming by n then cooling by n must return to neutral, so the two
|
|
directions have to be exact reciprocals rather than merely similar.
|
|
set: { temperature: -100 }
|
|
expect: { mul_r: 0.70710677, mul_b: 1.4142135 }
|
|
|
|
- name: the_extremes_stay_within_half_a_stop
|
|
why: |
|
|
A white balance control that can blow a channel by itself is a trap;
|
|
correction belongs in a range where highlights survive.
|
|
set: { temperature: 100, tint: 100 }
|
|
expect_range: { mul_r: [0.70, 1.42], mul_g: [0.70, 1.42], mul_b: [0.70, 1.42] }
|