Point at something grey and let the pipeline work out the rest
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.
This commit is contained in:
@@ -32,6 +32,33 @@ params:
|
|||||||
label: param.tint
|
label: param.tint
|
||||||
kind: amount
|
kind: amount
|
||||||
|
|
||||||
|
# An eyedropper, where the frontend has a canvas to hang one on.
|
||||||
|
#
|
||||||
|
# Sampling a neutral is the first move of the global tonal pass — every colour
|
||||||
|
# judgement afterwards is measured against where the grey was put — and it is
|
||||||
|
# a thing you do by pointing at the photograph, not by guessing at two sliders
|
||||||
|
# until a wall stops looking green.
|
||||||
|
#
|
||||||
|
# A *hint*, on the usual terms: the two parameters below stay ordinary
|
||||||
|
# addressable scalars, and a frontend with nowhere to host a sampler renders
|
||||||
|
# them as the sliders they already were. This one is additive rather than a
|
||||||
|
# replacement — the picker writes temperature and tint and the photographer
|
||||||
|
# still nudges them afterwards — which is a difference the frontend draws for
|
||||||
|
# itself; nothing here has to say it.
|
||||||
|
#
|
||||||
|
# The order matters and is the widget kind's own contract: the first parameter
|
||||||
|
# trades red against blue, the second green against magenta.
|
||||||
|
presentation:
|
||||||
|
widgets: [white_point]
|
||||||
|
params: [temperature, tint]
|
||||||
|
demand:
|
||||||
|
# A neutral is a point on the picture, so both axes at once.
|
||||||
|
two_dimensional: true
|
||||||
|
# Deliberately false. A grey card, a cloud, a white wall — the things
|
||||||
|
# worth sampling are large, and FR-UI-7 grows the hit region to the
|
||||||
|
# modality in any case, so a thumb is as workable as a mouse.
|
||||||
|
precise_pointing: false
|
||||||
|
|
||||||
# Temperature trades red against blue; tint trades green against magenta.
|
# Temperature trades red against blue; tint trades green against magenta.
|
||||||
# Both are scaled so the full range is a strong but not destructive
|
# Both are scaled so the full range is a strong but not destructive
|
||||||
# correction: ±0.5 in log2 at the extremes — half a stop of channel shift,
|
# correction: ±0.5 in log2 at the extremes — half a stop of channel shift,
|
||||||
|
|||||||
@@ -170,6 +170,24 @@ pub enum WidgetKind {
|
|||||||
/// On-canvas brush strokes.
|
/// On-canvas brush strokes.
|
||||||
BrushMask,
|
BrushMask,
|
||||||
/// An eyedropper bound to the canvas, setting white balance from a pixel.
|
/// An eyedropper bound to the canvas, setting white balance from a pixel.
|
||||||
|
///
|
||||||
|
/// **The one widget that reads the photograph rather than driving it**,
|
||||||
|
/// and that is what gives it a contract the others do not need. A curve
|
||||||
|
/// tells its frontend where its points are and the frontend moves them; an
|
||||||
|
/// eyedropper is handed a colour and has to work out what the parameters
|
||||||
|
/// should become, which is only possible if the operation says enough
|
||||||
|
/// about itself to be inverted:
|
||||||
|
///
|
||||||
|
/// * The first two parameters in [`Presentation::params`] are its axes —
|
||||||
|
/// the first trading red against blue, the second green against
|
||||||
|
/// magenta — and each is monotonic in its axis.
|
||||||
|
/// * The operation publishes exactly three uniforms: the linear
|
||||||
|
/// per-channel gains, in red, green, blue order.
|
||||||
|
///
|
||||||
|
/// The same kind of contract [`Self::ToneCurve`] carries when it says its
|
||||||
|
/// parameters are point coordinates interleaved, and it is checked rather
|
||||||
|
/// than trusted — see [`crate::neutral`], which does the inverting so that
|
||||||
|
/// no frontend has to hold a second copy of the declared response.
|
||||||
WhitePoint,
|
WhitePoint,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ use crate::descriptor::{
|
|||||||
use crate::framing::{CropRect, Framing};
|
use crate::framing::{CropRect, Framing};
|
||||||
use crate::lens::LensProfile;
|
use crate::lens::LensProfile;
|
||||||
use crate::mask::MaskStack;
|
use crate::mask::MaskStack;
|
||||||
use crate::operation::{compose_full, ComposedShader, Operation};
|
use crate::operation::{compose_full, ComposedShader, Operation, Uniform};
|
||||||
use crate::ops;
|
use crate::ops;
|
||||||
use crate::preset::{Preset, Scope};
|
use crate::preset::{Preset, Scope};
|
||||||
use crate::spot::SpotSet;
|
use crate::spot::SpotSet;
|
||||||
@@ -436,6 +436,33 @@ impl EditGraph {
|
|||||||
warps.chain(ops).chain(std::iter::once(framing)).collect()
|
warps.chain(ops).chain(std::iter::once(framing)).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-3a
|
||||||
|
/// What one operation's uniforms currently evaluate to.
|
||||||
|
///
|
||||||
|
/// The same numbers [`Self::compose`] would bake into the uniform block,
|
||||||
|
/// asked for one node rather than for the whole chain. `None` where no
|
||||||
|
/// operation carries that id — the framing and the lens warps are not in
|
||||||
|
/// `ops`, and neither publishes uniforms of this kind.
|
||||||
|
///
|
||||||
|
/// **Why anything outside composition wants these.** A widget that reads
|
||||||
|
/// the photograph rather than driving it — an eyedropper, above all — has
|
||||||
|
/// to invert the operation: it knows what the pixel is and what it should
|
||||||
|
/// become, and needs the parameter values that get it there. The mapping
|
||||||
|
/// from parameters to effect lives in the node's own declaration, and
|
||||||
|
/// this is the only way to ask it what that mapping currently says
|
||||||
|
/// without composing a shader and rendering one. See [`crate::neutral`],
|
||||||
|
/// which is the one caller.
|
||||||
|
///
|
||||||
|
/// Cheap: a declared node evaluates a handful of small arithmetic
|
||||||
|
/// expressions. It is not, however, a per-frame path, and it is not on
|
||||||
|
/// one — composition reads the same values by its own route.
|
||||||
|
pub fn uniforms_of(&self, op: OpId) -> Option<Vec<Uniform>> {
|
||||||
|
self.ops
|
||||||
|
.iter()
|
||||||
|
.find(|o| o.descriptor().id == op)
|
||||||
|
.map(|o| o.uniforms())
|
||||||
|
}
|
||||||
|
|
||||||
/// Set a parameter, clamping to the descriptor's declared range.
|
/// Set a parameter, clamping to the descriptor's declared range.
|
||||||
///
|
///
|
||||||
/// Clamping here rather than in each operation means an operation never
|
/// Clamping here rather than in each operation means an operation never
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ pub mod graph;
|
|||||||
pub mod history;
|
pub mod history;
|
||||||
pub mod lens;
|
pub mod lens;
|
||||||
pub mod mask;
|
pub mod mask;
|
||||||
|
pub mod neutral;
|
||||||
pub mod operation;
|
pub mod operation;
|
||||||
pub mod ops;
|
pub mod ops;
|
||||||
pub mod preset;
|
pub mod preset;
|
||||||
|
|||||||
@@ -0,0 +1,391 @@
|
|||||||
|
//! TRACES: FR-DEV-3 | FR-DEV-3a
|
||||||
|
//! Turning a colour that ought to be grey into white balance parameters.
|
||||||
|
//!
|
||||||
|
//! # Why this is in the core and not in the interface
|
||||||
|
//!
|
||||||
|
//! FR-DEV-3 asks for a white balance picker: point at something neutral and
|
||||||
|
//! the correction follows. The pointing is the interface's — it owns the
|
||||||
|
//! canvas, the pixel and the modality — but the *arithmetic* is not, and the
|
||||||
|
//! reason is ARCH §4.3a rather than tidiness. How far a hundred units of
|
||||||
|
//! temperature move red against blue is declared in
|
||||||
|
//! `core/dr-pipeline/ops/white_balance.yaml`, in one expression, and an
|
||||||
|
//! interface that inverted it would be holding a second copy of a number it
|
||||||
|
//! is not allowed to know. The copy would then be wrong the first time
|
||||||
|
//! somebody adjusted the range, silently, in the direction of "the picker is
|
||||||
|
//! slightly off".
|
||||||
|
//!
|
||||||
|
//! So the interface hands over a colour and this hands back a moved graph.
|
||||||
|
//!
|
||||||
|
//! # What it is allowed to assume, which is a widget kind and not an operation
|
||||||
|
//!
|
||||||
|
//! Nothing here names white balance. It looks for the operation that asks for
|
||||||
|
//! [`WidgetKind::WhitePoint`], and everything else is that widget kind's own
|
||||||
|
//! contract — the same kind of contract [`WidgetKind::ToneCurve`] carries when
|
||||||
|
//! it says its parameters are point coordinates interleaved:
|
||||||
|
//!
|
||||||
|
//! * The **first two parameters** the presentation names are the axes. The
|
||||||
|
//! first trades red against blue, the second green against magenta.
|
||||||
|
//! * The operation publishes **exactly three uniforms: the linear per-channel
|
||||||
|
//! gains, in red, green, blue order.** That is what an eyedropper needs in
|
||||||
|
//! order to be invertible at all, and it is the honest way to say so —
|
||||||
|
//! a node whose effect on a grey is not three gains is not a node an
|
||||||
|
//! eyedropper can drive, and declaring the widget on one is the mistake
|
||||||
|
//! this refuses rather than approximates.
|
||||||
|
//!
|
||||||
|
//! Checked rather than trusted: a node that declares the widget and publishes
|
||||||
|
//! four uniforms gets no picker, and its sliders go on working.
|
||||||
|
//!
|
||||||
|
//! # Why a search rather than an inversion
|
||||||
|
//!
|
||||||
|
//! The declared expression is arbitrary — `exp2` today, a table lookup or a
|
||||||
|
//! polynomial tomorrow — and only its *monotonicity* is part of the bargain:
|
||||||
|
//! warmer is warmer all the way along, or the slider is not a slider. A
|
||||||
|
//! bisection needs exactly that and nothing more, so it stays correct across
|
||||||
|
//! every expression the declaration language can grow, where a closed-form
|
||||||
|
//! inverse would be a second definition of the node maintained here.
|
||||||
|
//!
|
||||||
|
//! It is also free. Twenty halvings of two ranges, three times over, is a few
|
||||||
|
//! hundred evaluations of two small arithmetic expressions — on a click, not
|
||||||
|
//! on a frame.
|
||||||
|
|
||||||
|
use crate::descriptor::{OpId, ParamId, ParamKind, WidgetKind};
|
||||||
|
use crate::graph::{EditGraph, OpCapability};
|
||||||
|
|
||||||
|
/// Halvings per axis.
|
||||||
|
///
|
||||||
|
/// Twenty resolves a ±100 control to about a five-thousandth of a unit, which
|
||||||
|
/// is far under the precision any of them is displayed at. There is no reason
|
||||||
|
/// to stop earlier: each step is two multiplications.
|
||||||
|
const STEPS: u32 = 20;
|
||||||
|
|
||||||
|
/// How many times the two axes are solved in turn.
|
||||||
|
///
|
||||||
|
/// One pass suffices for a node whose axes are independent, which is what
|
||||||
|
/// "trades red against blue" and "trades green against magenta" describe. The
|
||||||
|
/// repeats are for a node whose are not — a green correction that also lifts
|
||||||
|
/// red would leave the first axis a little out — and they cost nothing worth
|
||||||
|
/// counting.
|
||||||
|
const ROUNDS: u32 = 3;
|
||||||
|
|
||||||
|
/// Below this a channel carries no ratio worth balancing.
|
||||||
|
///
|
||||||
|
/// A sample in the deep shadows, or one taken on a blown highlight where a
|
||||||
|
/// channel has already clipped to nothing, has no white balance in it: the
|
||||||
|
/// logarithms below would run away and the picker would slam a slider to its
|
||||||
|
/// stop. Refusing is the honest answer, and the caller reports that the point
|
||||||
|
/// was not usable rather than moving the photograph.
|
||||||
|
const FLOOR: f32 = 1e-4;
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-3
|
||||||
|
/// Move the graph so that `sample` renders neutral.
|
||||||
|
///
|
||||||
|
/// `sample` is linear RGB, as the operation's own gains multiply it — that is,
|
||||||
|
/// measured with the sampling operation at its defaults. Returns whether the
|
||||||
|
/// graph was moved: `false` where the chain offers no white point widget, or
|
||||||
|
/// where the colour has no balance in it to correct.
|
||||||
|
///
|
||||||
|
/// **Absolute, not relative.** The values written depend on the colour and not
|
||||||
|
/// on where the sliders happened to be, so sampling the same wall twice lands
|
||||||
|
/// in the same place — and sampling a second, better neutral corrects the
|
||||||
|
/// photograph rather than correcting the correction.
|
||||||
|
pub fn neutralise(graph: &mut EditGraph, sample: [f32; 3]) -> bool {
|
||||||
|
if sample.iter().any(|c| !c.is_finite() || *c < FLOOR) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let Some(axes) = sampler(graph) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
// The uniform contract, checked before anything moves. A node that
|
||||||
|
// declares the widget and publishes something other than three gains
|
||||||
|
// cannot be driven from a pixel, and half-solving it would leave the
|
||||||
|
// photograph somewhere nobody asked for.
|
||||||
|
if gains(graph, axes.op).is_none() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for _ in 0..ROUNDS {
|
||||||
|
solve(
|
||||||
|
graph,
|
||||||
|
axes.op,
|
||||||
|
axes.warm,
|
||||||
|
axes.warm_axis,
|
||||||
|
sample,
|
||||||
|
warm_error,
|
||||||
|
);
|
||||||
|
solve(
|
||||||
|
graph,
|
||||||
|
axes.op,
|
||||||
|
axes.green,
|
||||||
|
axes.green_axis,
|
||||||
|
sample,
|
||||||
|
green_error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The operation an eyedropper writes to, and the two axes it moves.
|
||||||
|
struct Axes {
|
||||||
|
op: OpId,
|
||||||
|
/// Red against blue, with its declared travel and display precision.
|
||||||
|
warm: ParamId,
|
||||||
|
warm_axis: (f32, f32, u8),
|
||||||
|
/// Green against magenta, on the same terms.
|
||||||
|
green: ParamId,
|
||||||
|
green_axis: (f32, f32, u8),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find the operation that asked to be driven from a pixel.
|
||||||
|
///
|
||||||
|
/// The *first* one, if a chain ever carries two. Two white points in one chain
|
||||||
|
/// is a chain that has already decided something odd, and picking the first is
|
||||||
|
/// at least the same answer every time.
|
||||||
|
fn sampler(graph: &EditGraph) -> Option<Axes> {
|
||||||
|
graph.capabilities().into_iter().find_map(|cap| {
|
||||||
|
let presentation = cap.presentation.as_ref()?;
|
||||||
|
if !presentation.widgets.contains(&WidgetKind::WhitePoint) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut owned = presentation.params.iter().copied();
|
||||||
|
let warm = owned.next()?;
|
||||||
|
let green = owned.next()?;
|
||||||
|
Some(Axes {
|
||||||
|
op: cap.id,
|
||||||
|
warm,
|
||||||
|
warm_axis: axis(&cap, warm)?,
|
||||||
|
green,
|
||||||
|
green_axis: axis(&cap, green)?,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How far a parameter may be moved and how finely, from its own declaration.
|
||||||
|
///
|
||||||
|
/// A non-scalar axis is refused rather than coerced: a bisection over a list
|
||||||
|
/// of named alternatives is meaningless, and an operation offering one has not
|
||||||
|
/// declared what this widget kind needs.
|
||||||
|
fn axis(cap: &OpCapability, param: ParamId) -> Option<(f32, f32, u8)> {
|
||||||
|
cap.params
|
||||||
|
.iter()
|
||||||
|
.find(|p| p.id == param)
|
||||||
|
.and_then(|p| match &p.kind {
|
||||||
|
ParamKind::Scalar {
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
precision,
|
||||||
|
..
|
||||||
|
} => Some((*min, *max, *precision)),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Round to the precision the parameter is displayed at.
|
||||||
|
///
|
||||||
|
/// **The search has to stop somewhere, and this is the honest place.** Twenty
|
||||||
|
/// halvings land on something like -63.0117, which the panel would draw as
|
||||||
|
/// -63 and a photographer could never reproduce by hand — so the two would
|
||||||
|
/// disagree about what the control says, and a slider nudged one unit either
|
||||||
|
/// way would silently discard a fraction nobody could see.
|
||||||
|
///
|
||||||
|
/// It also fixes the case that matters most: sampling something that is
|
||||||
|
/// *already* neutral. Unrounded, that lands a ten-thousandth off zero, which
|
||||||
|
/// is a photograph marked modified and a step on the undo stack for a
|
||||||
|
/// correction of nothing.
|
||||||
|
fn snap(value: f32, precision: u8) -> f32 {
|
||||||
|
let scale = 10f32.powi(i32::from(precision));
|
||||||
|
(value * scale).round() / scale
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The three linear gains this operation currently applies, in RGB order.
|
||||||
|
///
|
||||||
|
/// `None` unless there are exactly three, which is the widget kind's contract
|
||||||
|
/// stated as a check. See the module note for why it is a contract and not a
|
||||||
|
/// guess.
|
||||||
|
fn gains(graph: &EditGraph, op: OpId) -> Option<[f32; 3]> {
|
||||||
|
let uniforms = graph.uniforms_of(op)?;
|
||||||
|
match uniforms.as_slice() {
|
||||||
|
[r, g, b] => Some([r.value, g.value, b.value]),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How far red sits from blue in the corrected sample, in stops.
|
||||||
|
///
|
||||||
|
/// Logarithms because the correction is multiplicative and the controls are
|
||||||
|
/// symmetric: warming by n and cooling by n are exact reciprocals, so an error
|
||||||
|
/// measured as a ratio is linear in the thing being searched and a difference
|
||||||
|
/// of products is not.
|
||||||
|
fn warm_error(gains: [f32; 3], sample: [f32; 3]) -> f32 {
|
||||||
|
(gains[0] * sample[0]).ln() - (gains[2] * sample[2]).ln()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How far green sits from the red-blue midpoint, in stops.
|
||||||
|
///
|
||||||
|
/// Against the midpoint rather than against red alone, so that the two axes do
|
||||||
|
/// not fight: with red and blue already balanced the two are the same number,
|
||||||
|
/// and while they are not, green is being pulled toward where the other axis
|
||||||
|
/// is heading rather than toward one end of it.
|
||||||
|
fn green_error(gains: [f32; 3], sample: [f32; 3]) -> f32 {
|
||||||
|
let r = (gains[0] * sample[0]).ln();
|
||||||
|
let b = (gains[2] * sample[2]).ln();
|
||||||
|
(gains[1] * sample[1]).ln() - 0.5 * (r + b)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drive one axis until `error` crosses zero, and leave it there.
|
||||||
|
///
|
||||||
|
/// The direction is read off the ends rather than assumed: which way "warmer"
|
||||||
|
/// runs is the node's business, and a picker that had guessed would move the
|
||||||
|
/// slider the wrong way on the first node that disagreed with it.
|
||||||
|
fn solve(
|
||||||
|
graph: &mut EditGraph,
|
||||||
|
op: OpId,
|
||||||
|
param: ParamId,
|
||||||
|
(min, max, precision): (f32, f32, u8),
|
||||||
|
sample: [f32; 3],
|
||||||
|
error: fn([f32; 3], [f32; 3]) -> f32,
|
||||||
|
) {
|
||||||
|
let at = |graph: &mut EditGraph, value: f32| -> f32 {
|
||||||
|
graph.set_param(op, param, value);
|
||||||
|
gains(graph, op).map_or(0.0, |g| error(g, sample))
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut low = min;
|
||||||
|
let mut high = max;
|
||||||
|
let low_error = at(graph, low);
|
||||||
|
let high_error = at(graph, high);
|
||||||
|
|
||||||
|
// No crossing inside the declared travel: the correction this colour needs
|
||||||
|
// is more than the control can give. Taken as far as it goes, which is
|
||||||
|
// what a photographer would do by hand — refusing would leave a badly cast
|
||||||
|
// frame uncorrected because it could not be corrected *entirely*.
|
||||||
|
if low_error.is_sign_positive() == high_error.is_sign_positive() {
|
||||||
|
let end = if low_error.abs() <= high_error.abs() {
|
||||||
|
low
|
||||||
|
} else {
|
||||||
|
high
|
||||||
|
};
|
||||||
|
graph.set_param(op, param, snap(end, precision));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for _ in 0..STEPS {
|
||||||
|
let middle = 0.5 * (low + high);
|
||||||
|
if at(graph, middle).is_sign_positive() == low_error.is_sign_positive() {
|
||||||
|
low = middle;
|
||||||
|
} else {
|
||||||
|
high = middle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
graph.set_param(op, param, snap(0.5 * (low + high), precision));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// The spread of a triple, relative to its middle channel.
|
||||||
|
fn cast(rendered: [f32; 3]) -> f32 {
|
||||||
|
let high = rendered.iter().copied().fold(f32::MIN, f32::max);
|
||||||
|
let low = rendered.iter().copied().fold(f32::MAX, f32::min);
|
||||||
|
(high - low) / rendered[1].max(f32::EPSILON)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the sampled colour becomes once the graph has been moved.
|
||||||
|
fn corrected(graph: &EditGraph, sample: [f32; 3]) -> [f32; 3] {
|
||||||
|
let axes = sampler(graph).expect("the default chain declares a white point");
|
||||||
|
let g = gains(graph, axes.op).expect("three gains");
|
||||||
|
[g[0] * sample[0], g[1] * sample[1], g[2] * sample[2]]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-3
|
||||||
|
/// The whole point: a warm grey comes back grey.
|
||||||
|
#[test]
|
||||||
|
fn sampling_a_warm_grey_makes_it_grey() {
|
||||||
|
let mut graph = EditGraph::default_chain();
|
||||||
|
let sample = [0.62, 0.50, 0.40];
|
||||||
|
assert!(cast(sample) > 0.3, "the premise: this is a strong cast");
|
||||||
|
|
||||||
|
assert!(neutralise(&mut graph, sample));
|
||||||
|
assert!(
|
||||||
|
cast(corrected(&graph, sample)) < 0.02,
|
||||||
|
"the sample should render neutral: {:?}",
|
||||||
|
corrected(&graph, sample)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-3
|
||||||
|
/// And so does a cold one, which is the other half of the same claim: the
|
||||||
|
/// search finds its own direction rather than being told one.
|
||||||
|
#[test]
|
||||||
|
fn sampling_a_cold_grey_makes_it_grey_too() {
|
||||||
|
let mut graph = EditGraph::default_chain();
|
||||||
|
let sample = [0.40, 0.50, 0.62];
|
||||||
|
|
||||||
|
assert!(neutralise(&mut graph, sample));
|
||||||
|
assert!(
|
||||||
|
cast(corrected(&graph, sample)) < 0.02,
|
||||||
|
"{:?}",
|
||||||
|
corrected(&graph, sample)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-3
|
||||||
|
/// Sampling is absolute: the second sample corrects the photograph, not
|
||||||
|
/// the previous correction.
|
||||||
|
///
|
||||||
|
/// The failure this guards is the one every relative picker has — sample a
|
||||||
|
/// wall, decide a cloud was better, sample the cloud, and land somewhere
|
||||||
|
/// neither of them describes.
|
||||||
|
#[test]
|
||||||
|
fn a_second_sample_replaces_the_first_rather_than_compounding_it() {
|
||||||
|
let mut graph = EditGraph::default_chain();
|
||||||
|
|
||||||
|
assert!(neutralise(&mut graph, [0.62, 0.50, 0.40]));
|
||||||
|
assert!(neutralise(&mut graph, [0.44, 0.50, 0.56]));
|
||||||
|
|
||||||
|
let second = [0.44, 0.50, 0.56];
|
||||||
|
assert!(
|
||||||
|
cast(corrected(&graph, second)) < 0.02,
|
||||||
|
"{:?}",
|
||||||
|
corrected(&graph, second)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-3
|
||||||
|
/// A sample with nothing in it is refused rather than acted on.
|
||||||
|
///
|
||||||
|
/// Black has no white balance. A picker that treated it as one would take
|
||||||
|
/// the ratio of two numbers that are both noise and slam the controls to
|
||||||
|
/// their stops, which reads as the feature being broken rather than as the
|
||||||
|
/// point having been a poor choice.
|
||||||
|
#[test]
|
||||||
|
fn a_sample_with_no_balance_in_it_moves_nothing() {
|
||||||
|
let mut graph = EditGraph::default_chain();
|
||||||
|
let before = crate::Preset::capture(&graph);
|
||||||
|
|
||||||
|
assert!(!neutralise(&mut graph, [0.0, 0.0, 0.0]));
|
||||||
|
assert!(!neutralise(&mut graph, [f32::NAN, 0.5, 0.5]));
|
||||||
|
|
||||||
|
assert_eq!(crate::Preset::capture(&graph), before);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-3a
|
||||||
|
/// The picker is found through the widget kind, not through a name.
|
||||||
|
///
|
||||||
|
/// If this ever fails because the declaration moved, the right fix is in
|
||||||
|
/// the declaration: nothing here is entitled to know which node it is.
|
||||||
|
#[test]
|
||||||
|
fn the_chain_offers_exactly_one_white_point() {
|
||||||
|
let graph = EditGraph::default_chain();
|
||||||
|
let found = graph
|
||||||
|
.capabilities()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|c| {
|
||||||
|
c.presentation
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|p| p.widgets.contains(&WidgetKind::WhitePoint))
|
||||||
|
})
|
||||||
|
.count();
|
||||||
|
assert_eq!(found, 1, "one operation should ask to be driven by a pixel");
|
||||||
|
}
|
||||||
|
}
|
||||||
+52
-52
File diff suppressed because one or more lines are too long
+246
-7
@@ -1185,12 +1185,46 @@ pub(crate) fn supported(widget: WidgetKind) -> bool {
|
|||||||
// the panel contributes `ComposePanel`, the affordance that turns it
|
// the panel contributes `ComposePanel`, the affordance that turns it
|
||||||
// on.
|
// on.
|
||||||
WidgetKind::CropOverlay => true,
|
WidgetKind::CropOverlay => true,
|
||||||
|
// TRACES: FR-DEV-3
|
||||||
|
// Hosted on the canvas too — a click on the photograph — with the
|
||||||
|
// affordance that arms it in the group's own heading. See
|
||||||
|
// `samples_the_canvas` for why this one leaves its sliders standing
|
||||||
|
// where the crop takes them away.
|
||||||
|
WidgetKind::WhitePoint => true,
|
||||||
// Not implemented. Listed rather than caught by a wildcard so the next
|
// Not implemented. Listed rather than caught by a wildcard so the next
|
||||||
// kind added to the core surfaces here as a compile error.
|
// kind added to the core surfaces here as a compile error.
|
||||||
WidgetKind::ColourWheel
|
WidgetKind::ColourWheel | WidgetKind::GradientHandle | WidgetKind::BrushMask => false,
|
||||||
| WidgetKind::GradientHandle
|
}
|
||||||
| WidgetKind::BrushMask
|
}
|
||||||
| WidgetKind::WhitePoint => false,
|
|
||||||
|
/// TRACES: FR-DEV-3a | FR-UI-7
|
||||||
|
/// Whether an on-canvas widget *reads* the photograph rather than replacing
|
||||||
|
/// its parameters with handles.
|
||||||
|
///
|
||||||
|
/// **This is the distinction that stopped the picker eating its own sliders.**
|
||||||
|
/// [`WidgetKind::is_on_canvas`] says where a widget is manipulated, and the
|
||||||
|
/// panel had been treating that as also meaning "and so the panel draws
|
||||||
|
/// nothing for it". For a crop that is right: four edge fractions and an angle
|
||||||
|
/// are not controls anybody drags in a list, and the whole reason the crop is
|
||||||
|
/// on the photograph is that they are unusable anywhere else.
|
||||||
|
///
|
||||||
|
/// An eyedropper is the other thing. It *writes* temperature and tint — they
|
||||||
|
/// remain exactly the controls a photographer reaches for afterwards, because
|
||||||
|
/// a sampled neutral is a starting point and warming a portrait past it is the
|
||||||
|
/// next move, not a mistake. Taking the sliders away to make room for the
|
||||||
|
/// picker would be trading a control for a control.
|
||||||
|
///
|
||||||
|
/// So the panel draws the group as usual and puts the affordance that arms the
|
||||||
|
/// canvas in its heading. FR-DEV-3's "temperature/tint, **and** picker" is one
|
||||||
|
/// word doing a lot of work, and this is the word.
|
||||||
|
fn samples_the_canvas(widget: WidgetKind) -> bool {
|
||||||
|
match widget {
|
||||||
|
WidgetKind::WhitePoint => true,
|
||||||
|
// Dragged rather than sampled: the parameters *are* the handles.
|
||||||
|
WidgetKind::CropOverlay | WidgetKind::GradientHandle | WidgetKind::BrushMask => false,
|
||||||
|
// Not on the canvas at all, so nothing asks. Listed rather than
|
||||||
|
// wildcarded for the reason `supported` lists its own.
|
||||||
|
WidgetKind::ToneCurve | WidgetKind::ColourWheel => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1240,6 +1274,11 @@ pub(crate) fn rows_filtered(
|
|||||||
// Where this operation's rows begin. The panel groups by walking
|
// Where this operation's rows begin. The panel groups by walking
|
||||||
// back to it, so it has to be taken before any row is pushed.
|
// back to it, so it has to be taken before any row is pushed.
|
||||||
let group_head = rows.len();
|
let group_head = rows.len();
|
||||||
|
// TRACES: FR-DEV-3
|
||||||
|
// Whether this group's heading carries the affordance that arms an
|
||||||
|
// on-canvas sampler. Set below, from what the operation asked for and
|
||||||
|
// nothing else — the panel never learns which operation it is.
|
||||||
|
let mut group_samples = false;
|
||||||
// An operation may ask for one widget spanning several
|
// An operation may ask for one widget spanning several
|
||||||
// parameters. Honouring it is optional — dropping this block
|
// parameters. Honouring it is optional — dropping this block
|
||||||
// renders the same parameters as ordinary sliders, and the edit
|
// renders the same parameters as ordinary sliders, and the edit
|
||||||
@@ -1270,7 +1309,15 @@ pub(crate) fn rows_filtered(
|
|||||||
// build (ARCH §4.3a draws the line at the *generated* panel
|
// build (ARCH §4.3a draws the line at the *generated* panel
|
||||||
// naming stages, not at the interface having hand-made
|
// naming stages, not at the interface having hand-made
|
||||||
// widgets).
|
// widgets).
|
||||||
if widget.is_on_canvas() {
|
//
|
||||||
|
// TRACES: FR-DEV-3
|
||||||
|
// **Unless the canvas is *reading* rather than driving.** An
|
||||||
|
// eyedropper writes temperature and tint and leaves them as
|
||||||
|
// the controls they were, so its group is drawn in full and
|
||||||
|
// only the affordance moves to the heading. See
|
||||||
|
// `samples_the_canvas` for the whole of that argument.
|
||||||
|
group_samples = samples_the_canvas(widget);
|
||||||
|
if widget.is_on_canvas() && !group_samples {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1281,8 +1328,10 @@ pub(crate) fn rows_filtered(
|
|||||||
WidgetKind::ToneCurve => {
|
WidgetKind::ToneCurve => {
|
||||||
curve_row(op_index, group_head, op, presentation, curve_channel)
|
curve_row(op_index, group_head, op, presentation, curve_channel)
|
||||||
}
|
}
|
||||||
// Canvas-hosted kinds returned above; the rest are not
|
// Canvas-hosted kinds returned above, except a
|
||||||
// implemented and reached sliders via `choose`.
|
// sampler, which falls through to its own sliders; the
|
||||||
|
// rest are not implemented and reached sliders via
|
||||||
|
// `choose`.
|
||||||
WidgetKind::ColourWheel
|
WidgetKind::ColourWheel
|
||||||
| WidgetKind::CropOverlay
|
| WidgetKind::CropOverlay
|
||||||
| WidgetKind::GradientHandle
|
| WidgetKind::GradientHandle
|
||||||
@@ -1371,6 +1420,7 @@ pub(crate) fn rows_filtered(
|
|||||||
group_head: group_head as i32,
|
group_head: group_head as i32,
|
||||||
group_len,
|
group_len,
|
||||||
group_modified,
|
group_modified,
|
||||||
|
group_samples,
|
||||||
kind: kind.into(),
|
kind: kind.into(),
|
||||||
value: p.value,
|
value: p.value,
|
||||||
default_value: p.default,
|
default_value: p.default,
|
||||||
@@ -1498,6 +1548,8 @@ fn curve_row(
|
|||||||
// the group it heads is itself and nothing else.
|
// the group it heads is itself and nothing else.
|
||||||
group_len: 1,
|
group_len: 1,
|
||||||
group_modified: op.params.iter().any(|p| p.value != p.default),
|
group_modified: op.params.iter().any(|p| p.value != p.default),
|
||||||
|
// A curve is drawn, not sampled. Its own affordance is the plot.
|
||||||
|
group_samples: false,
|
||||||
kind: "curve".into(),
|
kind: "curve".into(),
|
||||||
value: 0.0,
|
value: 0.0,
|
||||||
default_value: 0.0,
|
default_value: 0.0,
|
||||||
@@ -3671,6 +3723,118 @@ impl DevelopSession {
|
|||||||
self.set_film(None);
|
self.set_film(None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-3 | FR-DEV-5
|
||||||
|
/// Set the white balance from a point on the photograph.
|
||||||
|
///
|
||||||
|
/// `x` and `y` are fractions of the *visible* image — the coordinates a
|
||||||
|
/// click on the canvas arrives in — so a photographer inspecting a
|
||||||
|
/// highlight at 4× samples the pixel they are actually looking at.
|
||||||
|
///
|
||||||
|
/// **Nothing here knows it is white balance.** The colour goes to
|
||||||
|
/// [`dr_pipeline::neutral`], which finds the operation that asked to be
|
||||||
|
/// driven by a pixel and inverts its declared response; this side supplies
|
||||||
|
/// the pixel and the undo step and nothing else. That is FR-DEV-3a's line
|
||||||
|
/// in its awkward case: 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 interface must not be the thing that
|
||||||
|
/// holds a second copy of it.
|
||||||
|
///
|
||||||
|
/// **One step per sample**, and no step at all for a sample that could not
|
||||||
|
/// be used — a point in the deep shadows has no balance in it to correct.
|
||||||
|
/// `Edit::Action` never coalesces, so two clicks are two decisions
|
||||||
|
/// however quickly they follow each other, which is what a photographer
|
||||||
|
/// trying a wall and then a cloud expects to be able to undo one at a
|
||||||
|
/// time.
|
||||||
|
///
|
||||||
|
/// Returns whether the photograph moved.
|
||||||
|
pub fn sample_neutral(&mut self, x: f32, y: f32) -> bool {
|
||||||
|
let Some(sample) = self.sample_as_shot(x, y) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if !dr_pipeline::neutral::neutralise(&mut self.graph, sample) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.history
|
||||||
|
.record(&self.graph, Edit::Action(labels::step::SAMPLED_NEUTRAL));
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The colour at a point with every adjustment taken off, in linear RGB.
|
||||||
|
///
|
||||||
|
/// **Measured before the chain rather than off the screen**, and that is
|
||||||
|
/// the difference between a picker that converges and one that chases
|
||||||
|
/// itself. The frame on the canvas has already been through the white
|
||||||
|
/// balance being solved for, the tone curve, the contrast and whatever
|
||||||
|
/// else is on; neutralising *that* pixel would be correcting a correction,
|
||||||
|
/// and the second sample of the same wall would land somewhere else. With
|
||||||
|
/// the adjustments stripped the value read is the colour as the file has
|
||||||
|
/// it, which is the domain `neutralise` is defined over.
|
||||||
|
///
|
||||||
|
/// The framing stays on, exactly as it does for [`Self::render_original`]
|
||||||
|
/// and for the same reason: `x` and `y` are fractions of what is on
|
||||||
|
/// screen, and a probe rendered without the crop and the zoom would be
|
||||||
|
/// answering about a different part of the photograph.
|
||||||
|
///
|
||||||
|
/// **Rendered small on purpose.** A 192px probe of the visible region
|
||||||
|
/// averages a small neighbourhood into each of its pixels, which is what
|
||||||
|
/// every eyedropper does deliberately: a single photosite off a noisy
|
||||||
|
/// shadow is a worse answer than the patch around it, and the photographer
|
||||||
|
/// is pointing at a grey card rather than at a pixel. It is also two
|
||||||
|
/// dispatches' worth of work on a click.
|
||||||
|
///
|
||||||
|
/// This overwrites the frame the adjust pass is holding, so the caller
|
||||||
|
/// must redraw — which the callback that samples does anyway, since the
|
||||||
|
/// picture has just changed.
|
||||||
|
fn sample_as_shot(&mut self, x: f32, y: f32) -> Option<[f32; 3]> {
|
||||||
|
/// Long edge of the probe render. See the note above on why it is
|
||||||
|
/// small rather than large.
|
||||||
|
const PROBE_EDGE: u32 = 192;
|
||||||
|
|
||||||
|
// sRGB regardless of the display: this is a measurement, not something
|
||||||
|
// anybody looks at, and decoding it needs a transfer function known
|
||||||
|
// here. Composing for a wide-gamut panel would put the reading in a
|
||||||
|
// space the arithmetic below does not undo.
|
||||||
|
let space = dr_types::ColourSpace::Srgb;
|
||||||
|
|
||||||
|
let saved = self.graph.state();
|
||||||
|
self.strip_adjustments();
|
||||||
|
|
||||||
|
let (sw, sh) = self.demosaiced.size();
|
||||||
|
let (fw, fh) = self.graph.output_size(sw, sh);
|
||||||
|
let (w, h) = fit(fw, fh, PROBE_EDGE, PROBE_EDGE);
|
||||||
|
let shader = self.graph.compose_for(space);
|
||||||
|
let probe = self
|
||||||
|
.render_with_masks(&shader, w, h, space)
|
||||||
|
.and_then(|()| self.adjust.export_pixels().map_err(|e| e.to_string()));
|
||||||
|
|
||||||
|
// Restored whatever happened, for the reason every other suspension
|
||||||
|
// here restores: leaving the graph stripped after a failed probe would
|
||||||
|
// discard the edit silently.
|
||||||
|
let debt = self.graph.set_state(&saved);
|
||||||
|
self.pay_film_debt(&debt);
|
||||||
|
|
||||||
|
let (rgba, pw, ph) = probe
|
||||||
|
.inspect_err(|e| log::warn!("could not read a neutral off the frame: {e}"))
|
||||||
|
.ok()?;
|
||||||
|
|
||||||
|
let (pw, ph) = (pw as usize, ph as usize);
|
||||||
|
let px = ((x.clamp(0.0, 1.0) * pw as f32) as usize).min(pw.saturating_sub(1));
|
||||||
|
let py = ((y.clamp(0.0, 1.0) * ph as f32) as usize).min(ph.saturating_sub(1));
|
||||||
|
let at = (py * pw + px) * 4;
|
||||||
|
let pixel = rgba.get(at..at + 3)?;
|
||||||
|
|
||||||
|
// The probe was encoded for the screen; the solve is multiplicative
|
||||||
|
// and only means anything in linear light (ARCH §5.2). Undone with
|
||||||
|
// the space's own transfer function rather than a second copy of the
|
||||||
|
// curve written out here.
|
||||||
|
let transfer = space.transfer();
|
||||||
|
Some([
|
||||||
|
transfer.decode(f32::from(pixel[0]) / 255.0),
|
||||||
|
transfer.decode(f32::from(pixel[1]) / 255.0),
|
||||||
|
transfer.decode(f32::from(pixel[2]) / 255.0),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
/// TRACES: FR-PLAT-AND-5 | NFR-RES-1
|
/// TRACES: FR-PLAT-AND-5 | NFR-RES-1
|
||||||
/// Give back the GPU memory this session is holding only to be fast.
|
/// Give back the GPU memory this session is holding only to be fast.
|
||||||
///
|
///
|
||||||
@@ -5597,6 +5761,37 @@ mod tests {
|
|||||||
assert!(!session.framing_edits_image());
|
assert!(!session.framing_edits_image());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-3 | FR-DEV-5
|
||||||
|
/// Sampling something that is already neutral corrects nothing, and says
|
||||||
|
/// so by leaving the stack alone.
|
||||||
|
///
|
||||||
|
/// The failure this guards is a picker that lands a ten-thousandth off
|
||||||
|
/// zero: the photograph would come back marked modified, an undo step
|
||||||
|
/// would appear for a correction of nothing, and the sidecar would gain a
|
||||||
|
/// temperature the photographer never chose. The solve rounds to the
|
||||||
|
/// precision the control is drawn at, which is what makes "no correction"
|
||||||
|
/// representable at all.
|
||||||
|
#[test]
|
||||||
|
fn sampling_a_grey_that_is_already_grey_leaves_the_photograph_alone() {
|
||||||
|
let Some(ctx) = headless() else { return };
|
||||||
|
let (mut session, _) = grey_session(&ctx);
|
||||||
|
let steps = session.history_rows().len();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
session.sample_neutral(0.5, 0.5),
|
||||||
|
"a flat grey frame is a usable sample"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
session.is_neutral(),
|
||||||
|
"there was nothing to correct, so nothing was corrected"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
session.history_rows().len(),
|
||||||
|
steps,
|
||||||
|
"and a correction of nothing is not a step"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// TRACES: FR-DEV-7 | FR-DEV-5
|
/// TRACES: FR-DEV-7 | FR-DEV-5
|
||||||
/// A held comparison hands the edit straight back.
|
/// A held comparison hands the edit straight back.
|
||||||
///
|
///
|
||||||
@@ -6449,6 +6644,50 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-3 | FR-DEV-3a
|
||||||
|
/// A canvas *sampler* adds an affordance; it does not take the sliders.
|
||||||
|
///
|
||||||
|
/// The distinction the panel had been missing. `is_on_canvas` was read as
|
||||||
|
/// "and so the panel draws nothing", which is right for a crop and wrong
|
||||||
|
/// for an eyedropper — FR-DEV-3 asks for "temperature/tint, **and**
|
||||||
|
/// picker", and a picker that ate the two sliders would have traded one
|
||||||
|
/// control for another. Asserted against the chain rather than a literal,
|
||||||
|
/// so it keeps testing the property when the declaration moves.
|
||||||
|
#[test]
|
||||||
|
fn a_sampled_operation_keeps_the_sliders_it_writes() {
|
||||||
|
let graph = EditGraph::default_chain();
|
||||||
|
let caps = graph.capabilities();
|
||||||
|
|
||||||
|
let (index, cap) = caps
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.find(|(_, c)| {
|
||||||
|
c.presentation
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|p| p.widgets.contains(&WidgetKind::WhitePoint))
|
||||||
|
})
|
||||||
|
.expect("some operation asks to be driven by a pixel");
|
||||||
|
|
||||||
|
let rows = rows_from(&caps);
|
||||||
|
let mine: Vec<_> = rows.iter().filter(|r| r.op_index == index as i32).collect();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
mine.len(),
|
||||||
|
cap.params.len(),
|
||||||
|
"every parameter of a sampled operation still has its own control"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
mine.iter().all(|r| r.group_samples),
|
||||||
|
"and the group's heading carries the picker"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
rows.iter()
|
||||||
|
.filter(|r| r.op_index != index as i32)
|
||||||
|
.all(|r| !r.group_samples),
|
||||||
|
"no other group claims one"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn framing_is_not_generated_as_sliders() {
|
fn framing_is_not_generated_as_sliders() {
|
||||||
// `ComposePanel` presents crop, rotation, flips and straightening as
|
// `ComposePanel` presents crop, rotation, flips and straightening as
|
||||||
|
|||||||
@@ -29,6 +29,14 @@ pub mod step {
|
|||||||
pub const PASTE: LocalizedKey = LocalizedKey("history.paste");
|
pub const PASTE: LocalizedKey = LocalizedKey("history.paste");
|
||||||
pub const FILM: LocalizedKey = LocalizedKey("history.film");
|
pub const FILM: LocalizedKey = LocalizedKey("history.film");
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-3
|
||||||
|
/// A neutral was picked off the photograph, setting the white balance.
|
||||||
|
///
|
||||||
|
/// Named for what the photographer did rather than for the parameters it
|
||||||
|
/// moved: two of them changed, and "Temperature" beside "Tint" in the list
|
||||||
|
/// would describe the mechanism rather than the decision.
|
||||||
|
pub const SAMPLED_NEUTRAL: LocalizedKey = LocalizedKey("history.sampled_neutral");
|
||||||
|
|
||||||
pub const RESET_ALL: LocalizedKey = LocalizedKey("history.reset_all");
|
pub const RESET_ALL: LocalizedKey = LocalizedKey("history.reset_all");
|
||||||
pub const RESET_OP: LocalizedKey = LocalizedKey("history.reset_op");
|
pub const RESET_OP: LocalizedKey = LocalizedKey("history.reset_op");
|
||||||
pub const RESET_PARAM: LocalizedKey = LocalizedKey("history.reset_param");
|
pub const RESET_PARAM: LocalizedKey = LocalizedKey("history.reset_param");
|
||||||
@@ -77,6 +85,7 @@ pub mod step {
|
|||||||
dr_pipeline::history::UNNAMED,
|
dr_pipeline::history::UNNAMED,
|
||||||
PASTE,
|
PASTE,
|
||||||
FILM,
|
FILM,
|
||||||
|
SAMPLED_NEUTRAL,
|
||||||
RESET_ALL,
|
RESET_ALL,
|
||||||
RESET_OP,
|
RESET_OP,
|
||||||
RESET_PARAM,
|
RESET_PARAM,
|
||||||
@@ -253,6 +262,7 @@ fn catalogued(key: &str) -> Option<&'static str> {
|
|||||||
"history.edit" => "Edit",
|
"history.edit" => "Edit",
|
||||||
"history.paste" => "Paste Settings",
|
"history.paste" => "Paste Settings",
|
||||||
"history.film" => "Film Stock",
|
"history.film" => "Film Stock",
|
||||||
|
"history.sampled_neutral" => "Sample Neutral",
|
||||||
|
|
||||||
"history.reset_all" => "Reset Everything",
|
"history.reset_all" => "Reset Everything",
|
||||||
"history.reset_op" => "Reset Group",
|
"history.reset_op" => "Reset Group",
|
||||||
|
|||||||
@@ -378,6 +378,11 @@ fn reset_view_state(window: &AppWindow) {
|
|||||||
// back anyway, but a press that opens the next photograph — the roll is a
|
// back anyway, but a press that opens the next photograph — the roll is a
|
||||||
// tap away — must not carry a held original onto it.
|
// tap away — must not carry a held original onto it.
|
||||||
window.set_showing_original(false);
|
window.set_showing_original(false);
|
||||||
|
// TRACES: FR-DEV-3
|
||||||
|
// And an armed sampler goes down with it: a picker left waiting would make
|
||||||
|
// the first click on the *next* photograph sample it, which is a click
|
||||||
|
// nobody meant to spend.
|
||||||
|
window.set_sampling_op(-1);
|
||||||
// TRACES: FR-DSP-7
|
// TRACES: FR-DSP-7
|
||||||
// Emptied rather than left standing: the previous photograph's histogram
|
// Emptied rather than left standing: the previous photograph's histogram
|
||||||
// beside the next one's filename is a confident, precise lie, and the gap
|
// beside the next one's filename is a confident, precise lie, and the gap
|
||||||
@@ -2833,6 +2838,39 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
|||||||
render_now(&w, false);
|
render_now(&w, false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
// TRACES: FR-DEV-3
|
||||||
|
// A neutral picked off the photograph.
|
||||||
|
//
|
||||||
|
// The operation is not named here and is not passed in: the click
|
||||||
|
// carries a point, the session turns it into a colour, and
|
||||||
|
// `dr_pipeline::neutral` finds the operation that asked to be driven
|
||||||
|
// by one. This callback's whole contribution is that a click on the
|
||||||
|
// canvas is a click on the canvas.
|
||||||
|
//
|
||||||
|
// The rows are re-synced because the sample moved parameters the panel
|
||||||
|
// is showing — two of them, from one press, which is exactly the case
|
||||||
|
// a paste and an undo already go through this path for.
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let session = session.clone();
|
||||||
|
let redraw = redraw.clone();
|
||||||
|
let rows = rows.clone();
|
||||||
|
window.on_neutral_picked(move |x, y| {
|
||||||
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
let sampled = session
|
||||||
|
.borrow_mut()
|
||||||
|
.as_mut()
|
||||||
|
.is_some_and(|s| s.sample_neutral(x, y));
|
||||||
|
if sampled {
|
||||||
|
sync_rows(&w, &rows, &session);
|
||||||
|
}
|
||||||
|
// Redrawn either way. A sample that found nothing usable still
|
||||||
|
// overwrote the frame the adjust pass was holding, and leaving the
|
||||||
|
// canvas showing a probe of the unedited image would look like the
|
||||||
|
// edit had been thrown away.
|
||||||
|
redraw(&w);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// TRACES: FR-DEV-5 | FR-DEV-7
|
// TRACES: FR-DEV-5 | FR-DEV-7
|
||||||
// Clicking a row. Arriving six steps away costs what arriving from one
|
// Clicking a row. Arriving six steps away costs what arriving from one
|
||||||
|
|||||||
@@ -42,6 +42,17 @@ export struct ParamRow {
|
|||||||
// cannot see the others.
|
// cannot see the others.
|
||||||
group-modified: bool,
|
group-modified: bool,
|
||||||
|
|
||||||
|
// TRACES: FR-DEV-3
|
||||||
|
// This operation is driven by pointing at the photograph as well as by
|
||||||
|
// its sliders, so the group's heading carries the control that arms the
|
||||||
|
// canvas. Identical on every row of the group, for the reason
|
||||||
|
// `group-modified` above is: the heading is one of those rows.
|
||||||
|
//
|
||||||
|
// Set in Rust from the widget the operation asked for, never from which
|
||||||
|
// operation it is (ARCH §4.3a) — the panel still does not know that white
|
||||||
|
// balance exists, only that something here can be sampled.
|
||||||
|
group-samples: bool,
|
||||||
|
|
||||||
// Which control to build. Mirrors ParamKind, plus the widget kinds an
|
// Which control to build. Mirrors ParamKind, plus the widget kinds an
|
||||||
// operation can request through its presentation.
|
// operation can request through its presentation.
|
||||||
kind: string, // "scalar" | "bool" | "enum" | "curve"
|
kind: string, // "scalar" | "bool" | "enum" | "curve"
|
||||||
@@ -111,8 +122,15 @@ component GroupHeading inherits Rectangle {
|
|||||||
in property <bool> modified: false;
|
in property <bool> modified: false;
|
||||||
/// Whether this group has anything to reset.
|
/// Whether this group has anything to reset.
|
||||||
in property <bool> has-reset: true;
|
in property <bool> has-reset: true;
|
||||||
|
/// TRACES: FR-DEV-3
|
||||||
|
/// Whether this group can be set by pointing at the photograph.
|
||||||
|
in property <bool> has-sampler: false;
|
||||||
|
/// Whether the canvas is currently waiting for that point.
|
||||||
|
in property <bool> sampling: false;
|
||||||
|
|
||||||
callback reset();
|
callback reset();
|
||||||
|
/// Arm the sampler, or put it away if it is already armed.
|
||||||
|
callback sample();
|
||||||
|
|
||||||
height: Theme.control-height;
|
height: Theme.control-height;
|
||||||
|
|
||||||
@@ -136,6 +154,52 @@ component GroupHeading inherits Rectangle {
|
|||||||
visible: root.modified;
|
visible: root.modified;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TRACES: FR-DEV-3 | NFR-A11Y-2
|
||||||
|
// The eyedropper, built exactly as the reset beside it is: a word, a
|
||||||
|
// hit target grown to a thumb, and a role so it is announced as
|
||||||
|
// something that can be pressed rather than read as a caption.
|
||||||
|
//
|
||||||
|
// A word rather than a drawn pipette, because this heading has no
|
||||||
|
// icons in it and one would be the only glyph in a column of text —
|
||||||
|
// and because "pick" says what happens next, which a pipette only
|
||||||
|
// says to somebody who already knows.
|
||||||
|
//
|
||||||
|
// It stays lit while armed. Arming changes what a click on the
|
||||||
|
// photograph *does*, and a mode with nothing saying it is on is the
|
||||||
|
// fault the develop view's own mode strip exists to prevent.
|
||||||
|
Rectangle {
|
||||||
|
width: 30px;
|
||||||
|
visible: root.has-sampler;
|
||||||
|
|
||||||
|
accessible-role: button;
|
||||||
|
accessible-label: "Set from a neutral in the photograph";
|
||||||
|
accessible-enabled: root.has-sampler;
|
||||||
|
accessible-checkable: true;
|
||||||
|
accessible-checked: root.sampling;
|
||||||
|
accessible-action-default => {
|
||||||
|
if (root.has-sampler) {
|
||||||
|
root.sample();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sample-touch := TouchArea {
|
||||||
|
width: 100%;
|
||||||
|
height: max(parent.height, Theme.touch-target);
|
||||||
|
y: (parent.height - self.height) / 2;
|
||||||
|
enabled: root.has-sampler;
|
||||||
|
mouse-cursor: pointer;
|
||||||
|
clicked => { root.sample(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
Caption {
|
||||||
|
text: "pick";
|
||||||
|
emphasised: root.sampling || sample-touch.has-hover;
|
||||||
|
horizontal-alignment: right;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
width: 34px;
|
width: 34px;
|
||||||
visible: root.has-reset;
|
visible: root.has-reset;
|
||||||
@@ -993,6 +1057,19 @@ export component AdjustPanel inherits Rectangle {
|
|||||||
callback op-reset(int);
|
callback op-reset(int);
|
||||||
callback reset-all();
|
callback reset-all();
|
||||||
|
|
||||||
|
// TRACES: FR-DEV-3
|
||||||
|
// The on-canvas sampler: which group has armed it, and the request to arm
|
||||||
|
// one.
|
||||||
|
//
|
||||||
|
// The op index rather than a bool, because arming is a question about a
|
||||||
|
// *group* — two operations could ask for a sampler and only one of them
|
||||||
|
// can be waiting for the click. -1 is none armed, the sentinel this file
|
||||||
|
// already uses for "no swatch" and "everything" and for the same reason:
|
||||||
|
// a Slint struct or property cannot carry an optional.
|
||||||
|
in property <int> sampling-op: -1;
|
||||||
|
/// The heading's picker was pressed, for the operation at this index.
|
||||||
|
callback sampler-armed(int);
|
||||||
|
|
||||||
// TRACES: FR-DEV-3f
|
// TRACES: FR-DEV-3f
|
||||||
// The film stock, which is not a parameter and so is not a `ParamRow`.
|
// The film stock, which is not a parameter and so is not a `ParamRow`.
|
||||||
//
|
//
|
||||||
@@ -1276,6 +1353,15 @@ export component AdjustPanel inherits Rectangle {
|
|||||||
GroupHeading {
|
GroupHeading {
|
||||||
title: row.op-label;
|
title: row.op-label;
|
||||||
modified: row.group-modified;
|
modified: row.group-modified;
|
||||||
|
// TRACES: FR-DEV-3
|
||||||
|
// And, where the operation asked for one, the
|
||||||
|
// control that arms the canvas. The heading knows
|
||||||
|
// only that it has a sampler; which group is
|
||||||
|
// waiting is the panel's business, because only
|
||||||
|
// the panel can see the others.
|
||||||
|
has-sampler: row.group-samples;
|
||||||
|
sampling: root.sampling-op == row.op-index;
|
||||||
|
sample => { root.sampler-armed(row.op-index); }
|
||||||
// Resetting is what *this* panel's groups do; the
|
// Resetting is what *this* panel's groups do; the
|
||||||
// heading itself has no opinion about it.
|
// heading itself has no opinion about it.
|
||||||
reset => { root.op-reset(row.op-index); }
|
reset => { root.op-reset(row.op-index); }
|
||||||
|
|||||||
@@ -237,6 +237,30 @@ export component AppWindow inherits Window {
|
|||||||
/// the two cannot get out of step and strand the view on the original.
|
/// the two cannot get out of step and strand the view on the original.
|
||||||
callback compare-original(bool);
|
callback compare-original(bool);
|
||||||
|
|
||||||
|
// --- sampling a neutral off the photograph (FR-DEV-3) ---
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-3
|
||||||
|
/// Which operation's on-canvas sampler is armed, or -1 for none.
|
||||||
|
///
|
||||||
|
/// Held here rather than in Rust, unlike the view mode beside it, and the
|
||||||
|
/// difference is real: entering a mode has side effects on the session and
|
||||||
|
/// this has none. Arming changes what the next click on the photograph
|
||||||
|
/// means and nothing else — no parameter moves, no history step is taken,
|
||||||
|
/// and the session has nothing to hear about until a point is picked. It
|
||||||
|
/// is one-shot: the click that samples disarms it.
|
||||||
|
///
|
||||||
|
/// An index rather than a bool because arming belongs to a *group*. Two
|
||||||
|
/// operations could offer a sampler and only one of them can be waiting.
|
||||||
|
in-out property <int> sampling-op: -1;
|
||||||
|
/// Shorthand for the condition the canvas tests, written once.
|
||||||
|
property <bool> sampling: root.sampling-op >= 0;
|
||||||
|
/// A neutral was picked, in fractions of the visible image.
|
||||||
|
///
|
||||||
|
/// The operation is not passed: which one a sampled colour belongs to is
|
||||||
|
/// settled by what the chain declared, and this file has no business
|
||||||
|
/// knowing. See `dr_pipeline::neutral`.
|
||||||
|
callback neutral-picked(float, float);
|
||||||
|
|
||||||
/// Every step, newest first. Rust owns the order and stamps each row with
|
/// Every step, newest first. Rust owns the order and stamps each row with
|
||||||
/// its own position in the stack, so nothing here does arithmetic to turn
|
/// its own position in the stack, so nothing here does arithmetic to turn
|
||||||
/// a row back into a step.
|
/// a row back into a step.
|
||||||
@@ -2050,6 +2074,43 @@ in property <bool> panel-visible: true;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TRACES: FR-DEV-3 | FR-UI-7
|
||||||
|
// Sampling a neutral, beside the repair placer above and
|
||||||
|
// built exactly like it: a click on the fitted image, over
|
||||||
|
// the pan area so the click reaches it first, and a
|
||||||
|
// separate handler rather than a branch inside the pan —
|
||||||
|
// panning wants press-drag-release and this wants a click,
|
||||||
|
// and interleaving the two is how a drag ends up sampling
|
||||||
|
// whatever it happened to travel over.
|
||||||
|
//
|
||||||
|
// **One click, one sample, one history step.** There is no
|
||||||
|
// hover preview: previewing would mean a render and a
|
||||||
|
// solve per pixel the pointer crossed, and if any of them
|
||||||
|
// were recorded the stack would fill with a hundred
|
||||||
|
// temperatures nobody chose. The arming state is what says
|
||||||
|
// the next click will do something instead.
|
||||||
|
if root.sampling && root.total > 0 && root.load-error == "": TouchArea {
|
||||||
|
x: parent.shown-x;
|
||||||
|
y: parent.shown-y;
|
||||||
|
width: parent.shown-w;
|
||||||
|
height: parent.shown-h;
|
||||||
|
mouse-cursor: MouseCursor.crosshair;
|
||||||
|
enabled: !root.cropping;
|
||||||
|
|
||||||
|
clicked => {
|
||||||
|
root.neutral-picked(
|
||||||
|
self.mouse-x / max(self.width, 1px),
|
||||||
|
self.mouse-y / max(self.height, 1px),
|
||||||
|
);
|
||||||
|
// One shot. Leaving it armed would make the next
|
||||||
|
// click on the photograph — to place a mask, to
|
||||||
|
// pan — a second sample, and the photographer
|
||||||
|
// would have to remember to put a tool away that
|
||||||
|
// has already done its job.
|
||||||
|
root.sampling-op = -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if root.total > 0 && root.load-error == "": TouchArea {
|
if root.total > 0 && root.load-error == "": TouchArea {
|
||||||
x: 0; y: 0;
|
x: 0; y: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -2831,6 +2892,17 @@ in property <bool> panel-visible: true;
|
|||||||
// kept in step with it.
|
// kept in step with it.
|
||||||
op-reset(op) => { root.curve-reset(op); }
|
op-reset(op) => { root.curve-reset(op); }
|
||||||
reset-all => { root.reset-all(); }
|
reset-all => { root.reset-all(); }
|
||||||
|
// TRACES: FR-DEV-3
|
||||||
|
// Arming is a toggle and it is resolved here,
|
||||||
|
// not in Rust: it changes what a click on the
|
||||||
|
// photograph does and nothing about the edit,
|
||||||
|
// so there is nothing for the session to hear
|
||||||
|
// about until a point is actually picked.
|
||||||
|
sampling-op: root.sampling-op;
|
||||||
|
sampler-armed(op) => {
|
||||||
|
root.sampling-op =
|
||||||
|
root.sampling-op == op ? -1 : op;
|
||||||
|
}
|
||||||
// TRACES: FR-DEV-3f
|
// TRACES: FR-DEV-3f
|
||||||
film-stocks: root.film-stocks;
|
film-stocks: root.film-stocks;
|
||||||
film-selected: root.film-selected;
|
film-selected: root.film-selected;
|
||||||
|
|||||||
Reference in New Issue
Block a user