Files
dtourolle 7421837c8a Let an operation say what it is about, so the panel can group without naming
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.
2026-08-22 10:10:00 +02:00

88 lines
3.0 KiB
YAML

id: blacks_whites
label: op.blacks_whites
order: 50
attributes: [tone]
doc: |
Blacks and whites — the endpoints, where the image clips.
Narrow where [`highlights_shadows`](highlights_shadows.yaml) is broad:
whites act only in the top quarter, blacks only in the bottom quarter. That
difference in reach is the whole distinction between the two pairs.
placement: |
After the broad recovery controls: the endpoints are placed once the tonal
range they bound has been settled.
params:
blacks:
label: param.blacks
kind: amount
whites:
label: param.whites
kind: amount
uniforms:
white_amount: whites / 100
black_amount:
value: blacks / 100 * 0.02
doc: |
A small linear offset. Scene-referred black sits near zero, so the
useful range here is far smaller than a stop — 0.02 is already a
visible lift on a dark frame.
helpers: [luminance, tone_position, apply_tone_gain]
wgsl: |
let luma = luminance(c);
let pos = tone_position(luma);
// Narrow weights concentrated at each end — this is what separates these
// controls from highlights/shadows, which are broad. Whites act only in the
// top quarter, blacks only in the bottom quarter.
let white_w = smoothstep(0.75, 1.0, pos);
let black_w = 1.0 - smoothstep(0.0, 0.25, pos);
// Whites scale the top end multiplicatively, moving the clipping point.
let white_gain = exp2(white_amount * white_w);
c = apply_tone_gain(c, white_gain);
// Blacks shift the floor. This one is deliberately *additive*: the point of
// a blacks control is to set where the image reaches zero, and a multiply
// can never bring a non-zero value to zero nor lift a true black off it.
c = c + vec3<f32>(black_amount * black_w);
// The subtractive direction can push below zero, which is not light.
c = max(c, vec3<f32>(0.0));
tests:
- name: it_starts_neutral
expect_active: false
- name: one_parameter_is_enough_to_activate
set: { whites: 20 }
expect_active: true
- name: the_blacks_offset_stays_small
why: |
Scene-referred black is near zero; a full-stop control here would be
unusable, moving the image to grey at a fraction of its travel.
set: { blacks: 100 }
expect_range: { black_amount: [0.0, 0.05] }
- name: blacks_are_additive_not_multiplicative
why: |
A multiply can never bring a non-zero value to zero nor lift a true
black off it, which is precisely what this control is for.
expect_wgsl: ["c = c + vec3<f32>(black_amount * black_w);"]
- name: the_result_cannot_go_below_zero
why: A negative radiance is not light, and it poisons everything downstream.
expect_wgsl: ["c = max(c, vec3<f32>(0.0));"]
- name: these_endpoints_are_narrower_than_the_recovery_controls
why: |
The one property distinguishing this node from highlights_shadows. If
these weights widened to match, the two would be the same control twice.
expect_wgsl: ["smoothstep(0.75, 1.0, pos)", "smoothstep(0.0, 0.25, pos)"]