//! 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 bundled; 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 orphan; pub mod preset; pub mod sidecar; pub mod spot; 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, CLIP_ONSET, RESERVED_UNIFORM_FIELDS, SAMPLE_CACHE_UNIFORM_OFFSET, }; pub use preset::{LibraryParseError, NameError, Preset, PresetLibrary, Reach, 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"); } } }