Split develop.rs into develop/ by area of behaviour
develop.rs had grown to 9,327 lines covering everything the develop session does: opening a photograph, the parameter-row and curve-widget panel model, mask viewing and editing, mask creation and the rasteriser that turns a mask stack into GPU arrays, spot repairs, scene segmentation, framing and zoom, white-balance sampling, rendering and film choice, and the undo/snapshot history. docs/dev/code-health.md CH-1 names dr-ui's lack of a view layer as the reason every feature kept landing in a handful of files; this is the first of the two pure splits it recommends as easy, no-behaviour-change wins independent of that larger rework. The boundaries follow the file's own sections (several were already marked off with comment headers) and the seams a full read turned up underneath them -- mask storage/rasterisation turned out to be a distinct concern from mask viewing and editing, and rows/tabs/curves from each other, so those split further than the headers alone suggested. Each module stays under about 1,500 lines. Struct fields and the handful of helper methods now called from a sibling module became `pub(super)`, which is strictly narrower than the whole-crate reachability a single file gave them; nothing gained visibility outside `develop`. Tests moved with the code they test, including the few cases where a helper one file's tests needed was itself only defined in another's -- those became shared fixtures in `mod.rs` alongside the `headless`/`read_back`/`grey_session` helpers that already worked that way. `mod.rs` re-exports every item `develop::` callers outside this module used before, so lib.rs, masks_ui.rs and the rest needed no changes.
This commit is contained in:
@@ -0,0 +1,523 @@
|
||||
//! The tone-curve widget: one run of points per subject, and the session's
|
||||
//! curve-channel selection that picks which one is being dragged.
|
||||
use dr_pipeline::ops::curve;
|
||||
use dr_pipeline::{OpCapability, Presentation, WidgetKind};
|
||||
|
||||
use crate::labels;
|
||||
use crate::ParamRow;
|
||||
|
||||
#[cfg(test)]
|
||||
use super::rows::rows_filtered;
|
||||
#[cfg(test)]
|
||||
use super::rows::rows_from;
|
||||
use super::rows::{no_choices, supported};
|
||||
use super::session::DevelopSession;
|
||||
#[cfg(test)]
|
||||
use dr_pipeline::{EditGraph, OpId, ParamId, ParamKind, Unit};
|
||||
|
||||
/// One run of a curve widget's parameters: the points of a single curve.
|
||||
///
|
||||
/// A widget may span several curves — the tone curve is one plot over a master
|
||||
/// curve and three colour channels — and it says so the way the colour mixer
|
||||
/// says it has twelve bands: by faceting each parameter with the *subject* it
|
||||
/// acts on. Consecutive parameters sharing a subject are one curve.
|
||||
struct CurveRun {
|
||||
/// The subject's localisation key, or `None` where the widget's parameters
|
||||
/// carry no facet at all and are therefore a single unnamed curve.
|
||||
subject: Option<&'static str>,
|
||||
/// Where this run's points begin in the operation's parameter list. What
|
||||
/// a drag routes back through, so it must be a position in `op.params`
|
||||
/// and not in the presentation's list.
|
||||
base: usize,
|
||||
/// How many coordinates it holds.
|
||||
len: usize,
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3a
|
||||
/// The curves a curve widget spans, in the order the operation declares them.
|
||||
///
|
||||
/// **This is the whole of the panel's knowledge of colour channels: none.** It
|
||||
/// groups by whatever subject the parameters carry, so an operation offering a
|
||||
/// master curve and three channels gets a four-way selector, one offering a
|
||||
/// single unfaceted curve gets no selector at all, and one that grows a fifth
|
||||
/// curve tomorrow needs no change here.
|
||||
///
|
||||
/// Returns `None` where the parameters do not look like point coordinates —
|
||||
/// an odd count, a run that is not contiguous in the capability list — in
|
||||
/// which case the caller falls back to sliders rather than drawing a widget
|
||||
/// over a layout it has guessed at.
|
||||
fn curve_runs(op: &OpCapability, presentation: &Presentation) -> Option<Vec<CurveRun>> {
|
||||
// Points are x/y pairs, so an odd count means the operation and this code
|
||||
// disagree about the layout.
|
||||
if presentation.params.len() < 2 || !presentation.params.len().is_multiple_of(2) {
|
||||
log::warn!("{}: curve widget needs an even parameter count", op.id);
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut runs: Vec<CurveRun> = Vec::new();
|
||||
for id in &presentation.params {
|
||||
// The widget addresses points by offset from the first of its run, so
|
||||
// a run has to be contiguous in the capability list.
|
||||
let at = op.params.iter().position(|p| p.id == *id)?;
|
||||
let subject = op.params[at].facet.as_ref().map(|f| f.subject.0);
|
||||
|
||||
match runs.last_mut() {
|
||||
Some(run) if run.subject == subject && run.base + run.len == at => run.len += 1,
|
||||
_ => runs.push(CurveRun {
|
||||
subject,
|
||||
base: at,
|
||||
len: 1,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
if runs.iter().any(|r| !r.len.is_multiple_of(2)) {
|
||||
log::warn!("{}: a curve's points are not contiguous", op.id);
|
||||
return None;
|
||||
}
|
||||
Some(runs)
|
||||
}
|
||||
|
||||
/// One row standing for a whole curve.
|
||||
///
|
||||
/// `channel` picks which of the widget's curves is plotted; it is clamped
|
||||
/// rather than validated, because the selection is interface state that
|
||||
/// outlives a change of photograph and the new image's operation may have
|
||||
/// fewer curves than the old one's.
|
||||
///
|
||||
/// Returns `None` if the operation's parameters do not look like point
|
||||
/// coordinates, in which case the caller falls back to sliders rather than
|
||||
/// rendering a broken widget.
|
||||
pub(super) fn curve_row(
|
||||
op_index: usize,
|
||||
group_head: usize,
|
||||
op: &OpCapability,
|
||||
presentation: &Presentation,
|
||||
channel: usize,
|
||||
) -> Option<ParamRow> {
|
||||
let runs = curve_runs(op, presentation)?;
|
||||
let run = runs.get(channel.min(runs.len().saturating_sub(1)))?;
|
||||
|
||||
let points: Vec<f32> = op.params[run.base..run.base + run.len]
|
||||
.iter()
|
||||
.map(|p| p.value)
|
||||
.collect();
|
||||
|
||||
Some(ParamRow {
|
||||
op_index: op_index as i32,
|
||||
// The first point parameter *of the curve on show*; the widget offsets
|
||||
// from here, so switching curve is what re-points the drag.
|
||||
param_index: run.base as i32,
|
||||
op_label: labels::resolve(op.label.0).into(),
|
||||
param_label: String::new().into(),
|
||||
// A widget spanning a whole operation is not a row in anyone's
|
||||
// grid, so it heads no run and carries no swatch.
|
||||
facet_label: String::new().into(),
|
||||
starts_facet: false,
|
||||
swatch_hue: -1.0,
|
||||
group_head: group_head as i32,
|
||||
// One widget standing for every parameter of the operation, so
|
||||
// the group it heads is itself and nothing else.
|
||||
group_len: 1,
|
||||
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(),
|
||||
value: 0.0,
|
||||
default_value: 0.0,
|
||||
minimum: 0.0,
|
||||
maximum: 1.0,
|
||||
precision: 4,
|
||||
unit: String::new().into(),
|
||||
points: slint::ModelRc::new(slint::VecModel::from(points)),
|
||||
// A curve is not a choice between named alternatives. The curves it
|
||||
// can switch between are named on the panel rather than on the row —
|
||||
// see `DevelopSession::curve_channels` for why they cannot ride here.
|
||||
choices: no_choices(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Sort ascending and force a minimum separation.
|
||||
///
|
||||
/// Mirrors what the curve operation does before handing points to the
|
||||
/// shader. Duplicated rather than shared because the operation keeps it
|
||||
/// private, and the consequence of drift is only a drawn line that lags the
|
||||
/// rendered one by a pixel — not a wrong image.
|
||||
fn sort_with_gap(xs: &mut [f32]) {
|
||||
const MIN_GAP: f32 = 0.001;
|
||||
for i in 1..xs.len() {
|
||||
let mut j = i;
|
||||
while j > 0 && xs[j - 1] > xs[j] {
|
||||
xs.swap(j - 1, j);
|
||||
j -= 1;
|
||||
}
|
||||
}
|
||||
for i in 1..xs.len() {
|
||||
if xs[i] - xs[i - 1] < MIN_GAP {
|
||||
xs[i] = xs[i - 1] + MIN_GAP;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DevelopSession {
|
||||
/// TRACES: FR-DEV-3
|
||||
/// The names of the curves the widget can switch between.
|
||||
///
|
||||
/// Empty where there is only one, which is also the answer for a frontend
|
||||
/// with no curve at all: a selector over a single choice is a row of
|
||||
/// nothing.
|
||||
///
|
||||
/// **Derived from the facets, so nothing here names a colour channel.**
|
||||
/// The operation says its forty points are one control applied to four
|
||||
/// subjects and publishes a localisation key for each; this resolves the
|
||||
/// keys and hands over four words. An operation that grew a fifth curve
|
||||
/// would appear here on its own.
|
||||
///
|
||||
/// A panel property rather than a field on the curve's `ParamRow`, and the
|
||||
/// reason is Slint's: a row's models are compared by identity, so a fresh
|
||||
/// list of names built on every parameter event would make the row look
|
||||
/// changed every time, and rewriting a row rebuilds the repeater item
|
||||
/// underneath it — destroying the `TouchArea` holding the drag in
|
||||
/// progress. The same hazard `rows`'s in-place point update exists to
|
||||
/// avoid. Nothing in this list is a drag target, so up here it is safe to
|
||||
/// replace wholesale, exactly as [`Self::curve_samples`] is.
|
||||
pub fn curve_channels(&self) -> Vec<String> {
|
||||
for op in &self.scoped_capabilities() {
|
||||
let Some(presentation) = &op.presentation else {
|
||||
continue;
|
||||
};
|
||||
if presentation.choose(supported) != Some(WidgetKind::ToneCurve) {
|
||||
continue;
|
||||
}
|
||||
let Some(runs) = curve_runs(op, presentation) else {
|
||||
continue;
|
||||
};
|
||||
if runs.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
return runs
|
||||
.iter()
|
||||
.map(|r| r.subject.map(labels::resolve).unwrap_or_default())
|
||||
.collect();
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Which curve the widget is plotting, as an index into
|
||||
/// [`Self::curve_channels`].
|
||||
pub fn curve_channel(&self) -> i32 {
|
||||
self.curve_channel as i32
|
||||
}
|
||||
|
||||
/// Plot a different one of the operation's curves.
|
||||
///
|
||||
/// Out-of-range indices are ignored rather than clamped: the only thing
|
||||
/// that can send one is a stale interface event, and quietly moving the
|
||||
/// selection somewhere the user did not point is worse than doing nothing.
|
||||
pub fn set_curve_channel(&mut self, index: i32) {
|
||||
let Ok(index) = usize::try_from(index) else {
|
||||
return;
|
||||
};
|
||||
if index < self.curve_channels().len() {
|
||||
self.curve_channel = index;
|
||||
}
|
||||
}
|
||||
|
||||
/// The plotted curve's shape, sampled for drawing.
|
||||
///
|
||||
/// Evaluated with `dr_pipeline`'s own spline, so the line the user drags
|
||||
/// is the line the shader applies. The alternative — reading the curve
|
||||
/// back off the GPU — is the round-trip ARCH §6.1 forbids, to draw a
|
||||
/// polyline.
|
||||
///
|
||||
/// The line drawn is the *selected* curve's own shape, not the composition
|
||||
/// of it with the master. Two curves overlaid on one grid is a plot of two
|
||||
/// things, and the one being dragged has to be the one whose points are
|
||||
/// under the pointer.
|
||||
pub fn curve_samples(&self) -> Vec<f32> {
|
||||
const SAMPLES: usize = 96;
|
||||
|
||||
// The selection is an index over the subjects the panel found, which
|
||||
// for this operation is its channel order. Clamped rather than
|
||||
// trusted: a selection made on one photograph outlives the change to
|
||||
// the next.
|
||||
let channel = curve::Channel::ALL[self.curve_channel.min(curve::CHANNELS - 1)];
|
||||
|
||||
let mut xs = [0.0f32; curve::POINTS];
|
||||
let mut ys = [0.0f32; curve::POINTS];
|
||||
let mut found = false;
|
||||
|
||||
for cap in self.graph.capabilities() {
|
||||
if cap.id != curve::ID {
|
||||
continue;
|
||||
}
|
||||
found = true;
|
||||
// By id rather than by position, so which curve is plotted is
|
||||
// decided by naming it and not by arithmetic over the parameter
|
||||
// list.
|
||||
let value = |id| {
|
||||
cap.params
|
||||
.iter()
|
||||
.find(|p| p.id == id)
|
||||
.map_or(0.0, |p| p.value)
|
||||
};
|
||||
for i in 0..curve::POINTS {
|
||||
xs[i] = value(curve::coordinate(channel, i, curve::Axis::X));
|
||||
ys[i] = value(curve::coordinate(channel, i, curve::Axis::Y));
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
// Sorted the same way the operation sorts before handing points to
|
||||
// the shader, or a dragged-past point would draw differently from
|
||||
// how it renders.
|
||||
sort_with_gap(&mut xs);
|
||||
|
||||
(0..SAMPLES)
|
||||
.map(|i| {
|
||||
let x = i as f32 / (SAMPLES - 1) as f32;
|
||||
curve::evaluate(&xs, &ys, x).clamp(0.0, 1.0)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_curve_collapses_to_a_single_row() {
|
||||
// Every point parameter — all four curves' worth — must appear as one
|
||||
// curve control, not as forty sliders. Otherwise the widget and the
|
||||
// sliders both render and the panel shows the same values twice.
|
||||
let graph = EditGraph::default_chain();
|
||||
let curve_cap = graph
|
||||
.capabilities()
|
||||
.into_iter()
|
||||
.find(|c| c.id == curve::ID)
|
||||
.expect("the chain includes a tone curve");
|
||||
|
||||
assert_eq!(
|
||||
curve_cap.params.len(),
|
||||
curve::CHANNELS * curve::POINTS * 2,
|
||||
"a master curve and one per colour channel"
|
||||
);
|
||||
let presentation = curve_cap
|
||||
.presentation
|
||||
.as_ref()
|
||||
.expect("the curve declares a widget");
|
||||
// Asked the way the panel asks it: the first preference this frontend
|
||||
// implements, not a fixed single kind.
|
||||
assert_eq!(presentation.choose(supported), Some(WidgetKind::ToneCurve));
|
||||
// Every parameter is owned by the widget, so none is left over to be
|
||||
// rendered as a stray slider.
|
||||
assert_eq!(presentation.params.len(), curve_cap.params.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curve_point_parameters_are_contiguous() {
|
||||
// The widget addresses points by offset from the first. Were they
|
||||
// interleaved with anything else, dragging a point would write to
|
||||
// the wrong parameter.
|
||||
let graph = EditGraph::default_chain();
|
||||
let cap = graph
|
||||
.capabilities()
|
||||
.into_iter()
|
||||
.find(|c| c.id == curve::ID)
|
||||
.expect("tone curve present");
|
||||
let presentation = cap.presentation.as_ref().expect("declares a widget");
|
||||
|
||||
let base = cap
|
||||
.params
|
||||
.iter()
|
||||
.position(|p| p.id == presentation.params[0])
|
||||
.expect("first point is a parameter");
|
||||
for (i, id) in presentation.params.iter().enumerate() {
|
||||
assert_eq!(
|
||||
cap.params[base + i].id,
|
||||
*id,
|
||||
"point parameter {i} is out of order"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The panel's whole knowledge of colour channels, asserted to be none.
|
||||
///
|
||||
/// It groups the widget's parameters by the subject the *operation* put on
|
||||
/// them and finds four curves; nothing below says "red", and an operation
|
||||
/// that grew a fifth curve would arrive here on its own.
|
||||
#[test]
|
||||
fn a_curve_widget_offers_one_run_per_subject() {
|
||||
let graph = EditGraph::default_chain();
|
||||
let cap = graph
|
||||
.capabilities()
|
||||
.into_iter()
|
||||
.find(|c| c.id == curve::ID)
|
||||
.expect("tone curve present");
|
||||
let presentation = cap.presentation.as_ref().expect("declares a widget");
|
||||
|
||||
let runs = curve_runs(&cap, presentation).expect("a curve-shaped operation");
|
||||
assert_eq!(runs.len(), curve::CHANNELS);
|
||||
for (i, run) in runs.iter().enumerate() {
|
||||
assert_eq!(run.len, curve::POINTS * 2, "run {i} is not five points");
|
||||
assert_eq!(run.base, i * curve::POINTS * 2);
|
||||
assert!(run.subject.is_some(), "run {i} is unnamed");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switching_curve_repoints_the_row() {
|
||||
use slint::Model as _;
|
||||
|
||||
// What a drag routes through. The row's `param_index` is the base of
|
||||
// the curve *on show*, so picking a different one must move it — if it
|
||||
// did not, dragging a point on the red curve would write to the
|
||||
// master's.
|
||||
let graph = EditGraph::default_chain();
|
||||
let caps = graph.capabilities();
|
||||
let curve_at = caps
|
||||
.iter()
|
||||
.position(|c| c.id == curve::ID)
|
||||
.expect("tone curve present");
|
||||
|
||||
let mut bases = Vec::new();
|
||||
for channel in 0..curve::CHANNELS {
|
||||
let rows = rows_filtered(&caps, |_| true, channel);
|
||||
let row = rows
|
||||
.iter()
|
||||
.find(|r| r.op_index as usize == curve_at)
|
||||
.expect("the curve has a row");
|
||||
assert_eq!(row.kind, "curve");
|
||||
assert_eq!(
|
||||
row.points.row_count(),
|
||||
curve::POINTS * 2,
|
||||
"one curve's points, not all four curves'"
|
||||
);
|
||||
bases.push(row.param_index);
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
bases,
|
||||
(0..curve::CHANNELS)
|
||||
.map(|i| (i * curve::POINTS * 2) as i32)
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_selection_the_operation_cannot_honour_falls_back_to_its_last_curve() {
|
||||
// The selection outlives the photograph it was made on, and the next
|
||||
// image's operation may offer fewer curves. Clamping keeps a plot on
|
||||
// the grid; the alternative is a curve row that vanishes, which reads
|
||||
// as the tone curve having disappeared from the panel.
|
||||
let graph = EditGraph::default_chain();
|
||||
let caps = graph.capabilities();
|
||||
let rows = rows_filtered(&caps, |_| true, 99);
|
||||
let row = rows
|
||||
.iter()
|
||||
.find(|r| r.kind == "curve")
|
||||
.expect("the curve still has a row");
|
||||
assert_eq!(
|
||||
row.param_index,
|
||||
((curve::CHANNELS - 1) * curve::POINTS * 2) as i32
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_operation_whose_points_are_unfaceted_is_one_curve() {
|
||||
// A curve widget that spans a single unnamed curve — which is what
|
||||
// this operation was before the channels arrived, and what any other
|
||||
// node declaring a `tone_curve` widget over ten scalars would be.
|
||||
// It must draw, and it must offer no choice.
|
||||
use dr_pipeline::{LocalizedKey, ParamCapability, WidgetDemand};
|
||||
use slint::Model as _;
|
||||
|
||||
static IDS: [ParamId; 4] = [
|
||||
ParamId("p0_x"),
|
||||
ParamId("p0_y"),
|
||||
ParamId("p1_x"),
|
||||
ParamId("p1_y"),
|
||||
];
|
||||
let param = |id: ParamId| ParamCapability {
|
||||
id,
|
||||
label: LocalizedKey("param.point"),
|
||||
kind: ParamKind::Scalar {
|
||||
min: 0.0,
|
||||
max: 1.0,
|
||||
scale: dr_pipeline::Scale::Linear,
|
||||
unit: Unit::None,
|
||||
precision: 4,
|
||||
},
|
||||
default: 0.0,
|
||||
value: 0.0,
|
||||
facet: None,
|
||||
};
|
||||
let plain = OpCapability {
|
||||
id: OpId("invented_curve"),
|
||||
label: LocalizedKey("op.invented_curve"),
|
||||
active: false,
|
||||
presentation: Some(Presentation {
|
||||
widgets: vec![WidgetKind::ToneCurve],
|
||||
demand: WidgetDemand {
|
||||
two_dimensional: true,
|
||||
precise_pointing: true,
|
||||
},
|
||||
params: IDS.to_vec(),
|
||||
}),
|
||||
params: IDS.iter().map(|id| param(*id)).collect(),
|
||||
attributes: vec![dr_pipeline::Attribute::Tone],
|
||||
};
|
||||
|
||||
let presentation = plain.presentation.as_ref().expect("declares a widget");
|
||||
let runs = curve_runs(&plain, presentation).expect("curve-shaped");
|
||||
assert_eq!(runs.len(), 1, "one unnamed curve");
|
||||
assert_eq!(runs[0].subject, None);
|
||||
|
||||
let rows = rows_from(&[plain]);
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!(rows[0].kind, "curve");
|
||||
assert_eq!(rows[0].points.row_count(), IDS.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curve_samples_start_on_the_diagonal() {
|
||||
// A fresh curve is the identity, so the drawn line must be the 45°
|
||||
// diagonal — anything else means the widget opens showing a shape
|
||||
// the image does not have.
|
||||
let mut xs = [0.0f32; curve::POINTS];
|
||||
let mut ys = [0.0f32; curve::POINTS];
|
||||
for i in 0..curve::POINTS {
|
||||
let t = i as f32 / (curve::POINTS - 1) as f32;
|
||||
xs[i] = t;
|
||||
ys[i] = t;
|
||||
}
|
||||
for i in 0..=20 {
|
||||
let x = i as f32 / 20.0;
|
||||
let y = curve::evaluate(&xs, &ys, x);
|
||||
assert!((y - x).abs() < 1e-4, "at {x} the identity gave {y}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sorting_enforces_a_minimum_gap() {
|
||||
// Two points dragged onto each other would divide by zero in the
|
||||
// spline; the drawn curve must survive it exactly as the shader does.
|
||||
let mut xs = [0.5, 0.5, 0.5, 0.5, 0.5];
|
||||
sort_with_gap(&mut xs);
|
||||
for i in 1..xs.len() {
|
||||
assert!(xs[i] > xs[i - 1], "not separated: {xs:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sorting_orders_reversed_points() {
|
||||
let mut xs = [0.9, 0.7, 0.5, 0.3, 0.1];
|
||||
sort_with_gap(&mut xs);
|
||||
for i in 1..xs.len() {
|
||||
assert!(xs[i] > xs[i - 1], "not sorted: {xs:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user