Files
DarkRoom/core/dr-pipeline/src/lib.rs
T
dtourolleandClaude Opus 5 9b2ee0d0eb
Build and test / Desktop (Linux) (push) Successful in 17m20s
Build and test / Layer separation (push) Successful in 33s
🐳 Android image / Build and push (push) Successful in 2s
Build and test / android-image (push) Successful in 2s
Traceability / Requirement traces (push) Failing after 26s
Build and test / Android (aarch64) (push) Failing after 8m59s
Show the colour mixer as three runs of twelve, each row a colour
The mixer was thirty-six sliders reading "Hue / Sat / Lum" twelve times
over with nothing saying which band any row belonged to. The identity was
there all along — the descriptor declares param.mixer.orange.sat and BANDS
carries orange at 30° — and was discarded on the way out: labels.rs had no
mixer entries, so every key fell through to a derived label that yields the
bare channel name.

A parameter can now say which aspect it adjusts and which subject it
adjusts it on, with the subject's hue where the subject is a colour
(descriptor::Facet). That is data about what the operation does, not a
layout: the mixer genuinely weights pixels around 30°. What to draw from
30°, and in what order to stack the runs, stay in dr-ui (ARCH §4.3a) —
develop.rs brings rows sharing an aspect together and marks the first of
each, and adjust.slint names the run once and draws a swatch, a track and a
readout on one line.

Grouped by channel rather than by band because an edit is almost never
"everything about orange"; it is the saturation of the greens, made by
comparing one channel across neighbouring bands. Twelve band sections put
those twelve rows in twelve different places.

The swatch is the label, which is what makes twelve rows fit where four
did. The band name is not lost: it is the row's accessible label, so the
control is not colour-only, and labels.rs is where the mapping is written
down — including chartreuse as "Yellow-Green" and spring as "Blue-Green",
since nobody hunting foliage scans a list for "Spring".

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

294 lines
10 KiB
Rust

