Files
DarkRoom/core/dr-pipeline/ops
dtourolleandClaude Opus 5 23f0c4b76a Let a declared node ask for a widget, and write down how
Two gaps in the control vocabulary, both found by reading back what 0a331c7
actually shipped against what it claimed.

**`kind: enum` worked and was undocumented.** The whole point of that kind is
that a node author can declare a control without touching the interface, and
ops/README.md is where a node author looks. A kind absent from the table is one
nobody will use. It is now in the table, with the property that makes it cheap
spelled out: the value is the chosen index carried as an `f32` like every other
parameter, so a uniform expression can read it, the sidecar stores it
unchanged, and nothing along the way needed a second kind of value.

**A declared node could not ask for a widget at all.** `Presentation` gained an
ordered preference list and a demand, and `tone_curve` and `framing` use them —
but both are hand-written Rust. A YAML node had no way to say that several of
its parameters form one control, so the generated half of the pipeline was
locked out of the half of FR-DEV-3a that makes widgets extensible. Nodes now
take a `presentation:` block:

    presentation:
      widgets: [colour_wheel]
      demand: { two_dimensional: true }
      params: [hue, strength]

`demand` carries exactly two flags and there is deliberately no key for a pixel
width, a breakpoint or a platform name — those are the frontend's to decide,
and ARCH §4.3a is explicit that a core reasoning about them will eventually be
wrong about a display it never saw.

Widget names are spelled in the YAML the way `WidgetKind` spells them, so a
declaration and descriptor.rs cannot drift into two vocabularies for one idea,
and an unknown one is a build error listing the six that exist. `params:` is
checked against the node's own parameters: the widget claims what it names and
the generic path skips what was claimed, so a typo would silently drop a
parameter out of *both* — the one mistake here that produces no error and no
control.

Verified by declaring a presentation on a node, confirming the generated
`fn presentation` matched, and confirming both error paths report the file and
the key; then reverted, since no node in the chain wants a widget yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 23:54:10 +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;

presentation:                # optional; omit for one control per parameter
  widgets: [tone_curve]      # a hint, most preferred first
  params: [exposure]         # what that widget owns

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 —
enum one of a short, fixed list variants
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.

An enum names its choices as localisation keys, most-neutral first:

params:
  method:
    label: param.method
    kind: enum
    variants: [param.method.fast, param.method.exact]

The value is the chosen index, carried as an f32 like every other parameter — so a uniform expression can use it (method is 0 or 1), the sidecar stores it unchanged, and nothing along the way needed a second kind of value. The default is always the first variant, so reset means what it means everywhere else; if your neutral choice is not first, reorder the list rather than reaching for a default:.

Two variants is the minimum. A one-entry list is a control the user cannot change, and the build rejects it.

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.

Presentation

Optional, and absent from nearly every node: one control per parameter is the right answer for a list of unrelated sliders, which is what most operations are. Declare it when several parameters form one conceptual control.

presentation:
  widgets: [colour_wheel]        # most preferred first
  demand:
    two_dimensional: true        # dragged in x and y at once
    precise_pointing: false      # needs accuracy finer than a fingertip
  params: [hue, strength]        # what the widget owns, in its order

Widgets: tone_curve, colour_wheel, crop_overlay, gradient_handle, brush_mask, white_point.

It is a hint and only a hint. The frontend walks widgets and takes the first it both implements and can afford; if it implements none of them, or cannot meet the demand, it renders the parameters as ordinary sliders and the edit still works. So it is safe to name a widget nothing draws yet — the node degrades to sliders rather than breaking. That fallback is also why every parameter must stay an ordinary scalar: it is what there is to fall back to.

demand says what the widget inherently needs, never what the screen has. There is deliberately no way to write a pixel width, a breakpoint or a platform name here — those are the frontend's to decide, and a node that reasoned about them would eventually be wrong about a display it never saw (ARCH §4.3a).

Parameters named in params: are claimed by the widget and not drawn separately, so a typo would take a parameter out of both. The build checks each name against the node's own params: and fails if it does not match.

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/.