FR-DEV-3 has asked for "white balance (temperature/tint, and picker)" since it was written, and only the first half existed. `WidgetKind::WhitePoint` was in the vocabulary and `develop::supported` answered false for it, so the node degraded to two sliders — correct behaviour that had quietly become the only behaviour. Sampling a neutral is the first move of the global tonal pass and every colour judgement afterwards is measured against where the grey was put, so guessing at two sliders until a wall stops looking green is the wrong way round. The awkward part is that a picker genuinely needs to know how far a hundred units of temperature move red against blue, and that number is declared in the node's own file. So the inversion lives in `dr_pipeline::neutral` rather than in the interface: the canvas hands over a colour, the core finds the operation that asked to be driven by a pixel and bisects its declared response until the sample comes back grey. Nothing in `ui/` names white balance, and nothing holds a second copy of a response that would be wrong the first time somebody adjusted the range. A bisection rather than a closed-form inverse because only monotonicity is part of the bargain — the expression is free to become a table tomorrow. The result is rounded to the precision the control is drawn at, which is not cosmetic: unrounded, sampling something already neutral lands a ten-thousandth off zero, and the photograph comes back modified with an undo step for a correction of nothing. On the panel side this needed one distinction the generated path was missing. `is_on_canvas` was being read as "and so the panel draws nothing for it", which is right for a crop — four edge fractions are not controls anyone drags in a list — and wrong for an eyedropper, which *writes* temperature and tint and leaves them exactly the controls a photographer reaches for next. So a sampling widget keeps its sliders and puts the affordance that arms the canvas in the group's heading, built like the reset beside it. One click, one sample, one history step: `Edit::Action` never coalesces, and there is no hover preview to fill the stack with temperatures nobody chose. Declaring the presentation also groups temperature and tint under one undo step, where they were two. That follows from what `Presentation` means and reads correctly — white balance is one decision — but it is a change, and worth saying so.
377 lines
14 KiB
Rust
377 lines
14 KiB
Rust
//! TRACES: R3
|
|
//! 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 coverage;
|
|
pub mod declared;
|
|
pub mod descriptor;
|
|
pub mod detail;
|
|
pub mod framing;
|
|
pub mod graph;
|
|
pub mod history;
|
|
pub mod lens;
|
|
pub mod mask;
|
|
pub mod neutral;
|
|
pub mod operation;
|
|
pub mod ops;
|
|
pub mod preset;
|
|
pub mod sidecar;
|
|
pub mod spot;
|
|
pub mod starter;
|
|
pub mod state;
|
|
|
|
pub use coverage::Coverage;
|
|
pub use declared::{Declaration, DeclaredOp};
|
|
pub use descriptor::{
|
|
Attribute, Facet, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, ParamKind,
|
|
Presentation, Scale, Unit, WidgetDemand, WidgetKind,
|
|
};
|
|
pub use detail::{
|
|
compose_detail, ComposedDetail, ComposedDetailPass, DetailPass, DetailStage, RenderScale,
|
|
};
|
|
pub use framing::{CropRect, Framing};
|
|
pub use graph::{EditGraph, OpCapability, ParamCapability};
|
|
pub use history::{Edit, Entry as HistoryEntry, History, Step};
|
|
pub use lens::{compose_warps, ComposedWarp, LensProfile, Tca, Warp};
|
|
pub use operation::{
|
|
compose, compose_with_framing, Affects, ComposedShader, Helper, Invalidation, Operation,
|
|
OutputMode, Uniform, BASE_CURVE_POINTS, BASE_CURVE_UNIFORM_OFFSET, RESERVED_UNIFORM_FIELDS,
|
|
};
|
|
pub use preset::{LibraryParseError, NameError, Preset, PresetLibrary, Scope};
|
|
pub use sidecar::{Sidecar, Version};
|
|
pub use spot::{Spot, SpotMode, SpotSet};
|
|
pub use state::{EditState, FilmRebake, FilmRef};
|
|
|
|
#[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,
|
|
// The last variant, so the choice differs from the
|
|
// default whatever the list holds. A single-variant enum
|
|
// cannot be moved off its default and is correctly left
|
|
// where it is.
|
|
ParamKind::Enum { variants } => variants.len().saturating_sub(1) as f32,
|
|
};
|
|
g.set_param(desc.id, p.id, v);
|
|
}
|
|
}
|
|
|
|
// `film_sim` is the one node a moved parameter cannot activate: it
|
|
// needs a stock's measured tables, which are not parameters and which
|
|
// no slider produces. So it is loaded explicitly here.
|
|
//
|
|
// This is the single per-node step in an otherwise generic helper, and
|
|
// it is deliberate rather than an oversight: a node that carries
|
|
// measurements is a real second kind of node, and pretending otherwise
|
|
// would mean silently leaving it out of every test that uses this.
|
|
g.set_film(Some(crate::graph::Film {
|
|
stock: "test_stock".into(),
|
|
print: None,
|
|
tables: crate::ops::FilmTables {
|
|
exposure_matrix: [[5.0, 0.5, 0.2], [0.1, 5.0, 0.3], [0.2, 0.5, 4.0]],
|
|
curves: vec![[0.5, 0.5, 0.5]; crate::ops::film_sim::CURVE_SAMPLES],
|
|
curve_log_min: -3.0,
|
|
curve_log_max: 4.0,
|
|
lut: vec![[0.5, 0.5, 0.5]; 32 * 32 * 32],
|
|
density_max: 3.0,
|
|
lut_size: 32,
|
|
grain_particles: [0.0; 3],
|
|
grain_density_max: [3.0; 3],
|
|
grain_uniformity: 0.97,
|
|
},
|
|
}));
|
|
g
|
|
}
|
|
|
|
#[test]
|
|
fn every_operation_can_be_activated_together() {
|
|
let g = fully_active();
|
|
assert!(!g.is_neutral());
|
|
let shader = g.compose();
|
|
|
|
// The chain has two kinds of operation in it and they arrive in
|
|
// different places: a point operation is a block in the fused shader,
|
|
// while a neighbourhood operation is a pass of the detail chain and
|
|
// contributes no fused block at all — it reads pixels it is not
|
|
// writing, and a fused fragment is handed a colour with no coordinate.
|
|
//
|
|
// So the assertion is that each operation reaches exactly one of the
|
|
// two, checked against the chain rather than a literal, and phrased so
|
|
// that adding either kind extends it without an edit here. The XOR is
|
|
// the point: a plain count of fused blocks cannot tell "moved to the
|
|
// detail stage" from "vanished from both", and this test has now been
|
|
// broken three times by exactly that ambiguity.
|
|
//
|
|
// Composed at 1:1 deliberately. An acutance operation's radius is in
|
|
// source pixels, so on a proxy it may honestly decline to draw at all
|
|
// (`RenderScale::resolves`) — which would put it in neither half and
|
|
// make the assertion fail for a reason that is not a defect.
|
|
let detail = g.compose_detail((4000, 3000), (4000, 3000));
|
|
let mut fused_blocks = 0;
|
|
for desc in g.descriptors() {
|
|
let id = desc.id.0;
|
|
let point = shader.source.contains(&format!("---- {id} ----"));
|
|
let neighbourhood = detail
|
|
.passes
|
|
.iter()
|
|
.any(|p| p.label.starts_with(&format!("{id}/")));
|
|
assert!(
|
|
point ^ neighbourhood,
|
|
"{id} reaches {} of the two stages; an active operation \
|
|
belongs to exactly one",
|
|
if point { "both" } else { "neither" }
|
|
);
|
|
fused_blocks += usize::from(point);
|
|
}
|
|
assert_eq!(
|
|
shader.source.matches("---- ").count(),
|
|
fused_blocks,
|
|
"the fused shader carries a block nothing in the chain asked for"
|
|
);
|
|
}
|
|
|
|
#[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");
|
|
}
|
|
}
|
|
}
|