# 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`](../src/operation.rs), 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. ## `attributes:` — what the operation is about Required, one or more of `tone`, `colour`, `detail`, `optics`, `geometry`, `effect`. The build fails without it, and that is deliberate: an operation with no attribute is invisible to an interface that groups by them, and a control that silently does not exist is a worse failure than a build that stops. **Say what the operation is, not where you want it drawn.** The core declares capabilities; the frontend composes (ARCH §4.3a). `tone` means "this is about lightness", not "put this in the second tab" — the interface is free to render attributes as tabs, as headings, or not at all, and that choice may differ between a tablet and a desktop without this file changing. **Plural is normal.** The tone curve is `[tone, colour]` because an RGB curve is tonal and the per-channel curves are chromatic; it appears wherever the frontend decides that means. Reach for a second attribute when an operation genuinely answers two questions, not to make it easier to find. **The vocabulary is closed.** A typo is a build error rather than a new category containing exactly one operation — which looks identical to a deliberate new category until somebody notices the group with one control in it. ## 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:`. - **A place in the panel**, from `attributes:`. The interface groups by these and names no operation, so a node arrives in the right group by saying what it is rather than by anyone editing a list in `ui/`. - **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 ```yaml id: exposure # must match the filename label: op.exposure # a localisation key, never a display string order: 20 # where it sits in the chain attributes: [tone] # what it is about; one or more, and required 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: ```yaml 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. ```yaml 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/`](../src/ops/) and declare only their position here: ```yaml 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), `capture_sharpen` (a separable convolution, which is the other reason a node is Rust — see the next section). `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 `Warp`s rather than operations: they rewrite coordinates before sampling rather than transforming a colour after it. ## Nodes that read their neighbours `wgsl:` above is handed `c`, a colour, and no coordinate. That is what makes the fused dispatch possible and it is also a wall: sharpening, noise reduction, clarity, texture, dehaze and spot removal are all defined by what the *neighbouring* pixels are doing, and none of them can be written as a function of `c` at any price. They go in the **detail stage**, which runs after the fused pass, in linear light, at render resolution, before the output transform — see [`../src/detail.rs`](../src/detail.rs) for why each of those is a decision rather than a convenience. A node of this kind: - is declared here with `rust:`, like any other hand-written node, because a kernel is not four facts and stretching this schema to cover one would produce a worse language than Rust; - implements `Operation` as usual — descriptor, parameters, `is_active` — so the panel, the sidecar, the history and the presets all work unchanged; - returns `Affects::Detail` from `affects()` and `Some(self)` from `detail()`; - implements `DetailStage::passes`, returning one `DetailPass` per dispatch, each with a WGSL body, its uniforms, and **its kernel radius in render pixels**, which the tile scheduler needs and nothing can infer. `capture_sharpen` is the worked example: two passes, one per axis, and a radius converted from source pixels once per render. The `order:` still belongs here, and still orders the node — among the other detail nodes. Detail runs as a group after every point operation, so an `order:` that interleaves one with exposure would be a lie the chain cannot tell. The one thing to get right is the **unit of a radius**. Never store pixels: a length is either a fraction of the frame's shorter edge (`RenderScale:: frame_fraction` — clarity, texture, the unit a mask feather already uses) or a count of source pixels (`RenderScale::source_pixels` — capture sharpening, luminance NR). `passes()` is given the scale and converts on the CPU. A radius in raw pixels is a different photograph on screen and in the exported file. ## What is not a node, and why Three things act on every pixel and are deliberately not in this directory: the as-shot white balance, the camera matrix, and the **base curve** (FR-DEV-3e). They are emitted by [`../src/operation.rs`](../src/operation.rs) into the composed shader's fixed preamble, around the block of nodes. The test is not "does it transform a colour" — all three do. It is **whose decision is it**. A node is something a photographer chose: it has parameters, it moves off a neutral, it lands in the sidecar, it can be undone. These three are properties of the *file*, at the same standing as the masked-photosite crop (FR-RAW-3) and the stored orientation (FR-DEV-3h). Nobody chose the sensor's green sensitivity or the body's rendering; they are what reading the file correctly means. Making the base curve a node would have said the opposite in four places at once. It would have appeared in the develop panel as a control, so an unprofiled body would show a slider that does nothing. Its values would have gone into the sidecar, and sidecars are shared between devices and bodies (FR-NC-9) — one camera's rendering would follow an edit onto another camera's file. Its neutral would have had to be "the identity", so a profiled body would open reporting itself modified. And there is no seam through which a node could learn which camera took the frame: the profile arrives on the decoded image, travels through `DemosaicedImage` beside the matrix it belongs with, and is written into the uniform block by the same three lines in `dr-gpu` — which is exactly the path the matrix already took, because it is exactly the same kind of thing. What it *does* share with the tone curve node is the spline. The composer asks `ToneCurve` for its `curve_span`/`curve_eval` helpers rather than emitting a second copy, so a profile author placing a control point and a photographer dragging one mean the same thing by it. The order still reads correctly from this directory: the base curve runs after every node in the chain and before the conversion out of camera space. That is the same reasoning `exposure` records under `placement:` — corrections to capture are only meaningful on linear values, so the rendering goes last. ## 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/`.