Files
DarkRoom/core/dr-pipeline/ops
dtourolleandClaude Opus 5 7c57f490fe Declare a develop operation in YAML, and generate the rest
An operation was, in the overwhelming majority of cases, four facts: what
its parameters are, what uniforms they compute, what WGSL those uniforms
drive, and where it sits in the chain. Written in Rust those four facts
arrived wrapped in ninety lines of trait implementation — a match on
parameter id to a struct field, another match back, an is_active comparing
each field to its default, a Vec<Uniform> built by hand. All mechanical,
and each one a place to make a silent mistake: a param() arm returning the
wrong field reads perfectly and breaks the sidecar round-trip.

So the four facts are the file now. core/dr-pipeline/ops/<id>.yaml is a
node, build.rs compiles it into the same Operation impl as before, and the
result lands in OUT_DIR — the same reasoning as style.yaml -> theme.slint,
including why it does not land beside the sources it would look exactly
like. Nothing downstream can tell a declared node from a hand-written one:
same &'static OpDescriptor, same fused-shader composition, same sidecar.

Nine nodes moved: exposure, white_balance, contrast, highlights_shadows,
blacks_whites, brilliance, vibrance, saturation, and the shared WGSL
helper registry. Their prose came with them, and so did their tests —
set/expect/expect_active/expect_wgsl in the declaration compile to real
#[test]s, so a node file carries its own proof rather than leaving it
behind in a file that no longer exists.

Two stayed in Rust and say so with `rust:`. The tone curve's neutral is a
relationship between five interpolated points rather than a set of values;
the colour mixer generates thirty-six faceted parameters from twelve
computed hue bands. A schema stretched to cover either would be a worse
language than Rust aimed at one caller. They still declare their position
here, because the chain's *order* is the one thing a reader comes to this
directory to learn, and an order written half in YAML and half in Rust
would be worse than either alone. default_chain() is generated from it.

Uniforms are derived by a small expression language — exp2(exposure),
blacks / 100 * 0.02 — compiled to Rust rather than interpreted, so an
unknown name or a wrong arity is a build error naming the file and the key
and the arithmetic costs nothing at runtime. The build script refuses a
duplicate order, a filename disagreeing with its id, a default outside its
own range, a test value the graph would clamp before the node saw it, a
helper that does not define the function it names, and a declared node
colliding with a file in src/ops.

Verified by adding a scratch node and removing it again: one file, no
other edit, and it joined the chain at its declared order with its test
running. 237 tests pass in dr-pipeline, clippy and fmt clean.

.yaml joins the traceability tool's scanned suffixes, because a node's
Rust now lives in OUT_DIR where a tag could never be linked from the
report. Coverage 47.7% -> 48.3%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 21:08:16 +02:00
..

Develop nodes

One file per operation. Adding a node to the pipeline is adding a file to this directory — there is no list to extend, no shader to edit, and no UI change.

../build.rs compiles each declaration into Rust implementing Operation, generated into OUT_DIR. The result is indistinguishable downstream from a hand-written operation: the same &'static OpDescriptor, the same fused-shader composition, the same sidecar round-trip.

What you get for free

A node dropped in here arrives with:

  • Controls. The develop panel builds them from the parameters' declared kinds (FR-DEV-3c). It never learns the node's name.
  • A place in the chain, from order:.
  • Persistence. Parameters are ordinary scalars, so the sidecar writes them with everything else.
  • Its tests, compiled from tests: and run with cargo test.
  • Neutral-means-absent. At its defaults the node contributes no code, no uniform, and no branch to the generated shader.

The shape of a file

id: exposure                 # must match the filename
label: op.exposure           # a localisation key, never a display string
order: 20                    # where it sits in the chain

doc: |                       # becomes the generated module's documentation
  Exposure — a linear gain, expressed in stops.
placement: |                 # why it sits where it does; appears above the
  A correction to capture.   # entry in the generated chain

params:
  exposure:
    label: param.exposure
    kind: stops
    min: -5
    max: 5
    doc: |                   # optional prose, kept with the descriptor
      ±5 stops.

uniforms:
  gain: exp2(exposure)       # shorthand, or a mapping with `value:` and `doc:`

helpers: [luminance]         # names from _helpers.yaml
define:                      # helpers this node alone needs
  my_curve: |
    fn my_curve(x: f32) -> f32 { return x; }

wgsl: |                      # `c` is linear RGB in and out
  c = c * gain;

tests:
  - name: one_stop_is_a_doubling
    why: The definition of a stop.
    set: { exposure: 1 }
    expect: { gain: 2.0 }

Parameter kinds

kind Shape Extra keys
amount −100…100, neutral at 0 — the familiar photographic control —
stops exposure-like, in stops min, max
fraction 0…1 default
switch a toggle, neutral off —
scalar anything else min, max, default, unit, scale, precision

Reach for amount first. Writing its range out by hand in each node is how one of them comes to disagree with the rest.

Uniform expressions

Arithmetic (+ - * /), parentheses, numbers, this node's parameters, and: exp2 log2 exp sqrt abs floor ceil round pow min max clamp mix.

Compiled to Rust, so an unknown name or a wrong arity is a build error naming the file and the key. Deliberately small: a node is a description, and letting it name arbitrary Rust would make this a second, worse place to write code.

Activity

By default a node is active exactly when some parameter has moved off its default, which is the honest rule and the right one for nearly everything. A node whose neutral is something else declares active: as an expression — non-zero means active.

Tests

Key Asserts
set parameter values to apply first; build fails if out of range
expect uniform values, within 1e-5
expect_range a uniform lies in [low, high]
expect_active whether the node reaches the shader
expect_wgsl substrings the fragment must contain
expect_helper_wgsl substrings a named helper must contain

why: becomes a comment in the generated test. Use it — a test named for a property should say what breaks without it.

Hand-written nodes

Some operations are not four facts, and forcing them into this schema would produce a worse language aimed at one caller. Those stay in ../src/ops/ and declare only their position here:

id: tone_curve
order: 60
rust: ToneCurve
why_rust: |
  Its neutral is a relationship between five points, not a set of values.

They still belong in this directory, because the pipeline's order is the one thing a reader comes here to learn, and an order written half in YAML and half in Rust would be worse than either alone.

Currently hand-written: tone_curve (a curve widget over five interpolated points), colour_mixer (thirty-six faceted parameters from twelve computed hue bands). vignetting is hand-written too but is not in the develop chain — it carries lens-profile coefficients that are not parameters. distortion and aberration are Warps rather than operations: they rewrite coordinates before sampling rather than transforming a colour after it.

Errors

The build script reports failures by naming the key you got wrong, and exits rather than panicking, so the message is the message and not a backtrace. It refuses: a duplicate order:, a filename disagreeing with its id:, a default outside its own range, a test value the graph would clamp before the node saw it, a helper that does not define the function it names, an expression naming something that is not a parameter, and a declared node whose name collides with a file in ../src/ops/.