//! The develop pipeline — operations, descriptors, and shader composition.
//!
//! # What this crate is
//!
//! An operation here is three things: a **descriptor** saying what its
//! parameters are, a **WGSL fragment** saying what it does to a colour, and
//! the **state** of its current settings. It is not a shader, not a pipeline,
//! and not a control — those belong to `dr-gpu` and `dr-ui` respectively.
//!
//! That split is what lets this crate have no GPU dependency at all: the
//! generated WGSL is a string, and everything about it can be tested without
//! a device (ARCH §6.5a).
//!
//! # Composable shaders
//!
//! The interesting property. Operations are separate in Rust but *fused* on
//! the GPU: [`operation::compose`] concatenates the enabled operations'
//! fragments into one compute shader, so an edit with three active
//! adjustments runs as one dispatch with one texture read and one write.
//!
//! An operation at neutral settings contributes nothing — no code, no
//! uniform, no branch. The shader for a given op-set compiles once and is
//! cached by [`operation::ComposedShader::structure_hash`], which covers the
//! operations and their order but not their values; moving a slider uploads
//! uniforms and reuses the pipeline.
//!
//! # Where this sits
//!
//! Input is the demosaiced texture from `dr-gpu`: linear, scene-referred,
//! camera colour space. Working in linear light is what makes exposure a
//! single multiply and white balance a per-channel scale; on gamma-encoded
//! data neither would be physically meaningful (ARCH §5.2).
pub mod descriptor;
pub mod framing;
pub mod graph;
pub mod lens;
pub mod operation;
pub mod ops;
pub mod sidecar;
pub use descriptor::{
Facet, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, ParamKind, Presentation,
Scale, Unit, WidgetKind,
};
pub use framing::{CropRect, Framing};
pub use graph::{EditGraph, OpCapability, ParamCapability};
pub use lens::{compose_warps, ComposedWarp, Warp};
pub use operation::{
compose, compose_with_framing, Affects, ComposedShader, Helper, Operation, Uniform,
RESERVED_UNIFORM_FIELDS,
};
pub use sidecar::{Sidecar, Version};
#[cfg(test)]
mod tests {
use super::*;
/// Every operation in the default chain, activated.
///
/// Parameters are moved away from their defaults by differing amounts,
/// scaled by position. A uniform nudge is not enough: the tone curve's
/// neutral is a *relationship* between its parameters rather than a set
/// of values, so shifting every point by the same amount slides it along
/// the identity diagonal and leaves the operation correctly inactive.
/// Varying the step breaks that symmetry, as any real edit would.
fn fully_active() -> EditGraph {
let mut g = EditGraph::default_chain();
for desc in g.descriptors() {
for (i, p) in desc.params.iter().enumerate() {
let v = match p.kind {
ParamKind::Scalar { min, max, .. } => {
// A fraction that differs per parameter, so no two
// move in lockstep.
let fraction = 0.15 + 0.05 * (i % 4) as f32;
let step = (max - min) * fraction;
if p.default + step <= max {
p.default + step
} else {
p.default - step
}
}
ParamKind::Bool => 1.0 - p.default,
};
g.set_param(desc.id, p.id, v);
}
}
g
}
#[test]
fn every_operation_can_be_activated_together() {
let g = fully_active();
assert!(!g.is_neutral());
let shader = g.compose();
// Counted against the chain rather than a literal, so adding an
// operation does not require editing this test.
assert_eq!(
shader.source.matches("---- ").count(),
g.descriptors().len(),
"every operation in the chain should appear"
);
}
#[test]
fn the_full_chain_generates_a_well_formed_uniform_block() {
let shader = fully_active().compose();
assert_eq!(
shader.uniforms.len() % 4,
0,
"the uniform block must be 16-byte aligned"
);
assert!(shader.uniforms.iter().all(|v| v.is_finite()));
}
#[test]
fn no_operation_declares_a_duplicate_id() {
// Two operations sharing an id would collide in the generated
// uniform struct and produce a shader that does not compile.
let g = EditGraph::default_chain();
let mut ids: Vec<&str> = g.descriptors().iter().map(|d| d.id.0).collect();
let before = ids.len();
ids.sort_unstable();
ids.dedup();
assert_eq!(before, ids.len(), "operation ids must be unique");
}
#[test]
fn no_operation_declares_a_duplicate_parameter() {
for desc in EditGraph::default_chain().descriptors() {
let mut ids: Vec<&str> = desc.params.iter().map(|p| p.id.0).collect();
let before = ids.len();
ids.sort_unstable();
ids.dedup();
assert_eq!(before, ids.len(), "{} has a duplicate parameter", desc.id);
}
}
#[test]
fn every_default_is_within_its_declared_range() {
// A default outside its own range would mean a fresh image opens
// with a value the UI cannot represent.
for desc in EditGraph::default_chain().descriptors() {
for p in desc.params {
assert_eq!(
p.clamp(p.default),
p.default,
"{}.{} default {} is outside its range",
desc.id,
p.id,
p.default
);
}
}
}
#[test]
fn defaults_leave_every_operation_inactive() {
// The invariant behind "opening an image shows the image": every
// operation must read its own default as neutral.
let g = EditGraph::default_chain();
for desc in g.descriptors() {
for p in desc.params {
assert_eq!(
g.param(desc.id, p.id),
Some(p.default),
"{}.{} does not start at its default",
desc.id,
p.id
);
}
}
assert!(g.is_neutral());
}
#[test]
fn generated_uniform_names_are_valid_wgsl_identifiers() {
let shader = fully_active().compose();
// Only the `struct Params` block. Scanning the whole source picks up
// helper *signatures* such as `fn contrast_curve(x: f32, ...)`, whose
// parameters are not uniform declarations at all.
let body = shader
.source
.split_once("struct Params {")
.expect("a uniform struct is always generated")
.1
.split_once('}')
.expect("the struct is closed")
.0;
let mut checked = 0;
for line in body.lines() {
let trimmed = line.trim();
if trimmed.starts_with("//") {
continue;
}
let Some((name, _)) = trimmed.split_once(':') else {
continue;
};
let name = name.trim();
assert!(
!name.is_empty()
&& name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
&& !name.starts_with(|c: char| c.is_ascii_digit()),
"{name} is not a valid WGSL identifier"
);
checked += 1;
}
assert!(checked > 0, "the struct should declare fields");
}
#[test]
fn no_fragment_declares_a_wgsl_reserved_keyword() {
// Caught the hard way: `let target = ...` in the contrast fragment
// failed to compile with "name `target` is a reserved keyword", and
// the error pointed at generated source rather than at the operation
// that wrote it. Checking here names the culprit directly.
//
// Not the full reserved list — the ones a colour operation would
// plausibly reach for.
const RESERVED: &[&str] = &[
"target",
"sample",
"filter",
"texture",
"buffer",
"binding",
"const",
"enum",
"mat",
"vec",
"ptr",
"ref",
"shared",
"static",
"typedef",
"union",
"unless",
"handle",
"layout",
"packed",
"premerge",
"regardless",
"typedef",
"active",
"do",
"enum",
"input",
"output",
"private",
"resource",
"restrict",
"self",
"std",
"where",
];
let g = EditGraph::default_chain();
for cap in g.capabilities() {
// Activate the whole operation so its fragment is emitted.
let mut probe = EditGraph::default_chain();
for p in &cap.params {
if let ParamKind::Scalar { max, .. } = p.kind {
probe.set_param(cap.id, p.id, max * 0.5);
}
}
let source = probe.compose().source;
for keyword in RESERVED {
let declaration = format!("let {keyword} ");
let var_declaration = format!("var {keyword} ");
assert!(
!source.contains(&declaration) && !source.contains(&var_declaration),
"{} declares `{keyword}`, which is a WGSL reserved keyword",
cap.id
);
}
}
}
#[test]
fn a_full_chain_declares_each_helper_once() {
// Six of the seven operations want `luminance`. A duplicate function
// definition fails to compile, so this is the property that keeps
// helper sharing safe as operations are added.
let source = fully_active().compose().source;
for helper in ["luminance", "tone_position", "colour_saturation"] {
let count = source.matches(&format!("fn {helper}(")).count();
assert!(count <= 1, "{helper} declared {count} times");
}
}
}