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
|
||||
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.
|
||||
# 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,
|
||||
|
||||
@@ -170,6 +170,24 @@ pub enum WidgetKind {
|
||||
/// On-canvas brush strokes.
|
||||
BrushMask,
|
||||
/// 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,
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ use crate::descriptor::{
|
||||
use crate::framing::{CropRect, Framing};
|
||||
use crate::lens::LensProfile;
|
||||
use crate::mask::MaskStack;
|
||||
use crate::operation::{compose_full, ComposedShader, Operation};
|
||||
use crate::operation::{compose_full, ComposedShader, Operation, Uniform};
|
||||
use crate::ops;
|
||||
use crate::preset::{Preset, Scope};
|
||||
use crate::spot::SpotSet;
|
||||
@@ -436,6 +436,33 @@ impl EditGraph {
|
||||
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.
|
||||
///
|
||||
/// Clamping here rather than in each operation means an operation never
|
||||
|
||||
@@ -41,6 +41,7 @@ pub mod graph;
|
||||
pub mod history;
|
||||
pub mod lens;
|
||||
pub mod mask;
|
||||
pub mod neutral;
|
||||
pub mod operation;
|
||||
pub mod ops;
|
||||
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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user