Files
DarkRoom/core/dr-pipeline/src/graph.rs
T
dtourolle 050dcff5bb Add viewport zoom to the framing
A view rect that composes with the crop in the same normalised space:
nesting one rect in the other is a multiply, so the shader needs no second
rect and no extra uniform slot.

Zoom is explicitly not an edit. It is excluded from is_active, from the
structure hash, and from the sidecar, so a zoomed view exports exactly as an
unzoomed one does. is_neutral now asks the framing whether it *edits* rather
than whether it is active — otherwise merely zooming would mark a clean file
dirty.

Because the render target keeps its size while the sampled region shrinks,
zooming raises the resolution the pipeline works at rather than magnifying
already-rendered pixels, which is what makes 1:1 inspection show real detail.

Assisted-by: LLM
2026-08-09 20:55:57 +02:00

563 lines
21 KiB
Rust

//! The edit graph — an ordered set of operations (ARCH §3.4).
//!
//! CPU-side state, deliberately. The GPU device can be lost and rebuilt at any
//! moment on Android (ARCH §6.10), and recovery is only tractable because
//! everything needed to re-render lives here rather than in GPU memory.
//!
//! Order is data, not code: operations run in the sequence this holds them,
//! so reordering the pipeline needs no code change.
use crate::descriptor::{LocalizedKey, OpDescriptor, OpId, ParamId, ParamKind, Presentation};
use crate::framing::{CropRect, Framing};
use crate::operation::{compose_with_framing, ComposedShader, Operation};
use crate::ops;
/// TRACES: FR-DEV-3a
/// What one operation offers, as plain data.
///
/// Deliberately owned rather than borrowed, and free of any trait objects:
/// the UI receives a snapshot it can hold across a frame without borrowing
/// the graph, and nothing in it hints at how the operation is implemented.
#[derive(Debug, Clone, PartialEq)]
pub struct OpCapability {
pub id: OpId,
/// A key for the UI's own catalogue. Never a display string — resolving
/// it needs a localiser, which `core/` must not depend on.
pub label: LocalizedKey,
/// Whether this operation currently alters the image. A UI may use it to
/// mark a section as modified, or to offer a per-operation reset.
pub active: bool,
pub params: Vec<ParamCapability>,
/// A hint that several of `params` form one conceptual control.
///
/// `None` means one control per parameter. A UI that does not implement
/// the named widget may ignore this and render sliders — the parameters
/// are ordinary scalars either way, so nothing becomes unreachable.
pub presentation: Option<Presentation>,
}
/// TRACES: FR-DEV-3a | FR-DEV-3b
/// What one parameter offers.
///
/// [`Self::kind`] is what selects the control: the UI maps each `ParamKind`
/// to a widget appropriate to the current input modality (ARCH §4.3), and
/// never switches on the parameter's identity.
#[derive(Debug, Clone, PartialEq)]
pub struct ParamCapability {
pub id: ParamId,
pub label: LocalizedKey,
pub kind: ParamKind,
pub default: f32,
/// The current setting, so the control opens where the edit actually is.
pub value: f32,
}
impl ParamCapability {
/// Whether this parameter is away from its default.
pub fn is_modified(&self) -> bool {
self.value != self.default
}
}
/// An ordered pipeline of operations, plus how the result is framed.
pub struct EditGraph {
ops: Vec<Box<dyn Operation>>,
/// Crop, straighten, rotation and flips.
///
/// Held apart from `ops` rather than in the list because it is not one:
/// an operation transforms a colour, and framing decides which source
/// pixel that colour is read from — and changes the output's dimensions,
/// which no colour operation can do. See [`crate::framing`].
framing: Framing,
}
impl EditGraph {
/// The default develop chain, in pipeline order (ARCH §5.2).
///
/// Order is not arbitrary. White balance and exposure come first because
/// they are corrections to how the scene was captured, and the tonal
/// operations that follow should act on a correctly exposed image.
/// Colour comes last, so vibrance responds to the tones the user has
/// actually settled on rather than the ones they started with.
pub fn default_chain() -> Self {
Self {
ops: vec![
Box::new(ops::WhiteBalance::new()),
Box::new(ops::Exposure::new()),
Box::new(ops::Contrast::new()),
Box::new(ops::HighlightsShadows::new()),
Box::new(ops::BlacksWhites::new()),
// After the region controls, so the curve is the final word
// on tone: a photographer reaches for it to fix what the
// fixed-weight controls could not place exactly.
Box::new(ops::ToneCurve::new()),
Box::new(ops::Brilliance::new()),
Box::new(ops::Vibrance::new()),
Box::new(ops::Saturation::new()),
// The mixer comes last: it is the finishing control, and it
// should act on the tones the user has already settled.
Box::new(ops::ColourMixer::new()),
],
framing: Framing::new(),
}
}
/// The framing — crop, straighten, rotation and flips.
///
/// Reached directly rather than through `set_param` because the crop is a
/// rectangle, and driving one through four independent scalars makes an
/// interactive drag four clamps that can disagree. The parameter route
/// still exists for the sidecar, which has only scalars to work with.
pub fn framing(&self) -> &Framing {
&self.framing
}
pub fn framing_mut(&mut self) -> &mut Framing {
&mut self.framing
}
/// The size this graph renders to, given a source of `(w, h)`.
///
/// Cropping and quarter turns change it, so the caller allocating the
/// output texture must ask rather than assume the source size.
pub fn output_size(&self, width: u32, height: u32) -> (u32, u32) {
self.framing.output_size(width, height)
}
/// Descriptors for every operation, in order.
///
/// Operations only — framing is not one, and is reached through
/// [`Self::framing`] or the capability list. The distinction matters here
/// because this is what the codegen tests count `---- ` shader blocks
/// against, and framing generates a prologue rather than a colour block.
/// A UI wanting everything should read [`Self::capabilities`] (FR-DEV-3a).
pub fn descriptors(&self) -> Vec<&'static OpDescriptor> {
self.ops.iter().map(|o| o.descriptor()).collect()
}
/// TRACES: FR-DEV-3a | FR-DEV-3c
/// Everything a UI needs to build its controls.
///
/// **This is the only thing the UI should read.** It must not know that
/// exposure exists, that saturation is implemented with a mix, or that
/// any of this becomes a shader — it walks this list and instantiates a
/// control per entry according to the [`ParamKind`]. A new operation
/// therefore appears in the interface with no UI change at all
/// (FR-DEV-3c), and an operation removed from the chain disappears from
/// it just as automatically.
///
/// Current values are included so the UI has no separate initialisation
/// step, and so reopening an edited image shows where the sliders
/// actually are.
pub fn capabilities(&self) -> Vec<OpCapability> {
let ops = self.ops.iter().map(|op| {
let desc = op.descriptor();
OpCapability {
id: desc.id,
label: desc.label,
active: op.is_active(),
params: desc
.params
.iter()
.map(|p| ParamCapability {
id: p.id,
label: p.label,
kind: p.kind.clone(),
default: p.default,
value: op.param(p.id),
})
.collect(),
presentation: op.presentation(),
}
});
// Framing last, matching where it sits in the pipeline: the crop is
// decided after the image looks right, not before.
let desc = self.framing.descriptor();
let framing = OpCapability {
id: desc.id,
label: desc.label,
active: self.framing.is_active(),
params: desc
.params
.iter()
.map(|p| ParamCapability {
id: p.id,
label: p.label,
kind: p.kind.clone(),
default: p.default,
value: self.framing.param(p.id),
})
.collect(),
// Framing is not an `Operation`, so it has no `presentation` to
// ask for. A crop overlay is a viewport interaction rather than a
// panel widget, which is a different mechanism again.
presentation: None,
};
ops.chain(std::iter::once(framing)).collect()
}
/// Set a parameter, clamping to the descriptor's declared range.
///
/// Clamping here rather than in each operation means an operation never
/// has to defend against an out-of-range value, and a corrupt sidecar
/// cannot reach a shader.
pub fn set_param(&mut self, op: OpId, param: ParamId, value: f32) {
if op == crate::framing::ID {
let Some(desc) = self.framing.descriptor().param(param) else {
log::warn!("unknown parameter {param} on {op}; ignoring");
return;
};
self.framing.set_param(param, desc.clamp(value));
return;
}
let Some(operation) = self.ops.iter_mut().find(|o| o.descriptor().id == op) else {
// A sidecar naming an operation this build does not have. The
// rest of the edit must still apply.
log::warn!("unknown operation {op}; ignoring");
return;
};
let clamped = match operation.descriptor().param(param) {
Some(d) => d.clamp(value),
None => {
log::warn!("unknown parameter {param} on {op}; ignoring");
return;
}
};
operation.set_param(param, clamped);
}
/// Read a parameter back.
pub fn param(&self, op: OpId, param: ParamId) -> Option<f32> {
if op == crate::framing::ID {
return self
.framing
.descriptor()
.param(param)
.map(|_| self.framing.param(param));
}
self.ops
.iter()
.find(|o| o.descriptor().id == op)
.map(|o| o.param(param))
}
/// Reset every parameter of every operation, and the framing, to default.
pub fn reset(&mut self) {
for op in &mut self.ops {
for p in op.descriptor().params {
op.set_param(p.id, p.default);
}
}
self.framing.reset();
}
/// Set the crop rectangle. Clamped to keep it inside the frame.
pub fn set_crop(&mut self, rect: CropRect) {
self.framing.set_crop(rect);
}
pub fn crop(&self) -> CropRect {
self.framing.crop()
}
/// Rotate by quarter turns, wrapping. The rotate-left/right buttons.
pub fn rotate_quarters(&mut self, turns: i32) {
self.framing.rotate_quarters(turns);
}
/// Whether any operation, or the framing, currently changes the image.
///
/// Asks the framing whether it *edits*, not whether it is active: zoom
/// makes the framing active without changing the image, and reporting a
/// merely-zoomed image as edited would mark a clean file dirty.
pub fn is_neutral(&self) -> bool {
!self.ops.iter().any(|o| o.is_active()) && !self.framing.edits_image()
}
/// Generate the fused shader for the current state.
pub fn compose(&self) -> ComposedShader {
compose_with_framing(&self.ops, &self.framing)
}
}
impl Default for EditGraph {
fn default() -> Self {
Self::default_chain()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ops::{exposure, white_balance};
#[test]
fn a_fresh_graph_is_neutral() {
// Opening an unedited image must produce the image, not an
// interpretation of it.
let g = EditGraph::default_chain();
assert!(g.is_neutral());
assert_eq!(
g.compose().source.matches("---- ").count(),
0,
"a neutral graph must generate no operation blocks"
);
}
#[test]
fn the_default_chain_exposes_every_operation() {
let g = EditGraph::default_chain();
let ids: Vec<&str> = g.descriptors().iter().map(|d| d.id.0).collect();
for expected in [
"white_balance",
"exposure",
"highlights_shadows",
"blacks_whites",
"brilliance",
"vibrance",
"saturation",
] {
assert!(ids.contains(&expected), "{expected} missing from the chain");
}
}
#[test]
fn white_balance_and_exposure_precede_the_tonal_operations() {
// Corrections to capture must come before interpretation of tone, or
// the tonal controls act on a wrongly exposed image.
let g = EditGraph::default_chain();
let ids: Vec<&str> = g.descriptors().iter().map(|d| d.id.0).collect();
let pos = |id: &str| ids.iter().position(|x| *x == id).expect(id);
assert!(pos("white_balance") < pos("highlights_shadows"));
assert!(pos("exposure") < pos("highlights_shadows"));
assert!(pos("highlights_shadows") < pos("vibrance"));
}
#[test]
fn setting_a_parameter_activates_its_operation() {
let mut g = EditGraph::default_chain();
g.set_param(exposure::ID, exposure::EXPOSURE, 1.5);
assert!(!g.is_neutral());
assert_eq!(g.param(exposure::ID, exposure::EXPOSURE), Some(1.5));
assert!(g.compose().source.contains("---- exposure ----"));
}
#[test]
fn values_are_clamped_to_the_descriptor() {
// The guarantee that lets each operation skip range checks.
let mut g = EditGraph::default_chain();
g.set_param(exposure::ID, exposure::EXPOSURE, 99.0);
assert_eq!(g.param(exposure::ID, exposure::EXPOSURE), Some(5.0));
}
#[test]
fn an_unknown_operation_is_ignored_rather_than_panicking() {
// A sidecar from a newer version names operations this build lacks.
// The rest of the edit must still load.
let mut g = EditGraph::default_chain();
g.set_param(OpId("time_machine"), ParamId("year"), 1994.0);
assert!(g.is_neutral());
}
#[test]
fn an_unknown_parameter_is_ignored() {
let mut g = EditGraph::default_chain();
g.set_param(exposure::ID, ParamId("nonexistent"), 3.0);
assert!(g.is_neutral());
}
#[test]
fn reset_returns_every_operation_to_neutral() {
let mut g = EditGraph::default_chain();
g.set_param(exposure::ID, exposure::EXPOSURE, 2.0);
g.set_param(white_balance::ID, white_balance::TEMPERATURE, 50.0);
assert!(!g.is_neutral());
g.reset();
assert!(g.is_neutral(), "reset must clear every operation");
}
#[test]
fn only_active_operations_reach_the_shader() {
// The composition property, end to end: two adjustments out of seven
// available must generate a shader doing exactly two things.
let mut g = EditGraph::default_chain();
g.set_param(exposure::ID, exposure::EXPOSURE, 1.0);
g.set_param(white_balance::ID, white_balance::TINT, 25.0);
let shader = g.compose();
assert_eq!(shader.source.matches("---- ").count(), 2);
assert!(shader.source.contains("---- exposure ----"));
assert!(shader.source.contains("---- white_balance ----"));
assert!(!shader.source.contains("---- saturation ----"));
}
#[test]
fn moving_a_slider_does_not_change_the_shader_structure() {
// What makes the pipeline cache worth having: dragging a slider must
// reuse the compiled pipeline and upload uniforms only.
let mut g = EditGraph::default_chain();
g.set_param(exposure::ID, exposure::EXPOSURE, 1.0);
let first = g.compose();
g.set_param(exposure::ID, exposure::EXPOSURE, 2.0);
let second = g.compose();
assert_eq!(first.structure_hash, second.structure_hash);
assert_eq!(first.source, second.source);
assert_ne!(first.uniforms, second.uniforms);
}
#[test]
fn capabilities_describe_every_operation_and_parameter() {
// The UI builds its whole panel from this. Anything missing here is
// something the UI would have to hardcode.
let g = EditGraph::default_chain();
let caps = g.capabilities();
// Every operation, plus framing — which is not an operation and so
// is absent from `descriptors`, but must still reach the panel.
assert_eq!(caps.len(), g.descriptors().len() + 1);
assert!(
caps.iter().any(|c| c.id == crate::framing::ID),
"framing must appear in the capability list, or the UI cannot \
build a crop control without naming it"
);
for cap in &caps {
assert!(!cap.params.is_empty(), "{} exposes no parameters", cap.id);
for p in &cap.params {
// A control cannot be built without a range.
match &p.kind {
ParamKind::Scalar { min, max, .. } => {
assert!(min < max, "{}.{} has an empty range", cap.id, p.id);
assert!(
(*min..=*max).contains(&p.default),
"{}.{} default is outside its range",
cap.id,
p.id
);
}
ParamKind::Bool => {}
}
}
}
}
#[test]
fn capabilities_report_current_values_not_just_defaults() {
// So reopening an edited image shows the sliders where the edit left
// them, with no separate initialisation path in the UI.
let mut g = EditGraph::default_chain();
g.set_param(exposure::ID, exposure::EXPOSURE, 1.25);
let cap = g
.capabilities()
.into_iter()
.find(|c| c.id == exposure::ID)
.expect("exposure is in the chain");
let p = &cap.params[0];
assert_eq!(p.value, 1.25);
assert_eq!(p.default, 0.0);
assert!(p.is_modified());
assert!(cap.active);
}
#[test]
fn a_fresh_graph_reports_nothing_modified() {
for cap in EditGraph::default_chain().capabilities() {
assert!(!cap.active, "{} should start inactive", cap.id);
for p in &cap.params {
assert!(
!p.is_modified(),
"{}.{} should start at default",
cap.id,
p.id
);
}
}
}
#[test]
fn capabilities_survive_a_round_trip_through_set_param() {
// The UI reads a capability, writes the value back, and must get the
// same thing out — no hidden scaling between the two.
//
// Written at the parameter's declared precision, because that is what
// the UI can actually produce: a control declaring 0 decimals emits
// whole numbers, and a stage free to quantise to them is behaving
// correctly rather than losing the value.
let mut g = EditGraph::default_chain();
for cap in g.capabilities() {
for p in &cap.params {
if let ParamKind::Scalar { max, precision, .. } = p.kind {
let step = 10f32.powi(i32::from(precision));
let target = (max * 0.5 * step).round() / step;
g.set_param(cap.id, p.id, target);
assert_eq!(
g.param(cap.id, p.id),
Some(target),
"{}.{} did not round-trip",
cap.id,
p.id
);
}
}
}
}
#[test]
fn adding_an_operation_needs_no_ui_change() {
// FR-DEV-3c, asserted structurally: everything a control needs is
// reachable from the capability list, so a new operation appears
// without the UI naming it. If this test needs editing to add an
// operation, the abstraction has leaked.
let g = EditGraph::default_chain();
let rendered: Vec<String> = g
.capabilities()
.iter()
.flat_map(|c| {
c.params.iter().map(move |p| match p.kind {
ParamKind::Scalar {
min,
max,
precision,
..
} => format!(
"{}/{}: slider {min}..{max} @{precision} = {}",
c.label.0, p.label.0, p.value
),
ParamKind::Bool => format!("{}/{}: switch", c.label.0, p.label.0),
})
})
.collect();
// Counted from the chain, not a literal: this test must not need
// editing when an operation is added, or it would be asserting the
// opposite of what it claims.
let expected: usize = g.capabilities().iter().map(|c| c.params.len()).sum();
assert_eq!(rendered.len(), expected);
assert!(rendered.iter().all(|r| !r.is_empty()));
assert!(
expected > 40,
"the chain should now carry the mixer's 36 parameters too"
);
}
#[test]
fn enabling_another_operation_does_change_the_structure() {
let mut g = EditGraph::default_chain();
g.set_param(exposure::ID, exposure::EXPOSURE, 1.0);
let before = g.compose().structure_hash;
g.set_param(
crate::ops::colour::SATURATION_ID,
crate::ops::colour::SATURATION,
30.0,
);
assert_ne!(before, g.compose().structure_hash);
}
}