2237 lines
91 KiB
Rust
2237 lines
91 KiB
Rust
//! The develop session — capabilities in, rendered image out.
|
||
//!
|
||
//! This is the only place the UI touches the pipeline, and it does so through
|
||
//! two calls: [`dr_pipeline::EditGraph::capabilities`] to learn what controls
|
||
//! to build, and `set_param` to change one. It never names an operation, and
|
||
//! it knows nothing about shaders.
|
||
//!
|
||
//! Whether a control is a slider or a switch follows from the parameter's
|
||
//! declared [`ParamKind`], not from which parameter it is (ARCH §4.3), so a
|
||
//! new operation appears in the panel with no change here (FR-DEV-3c).
|
||
|
||
use dr_decode::RawImage;
|
||
use dr_gpu::{AdjustPass, DemosaicedImage, Demosaicer, GpuContext, Histogram, HistogramPass};
|
||
use dr_pipeline::ops::curve;
|
||
use dr_pipeline::{
|
||
CropRect, Edit, EditGraph, History, OpCapability, OpId, ParamId, ParamKind, Presentation,
|
||
Preset, Scope, Unit, WidgetKind,
|
||
};
|
||
|
||
use crate::labels;
|
||
use crate::ParamRow;
|
||
|
||
/// A loaded image plus its edit state.
|
||
pub struct DevelopSession {
|
||
graph: EditGraph,
|
||
/// TRACES: FR-DEV-5
|
||
/// Undo, kept beside the graph rather than in the window.
|
||
///
|
||
/// Every mutator below records into it, so a caller cannot change the edit
|
||
/// and forget to. That is the whole reason it lives here: the callbacks in
|
||
/// `lib.rs` are generic by construction and there are a dozen of them, and
|
||
/// a history the *call sites* had to remember would be one press of undo
|
||
/// away from wrong every time a control is added.
|
||
history: History,
|
||
demosaiced: DemosaicedImage,
|
||
adjust: AdjustPass,
|
||
/// TRACES: FR-DSP-7
|
||
/// Optional, because a session that cannot count its frames is still a
|
||
/// session that can develop them. If the reduction fails to build — an
|
||
/// old driver, a device without the storage-buffer atomics it needs — the
|
||
/// photographer loses the histogram and keeps the photograph.
|
||
histogram: Option<HistogramPass>,
|
||
}
|
||
|
||
impl DevelopSession {
|
||
/// Demosaic an image and prepare its edit graph.
|
||
///
|
||
/// `orientation` is the file's EXIF orientation, not an edit: a sensor is
|
||
/// scanned the same way whichever way the body was held, so this is what
|
||
/// makes a portrait frame open upright. It is fixed for the life of the
|
||
/// session and survives a reset.
|
||
pub fn open(
|
||
ctx: &GpuContext,
|
||
raw: &RawImage,
|
||
orientation: dr_types::Orientation,
|
||
) -> Result<Self, String> {
|
||
let demosaicer = Demosaicer::new(ctx).map_err(|e| e.to_string())?;
|
||
let demosaiced = demosaicer.run(raw).map_err(|e| e.to_string())?;
|
||
Ok(Self::with_source(ctx, demosaiced, orientation))
|
||
}
|
||
|
||
/// Prepare an edit graph over an already-processed RGB image.
|
||
///
|
||
/// The JPEG path. A JPEG is already demosaiced, so there is no sensor
|
||
/// stage to run — but everything after it is identical, which is why this
|
||
/// shares [`Self::with_source`] rather than duplicating the session.
|
||
///
|
||
/// Worth being honest about what this cannot recover: an 8-bit JPEG has
|
||
/// clipped highlights and quantised shadows that no edit brings back, so
|
||
/// exposure has far less latitude here than on sensor data. The controls
|
||
/// are the same controls; the file simply carries less to work with.
|
||
pub fn open_rgb(
|
||
ctx: &GpuContext,
|
||
rgba: &[u8],
|
||
width: u32,
|
||
height: u32,
|
||
orientation: dr_types::Orientation,
|
||
) -> Result<Self, String> {
|
||
let source =
|
||
DemosaicedImage::from_rgba8(ctx, rgba, width, height).map_err(|e| e.to_string())?;
|
||
Ok(Self::with_source(ctx, source, orientation))
|
||
}
|
||
|
||
fn with_source(
|
||
ctx: &GpuContext,
|
||
demosaiced: DemosaicedImage,
|
||
orientation: dr_types::Orientation,
|
||
) -> Self {
|
||
let mut graph = EditGraph::default_chain();
|
||
graph.set_orientation(orientation);
|
||
let history = History::new(&graph);
|
||
Self {
|
||
graph,
|
||
history,
|
||
demosaiced,
|
||
adjust: AdjustPass::new(ctx),
|
||
histogram: HistogramPass::new(ctx)
|
||
.inspect_err(|e| log::warn!("no histogram on this device: {e}"))
|
||
.ok(),
|
||
}
|
||
}
|
||
|
||
/// The controls the interface should show.
|
||
///
|
||
/// Built entirely from the capability list. The `kind` string chooses the
|
||
/// widget; nothing switches on a parameter's identity.
|
||
pub fn rows(&self) -> Vec<ParamRow> {
|
||
rows_from(&self.graph.capabilities())
|
||
}
|
||
}
|
||
|
||
// The empty nested models, each a single shared identity.
|
||
//
|
||
// **`ModelRc` compares by identity, not by contents**, and `sync_rows` decides
|
||
// which controls to invalidate by comparing each freshly built row against the
|
||
// one on screen. A brand-new empty model per row per call therefore makes every
|
||
// row differ from *itself* on every parameter event, and the panel rewrites all
|
||
// of them.
|
||
//
|
||
// That is not merely wasteful — it breaks dragging. An operation with several
|
||
// parameters renders them through a repeater whose model is read off the
|
||
// group's head row; rewriting that row re-evaluates the repeater, rebuilding
|
||
// its items and destroying the `TouchArea` that holds the gesture. The slider
|
||
// takes the press, jumps once, then goes dead under the finger. Only
|
||
// multi-parameter operations show it, because a lone parameter has no inner
|
||
// repeater to rebuild — which is exactly how it hid: exposure and contrast drag
|
||
// perfectly while temperature and tint do not.
|
||
//
|
||
// Most rows carry neither points nor choices, so the empty case is the common
|
||
// one and it costs nothing to make it a constant.
|
||
|
||
/// The empty points model, shared by every row that is not a curve.
|
||
fn no_points() -> slint::ModelRc<f32> {
|
||
thread_local! {
|
||
static EMPTY: slint::ModelRc<f32> =
|
||
slint::ModelRc::new(slint::VecModel::from(Vec::<f32>::new()));
|
||
}
|
||
EMPTY.with(Clone::clone)
|
||
}
|
||
|
||
/// The empty choices model, shared by every row that is not an enum.
|
||
fn no_choices() -> slint::ModelRc<slint::SharedString> {
|
||
thread_local! {
|
||
static EMPTY: slint::ModelRc<slint::SharedString> =
|
||
slint::ModelRc::new(slint::VecModel::from(Vec::<slint::SharedString>::new()));
|
||
}
|
||
EMPTY.with(Clone::clone)
|
||
}
|
||
|
||
/// Whether this frontend has an implementation of `widget` **anywhere**.
|
||
///
|
||
/// "Anywhere" is doing real work: a widget may be drawn in the panel, as the
|
||
/// tone curve is, or hosted on the canvas, as the crop is. Both count as
|
||
/// implemented, and the difference is settled afterwards by
|
||
/// [`WidgetKind::is_on_canvas`] rather than by two separate lists that could
|
||
/// disagree about the same kind.
|
||
///
|
||
/// A kind answering `false` here is not an error — the operation's parameters
|
||
/// are ordinary scalars, so it falls back to sliders and stays fully editable
|
||
/// (ARCH §4.3a).
|
||
pub(crate) fn supported(widget: WidgetKind) -> bool {
|
||
match widget {
|
||
// Drawn in the panel.
|
||
WidgetKind::ToneCurve => true,
|
||
// Hosted on the canvas: the overlay is drawn over the photograph and
|
||
// the panel contributes `GeometryPanel`, the affordance that turns it
|
||
// on.
|
||
WidgetKind::CropOverlay => true,
|
||
// Not implemented. Listed rather than caught by a wildcard so the next
|
||
// kind added to the core surfaces here as a compile error.
|
||
WidgetKind::ColourWheel
|
||
| WidgetKind::GradientHandle
|
||
| WidgetKind::BrushMask
|
||
| WidgetKind::WhitePoint => false,
|
||
}
|
||
}
|
||
|
||
/// The panel model for a set of capabilities.
|
||
///
|
||
/// Free-standing rather than a method, and that is the point: it needs no GPU,
|
||
/// no decoded image and no session, so the whole descriptor-to-panel path can
|
||
/// be exercised against a hand-built capability list. That is what the
|
||
/// FR-DEV-3c acceptance test asks for — an operation the frontend has never
|
||
/// heard of appearing in a generated panel — and it cannot be asserted at all
|
||
/// if generating a row requires a device.
|
||
pub(crate) fn rows_from(caps: &[OpCapability]) -> Vec<ParamRow> {
|
||
let mut rows = Vec::new();
|
||
for (op_index, op) in caps.iter().enumerate() {
|
||
// 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.
|
||
let group_head = rows.len();
|
||
// An operation may ask for one widget spanning several
|
||
// parameters. Honouring it is optional — dropping this block
|
||
// renders the same parameters as ordinary sliders, and the edit
|
||
// still works — which is exactly why the hint is a hint.
|
||
if let Some(presentation) = &op.presentation {
|
||
// **The widget registry, and the only one.**
|
||
//
|
||
// `choose` walks the operation's preference list and hands back
|
||
// the first entry this frontend implements (ARCH §4.3a). A kind
|
||
// it does not implement falls through to sliders — the designed
|
||
// behaviour, not a gap, since every parameter is an individually
|
||
// addressable scalar.
|
||
if let Some(widget) = presentation.choose(supported) {
|
||
// **Yielded to the canvas, and this is what replaced naming
|
||
// framing.**
|
||
//
|
||
// This loop used to open with `if op.id == framing::ID { continue }`
|
||
// and a paragraph explaining that a crop is dragged on the
|
||
// photograph rather than typed into four boxes. All of that is
|
||
// true and none of it was this file's to know: it is a fact
|
||
// about the operation, and it now arrives as one. Any stage
|
||
// preferring an on-canvas widget is skipped here on the same
|
||
// terms, with nothing named.
|
||
//
|
||
// Skipped rather than rendered as an affordance row, because
|
||
// the affordance is `GeometryPanel` — a bespoke control for a
|
||
// known stage, which is a thing the interface is entitled to
|
||
// build (ARCH §4.3a draws the line at the *generated* panel
|
||
// naming stages, not at the interface having hand-made
|
||
// widgets).
|
||
if widget.is_on_canvas() {
|
||
continue;
|
||
}
|
||
|
||
// The `match` is exhaustive on purpose. Adding a `WidgetKind`
|
||
// to the core stops this compiling until someone has decided,
|
||
// here, whether the panel draws it.
|
||
let row = match widget {
|
||
WidgetKind::ToneCurve => curve_row(op_index, group_head, op, presentation),
|
||
// Canvas-hosted kinds returned above; the rest are not
|
||
// implemented and reached sliders via `choose`.
|
||
WidgetKind::ColourWheel
|
||
| WidgetKind::CropOverlay
|
||
| WidgetKind::GradientHandle
|
||
| WidgetKind::BrushMask
|
||
| WidgetKind::WhitePoint => None,
|
||
};
|
||
if let Some(row) = row {
|
||
rows.push(row);
|
||
continue;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Whether anything in this operation has been touched, aggregated
|
||
// before the rows are built so every row of the group can carry
|
||
// the same answer — the panel's heading is one of them and cannot
|
||
// see the others.
|
||
//
|
||
// Derived here rather than asked of the core: a group is a
|
||
// composition this side invented, so whether one is modified is
|
||
// this side's question to answer (ARCH §4.3a).
|
||
let group_modified = op.params.iter().any(|p| p.value != p.default);
|
||
let group_len = op.params.len() as i32;
|
||
|
||
// The aspect the previous row belonged to, so a run can be told
|
||
// from its continuation. Reset per operation: two operations that
|
||
// happened to facet on the same key are still two groups.
|
||
let mut previous_aspect: Option<&str> = None;
|
||
|
||
for param_index in presentation_order(&op.params) {
|
||
let p = &op.params[param_index];
|
||
// Empty for every kind but `Enum`, which is what the panel
|
||
// keys on to build a segmented control rather than a slider.
|
||
let mut choices: Vec<slint::SharedString> = Vec::new();
|
||
let (kind, min, max, precision, unit) = match &p.kind {
|
||
ParamKind::Scalar {
|
||
min,
|
||
max,
|
||
unit,
|
||
precision,
|
||
..
|
||
} => (
|
||
"scalar",
|
||
*min,
|
||
*max,
|
||
i32::from(*precision),
|
||
unit_suffix(*unit),
|
||
),
|
||
ParamKind::Bool => ("bool", 0.0, 1.0, 0, ""),
|
||
// The value is a variant index, so the range is the list's
|
||
// own bounds and the precision is whole numbers. Labels are
|
||
// resolved here, against this crate's catalogue, because
|
||
// the core deals in localisation keys only (NFR-A11Y-1).
|
||
ParamKind::Enum { variants } => {
|
||
choices = variants
|
||
.iter()
|
||
.map(|v| labels::resolve(v.0).into())
|
||
.collect();
|
||
("enum", 0.0, variants.len().saturating_sub(1) as f32, 0, "")
|
||
}
|
||
};
|
||
|
||
// A faceted parameter is named by its *subject* — the band —
|
||
// because its aspect is already written above the run it sits
|
||
// in. Unfaceted parameters keep their own label, which is
|
||
// every operation but the mixer.
|
||
let param_label = match &p.facet {
|
||
Some(f) => labels::resolve(f.subject.0),
|
||
None => labels::resolve(p.label.0),
|
||
};
|
||
let aspect = p.facet.as_ref().map(|f| f.aspect.0);
|
||
let starts_facet = aspect.is_some() && aspect != previous_aspect;
|
||
previous_aspect = aspect;
|
||
|
||
rows.push(ParamRow {
|
||
op_index: op_index as i32,
|
||
param_index: param_index as i32,
|
||
op_label: labels::resolve(op.label.0).into(),
|
||
param_label: param_label.into(),
|
||
facet_label: aspect.map(labels::resolve).unwrap_or_default().into(),
|
||
starts_facet,
|
||
// -1 rather than an `Option`, which a Slint struct cannot
|
||
// carry: 0° is red, so no value in range can stand for
|
||
// "no swatch".
|
||
swatch_hue: p.facet.as_ref().and_then(|f| f.subject_hue).unwrap_or(-1.0),
|
||
group_head: group_head as i32,
|
||
group_len,
|
||
group_modified,
|
||
kind: kind.into(),
|
||
value: p.value,
|
||
default_value: p.default,
|
||
minimum: min,
|
||
maximum: max,
|
||
precision,
|
||
unit: unit.into(),
|
||
// Only curve rows carry points.
|
||
points: no_points(),
|
||
// The shared empty model unless this row really has choices —
|
||
// see `no_choices` for why the identity matters.
|
||
choices: if choices.is_empty() {
|
||
no_choices()
|
||
} else {
|
||
slint::ModelRc::new(slint::VecModel::from(choices))
|
||
},
|
||
});
|
||
}
|
||
}
|
||
rows
|
||
}
|
||
|
||
/// One row standing for a whole curve.
|
||
///
|
||
/// 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.
|
||
fn curve_row(
|
||
op_index: usize,
|
||
group_head: usize,
|
||
op: &OpCapability,
|
||
presentation: &Presentation,
|
||
) -> Option<ParamRow> {
|
||
// 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;
|
||
}
|
||
|
||
// The widget addresses points by offset from the first, so they must
|
||
// be contiguous in the capability list.
|
||
let base = op
|
||
.params
|
||
.iter()
|
||
.position(|p| p.id == presentation.params[0])?;
|
||
for (i, id) in presentation.params.iter().enumerate() {
|
||
if op.params.get(base + i).map(|p| p.id) != Some(*id) {
|
||
log::warn!("{}: curve parameters are not contiguous", op.id);
|
||
return None;
|
||
}
|
||
}
|
||
|
||
let points: Vec<f32> = presentation
|
||
.params
|
||
.iter()
|
||
.filter_map(|id| op.params.iter().find(|p| p.id == *id))
|
||
.map(|p| p.value)
|
||
.collect();
|
||
|
||
Some(ParamRow {
|
||
op_index: op_index as i32,
|
||
// The first point parameter; the widget offsets from here.
|
||
param_index: 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),
|
||
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.
|
||
choices: no_choices(),
|
||
})
|
||
}
|
||
|
||
impl DevelopSession {
|
||
/// The 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.
|
||
pub fn curve_samples(&self) -> Vec<f32> {
|
||
const SAMPLES: usize = 96;
|
||
|
||
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;
|
||
for (i, p) in cap.params.iter().enumerate() {
|
||
let point = i / 2;
|
||
if point >= curve::POINTS {
|
||
break;
|
||
}
|
||
if i % 2 == 0 {
|
||
xs[point] = p.value;
|
||
} else {
|
||
ys[point] = p.value;
|
||
}
|
||
}
|
||
}
|
||
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()
|
||
}
|
||
|
||
/// Return every parameter of one operation to its default.
|
||
///
|
||
/// What both a section's reset and a curve's reset do — a curve is one
|
||
/// widget spanning all of its operation's parameters, so "reset this
|
||
/// curve" and "reset this operation" were always the same action. Nothing
|
||
/// here is curve-shaped; it walks whatever parameters the operation
|
||
/// declares.
|
||
pub fn reset_op(&mut self, op_index: i32) {
|
||
let caps = self.graph.capabilities();
|
||
let Some(cap) = usize::try_from(op_index).ok().and_then(|i| caps.get(i)) else {
|
||
return;
|
||
};
|
||
for p in &cap.params {
|
||
self.graph.set_param(cap.id, p.id, p.default);
|
||
}
|
||
// One step, though it moved every parameter the operation has: the
|
||
// user pressed one button.
|
||
self.history.record(&self.graph, Edit::Discrete);
|
||
}
|
||
|
||
/// Reset a curve, which is to reset its operation.
|
||
///
|
||
/// Kept as its own name because the call site is a curve widget's own
|
||
/// double-click, and reading `reset_curve` there says why it resets ten
|
||
/// parameters at once rather than the one that was clicked.
|
||
pub fn reset_curve(&mut self, op_index: i32) {
|
||
self.reset_op(op_index);
|
||
}
|
||
|
||
/// Apply a change from the interface.
|
||
///
|
||
/// Indices are positions in [`Self::rows`]; the mapping back to ids stays
|
||
/// on this side of the boundary.
|
||
pub fn set_param(&mut self, op_index: i32, param_index: i32, value: f32) {
|
||
let Some((op, param)) = self.lookup(op_index, param_index) else {
|
||
log::warn!("control at ({op_index}, {param_index}) has no parameter");
|
||
return;
|
||
};
|
||
self.graph.set_param(op, param, value);
|
||
let edit = Edit::for_param(&self.graph, op, param);
|
||
self.history.record(&self.graph, edit);
|
||
}
|
||
|
||
/// Return one parameter to its default.
|
||
pub fn reset_param(&mut self, op_index: i32, param_index: i32) {
|
||
let Some((op, param)) = self.lookup(op_index, param_index) else {
|
||
return;
|
||
};
|
||
let default = self
|
||
.graph
|
||
.capabilities()
|
||
.iter()
|
||
.find(|c| c.id == op)
|
||
.and_then(|c| c.params.iter().find(|p| p.id == param))
|
||
.map(|p| p.default)
|
||
.unwrap_or(0.0);
|
||
self.graph.set_param(op, param, default);
|
||
self.history.record(&self.graph, Edit::Discrete);
|
||
}
|
||
|
||
pub fn reset_all(&mut self) {
|
||
self.graph.reset();
|
||
self.history.record(&self.graph, Edit::Discrete);
|
||
}
|
||
|
||
fn lookup(&self, op_index: i32, param_index: i32) -> Option<(OpId, ParamId)> {
|
||
// Rows are emitted in capability order, so the flat index is the sum
|
||
// of preceding parameter counts.
|
||
let caps = self.graph.capabilities();
|
||
let op = caps.get(usize::try_from(op_index).ok()?)?;
|
||
let param = op.params.get(usize::try_from(param_index).ok()?)?;
|
||
Some((op.id, param.id))
|
||
}
|
||
|
||
/// TRACES: FR-DSP-1 | AC-8
|
||
/// Render at the requested display size and hand back a Slint image.
|
||
///
|
||
/// Renders at *viewport* resolution rather than sensor resolution, which
|
||
/// is what keeps slider interaction inside the frame budget on a 24 MP
|
||
/// file (FR-DSP-1).
|
||
///
|
||
/// **The image is the texture, not a copy of it.** This used to end in a
|
||
/// `read_output` into a `SharedPixelBuffer` — the GPU→CPU→GPU round-trip
|
||
/// ARCH §6.1 forbids and AC-8 asserts against, measured at ~7 ms at 4K
|
||
/// against a 0.28 ms compute pass. Spike S1 replaced it with
|
||
/// `slint::Image::try_from`, which wraps the texture where it already is.
|
||
/// The `clone` below is a refcount on the wgpu handle, not on the pixels.
|
||
///
|
||
/// This only works because the compositor is drawing with the same device
|
||
/// the pass wrote with; see `shared_gpu` in the crate root for how that is
|
||
/// arranged, and note that nothing here can detect it having gone wrong —
|
||
/// a texture from a foreign device is a runtime fault on a real screen,
|
||
/// which is why the arrangement is made once at startup and never again.
|
||
pub fn render(&mut self, width: u32, height: u32) -> Result<slint::Image, String> {
|
||
// Fit the render to the viewport while preserving aspect, so the
|
||
// pass does no work on pixels the view will letterbox away.
|
||
//
|
||
// Fitted against the *framed* size, not the sensor's: a crop changes
|
||
// the aspect ratio, and fitting the uncropped shape would letterbox
|
||
// to the wrong box and render the crop squashed.
|
||
let (sw, sh) = self.demosaiced.size();
|
||
let (fw, fh) = self.graph.output_size(sw, sh);
|
||
let (w, h) = fit(fw, fh, width.max(1), height.max(1));
|
||
|
||
let shader = self.graph.compose();
|
||
let texture = self
|
||
.adjust
|
||
.render(&self.demosaiced, &shader, w, h)
|
||
.map_err(|e| e.to_string())?;
|
||
|
||
// The import is fallible on format and usage only, and both are fixed
|
||
// in `AdjustPass`'s texture descriptor — so a failure here is a
|
||
// descriptor that drifted, not anything the caller did. Say that,
|
||
// rather than surfacing "InvalidUsage" to a photographer.
|
||
slint::Image::try_from(texture.clone())
|
||
.map_err(|e| format!("the render target is not importable by the compositor: {e}"))
|
||
}
|
||
|
||
/// TRACES: FR-DSP-7
|
||
/// Count the frame that is currently on the canvas.
|
||
///
|
||
/// **Reads the frame [`Self::render`] last produced rather than rendering
|
||
/// its own.** The histogram has to describe what the photographer is
|
||
/// looking at, and rendering a second time to count it would both cost a
|
||
/// second pass and open the possibility of the two disagreeing.
|
||
///
|
||
/// That the frame is the *displayed* one has two consequences worth being
|
||
/// explicit about. It is in the output colour space, which is what
|
||
/// FR-DSP-7 asks for — the levels counted are the levels the display will
|
||
/// show, so a clipped bin means a highlight that is actually gone rather
|
||
/// than one the transform might still recover. And when the view is zoomed
|
||
/// or cropped it describes the visible region, not the whole file: a
|
||
/// photographer inspecting a highlight at 4× is asking about *that*
|
||
/// highlight, and a histogram of the parts of the frame off screen would
|
||
/// be answering a question nobody asked.
|
||
///
|
||
/// `None` where nothing has been rendered yet, or where the device could
|
||
/// not build the reduction.
|
||
pub fn histogram(&self) -> Option<Histogram> {
|
||
let pass = self.histogram.as_ref()?;
|
||
let frame = self.adjust.output()?;
|
||
pass.compute(frame)
|
||
.inspect_err(|e| log::warn!("histogram failed: {e}"))
|
||
.ok()
|
||
}
|
||
|
||
/// Render the *whole* frame for the crop overlay to be drawn over.
|
||
///
|
||
/// Crop mode cannot use [`Self::render`]: that applies the crop, so the
|
||
/// area being cropped away would not be on screen and there would be
|
||
/// nothing to drag the handles across. This renders as though the crop
|
||
/// were full, and the interface draws the rect and greys the surround.
|
||
///
|
||
/// Zoom is suspended too. Panning a zoomed view while also dragging crop
|
||
/// handles is two conflicting meanings for one drag, and the handles are
|
||
/// placed against the whole frame in any case.
|
||
///
|
||
/// Returns the image together with the size it was rendered at, since the
|
||
/// overlay has to place its rect against exactly those pixels.
|
||
pub fn render_uncropped(
|
||
&mut self,
|
||
width: u32,
|
||
height: u32,
|
||
) -> Result<(slint::Image, u32, u32), String> {
|
||
let saved_crop = self.graph.crop();
|
||
let saved_view = self.graph.framing().view();
|
||
|
||
self.graph.set_crop(CropRect::default());
|
||
self.graph.framing_mut().set_view(CropRect::default());
|
||
|
||
let result = self.render(width, height);
|
||
|
||
// Restored whatever happened: leaving the graph cropped-to-full on a
|
||
// render error would silently discard the user's crop.
|
||
self.graph.set_crop(saved_crop);
|
||
self.graph.framing_mut().set_view(saved_view);
|
||
|
||
let image = result?;
|
||
let (sw, sh) = self.demosaiced.size();
|
||
// The uncropped frame still turns with the quarter turns, so the
|
||
// overlay's box comes from the framing rather than the sensor.
|
||
let (fw, fh) = self.graph.framing().output_size_uncropped(sw, sh);
|
||
let (rw, rh) = fit(fw, fh, width.max(1), height.max(1));
|
||
Ok((image, rw, rh))
|
||
}
|
||
|
||
/// The displayed size, for sizing the viewport.
|
||
///
|
||
/// The *framed* size, not the sensor's: cropping and quarter turns change
|
||
/// the aspect ratio, and a viewport sized to the sensor would letterbox a
|
||
/// cropped image against the wrong shape.
|
||
pub fn source_size(&self) -> (u32, u32) {
|
||
let (w, h) = self.demosaiced.size();
|
||
self.graph.output_size(w, h)
|
||
}
|
||
|
||
/// TRACES: FR-EXP-9
|
||
/// Render at full resolution and hand back the pixels, for an export.
|
||
///
|
||
/// **Not the frame on screen.** [`Self::render`] deliberately renders at
|
||
/// viewport size, which is what keeps a slider inside the frame budget on
|
||
/// a 24 MP file (FR-DSP-1) — and what would make an export of it a soft,
|
||
/// screen-sized file. This renders the framed output size instead, so the
|
||
/// export is the full-quality path FR-EXP-9 requires.
|
||
///
|
||
/// This reads pixels back and [`Self::render`] does not, and that is the
|
||
/// whole distinction AC-8 draws: a file is made of bytes on the CPU and
|
||
/// there is no path to one that avoids the transfer, whereas a frame on
|
||
/// screen had no business making the trip. See `AdjustPass::export_pixels`
|
||
/// for the longer version.
|
||
///
|
||
/// Leaves one of the pass's two targets at full resolution; it is dropped
|
||
/// and reallocated on the second display render after this, since the
|
||
/// other target still holds a viewport-sized texture and comes up first.
|
||
/// Cheaper than keeping a second pass alive for the exports a session
|
||
/// rarely performs.
|
||
///
|
||
/// `space` is the output colour space the file will claim. It is chosen
|
||
/// here rather than at encode time because the conversion happens in the
|
||
/// shader, before the clip to 0..1 — by the time pixels reach the encoder
|
||
/// they are in exactly one space, and the only honest thing left to do is
|
||
/// label them. Asking for the wrong one is a typed error rather than a
|
||
/// mislabelled file (FR-EXP-2).
|
||
pub fn render_for_export(
|
||
&mut self,
|
||
space: dr_types::ColourSpace,
|
||
) -> Result<dr_export::Frame, String> {
|
||
let (sw, sh) = self.demosaiced.size();
|
||
let (w, h) = self.graph.output_size(sw, sh);
|
||
|
||
let shader = self.graph.compose_for(space);
|
||
self.adjust
|
||
.render(&self.demosaiced, &shader, w, h)
|
||
.map_err(|e| e.to_string())?;
|
||
|
||
let (pixels, rw, rh) = self.adjust.export_pixels().map_err(|e| e.to_string())?;
|
||
dr_export::Frame::in_space(rw, rh, pixels, space).map_err(|e| e.to_string())
|
||
}
|
||
|
||
/// The sensor's own dimensions, before framing.
|
||
///
|
||
/// What a crop overlay needs: its handles are placed against the full
|
||
/// frame, since that is what the user is selecting *from*.
|
||
pub fn sensor_size(&self) -> (u32, u32) {
|
||
self.demosaiced.size()
|
||
}
|
||
|
||
/// Whether one source pixel now covers more than one screen pixel.
|
||
///
|
||
/// The question the interface asks to decide how the canvas is *filtered*,
|
||
/// not how it is rendered. Below 1:1 there are more source pixels than
|
||
/// screen pixels and smoothing is what stops the image aliasing; past it
|
||
/// there is no more detail to show, and smoothing only invents values
|
||
/// between real ones — at which point a photographer inspecting focus or
|
||
/// noise wants to see the pixels, not a blur of them.
|
||
///
|
||
/// Measured against the visible region rather than the zoom factor alone,
|
||
/// because the two differ: a 24 MP file in a 1200px viewport is still
|
||
/// showing five sensor pixels per screen pixel at 4×, while a small JPEG is
|
||
/// already magnified at 1×.
|
||
pub fn magnifies_source(&self, viewport_w: u32, viewport_h: u32) -> bool {
|
||
let (sw, sh) = self.demosaiced.size();
|
||
let (fw, fh) = self.graph.output_size(sw, sh);
|
||
let (rw, rh) = fit(fw, fh, viewport_w.max(1), viewport_h.max(1));
|
||
|
||
// How many source pixels lie behind the render target: the framed
|
||
// image narrowed to the region the view selects. The target keeps its
|
||
// size while that region shrinks, which is what raises the ratio.
|
||
let view = self.graph.framing().view();
|
||
let behind_w = f64::from(fw) * f64::from(view.width.max(f32::EPSILON));
|
||
let behind_h = f64::from(fh) * f64::from(view.height.max(f32::EPSILON));
|
||
|
||
// Strictly greater, with a margin: at exactly 1:1 either filter gives
|
||
// the same answer, and flipping mode on a rounding error would make the
|
||
// canvas visibly change character mid-scroll.
|
||
f64::from(rw) > behind_w * 1.001 && f64::from(rh) > behind_h * 1.001
|
||
}
|
||
|
||
/// Set the crop rectangle, in fractions of the source.
|
||
pub fn set_crop(&mut self, rect: CropRect) {
|
||
self.graph.set_crop(rect);
|
||
// Keyed on the operation, not on a parameter: one drag of one handle
|
||
// moves the origin and the extent together.
|
||
self.history
|
||
.record(&self.graph, Edit::Op(dr_pipeline::framing::ID));
|
||
}
|
||
|
||
pub fn crop(&self) -> CropRect {
|
||
self.graph.crop()
|
||
}
|
||
|
||
/// Rotate by quarter turns, wrapping. The rotate-left/right buttons.
|
||
///
|
||
/// The crop travels with the frame rather than staying where it was on
|
||
/// screen. A crop is a decision about *this part of the photograph*, and
|
||
/// leaving the rect in place while the image turns under it would move the
|
||
/// selection onto a different part of the picture — so the rect is turned
|
||
/// by the same quarter and the composition survives the rotation.
|
||
pub fn rotate_quarters(&mut self, turns: i32) {
|
||
let crop = self.graph.crop();
|
||
if !crop.is_full() {
|
||
self.graph.set_crop(rotate_crop(crop, turns));
|
||
}
|
||
self.graph.rotate_quarters(turns);
|
||
self.history.record(&self.graph, Edit::Discrete);
|
||
}
|
||
|
||
/// Straightening, in degrees. Positive turns the image clockwise.
|
||
pub fn angle(&self) -> f32 {
|
||
self.graph.framing().angle()
|
||
}
|
||
|
||
/// Quarter turns clockwise, 0..=3 — for the panel's readout.
|
||
pub fn quarter_turns(&self) -> u8 {
|
||
self.graph.framing().quarter_turns()
|
||
}
|
||
|
||
pub fn flips(&self) -> (bool, bool) {
|
||
self.graph.framing().flips()
|
||
}
|
||
|
||
/// Mirror horizontally, about the frame's vertical centre line.
|
||
pub fn toggle_flip_h(&mut self) {
|
||
let (h, _) = self.graph.framing().flips();
|
||
self.graph.set_param(
|
||
dr_pipeline::framing::ID,
|
||
dr_pipeline::framing::FLIP_H,
|
||
f32::from(u8::from(!h)),
|
||
);
|
||
self.history.record(&self.graph, Edit::Discrete);
|
||
}
|
||
|
||
pub fn toggle_flip_v(&mut self) {
|
||
let (_, v) = self.graph.framing().flips();
|
||
self.graph.set_param(
|
||
dr_pipeline::framing::ID,
|
||
dr_pipeline::framing::FLIP_V,
|
||
f32::from(u8::from(!v)),
|
||
);
|
||
self.history.record(&self.graph, Edit::Discrete);
|
||
}
|
||
|
||
/// Set the straightening angle, in degrees.
|
||
pub fn set_angle(&mut self, degrees: f32) {
|
||
self.graph.set_param(
|
||
dr_pipeline::framing::ID,
|
||
dr_pipeline::framing::ANGLE,
|
||
degrees,
|
||
);
|
||
self.history.record(
|
||
&self.graph,
|
||
Edit::Param(dr_pipeline::framing::ID, dr_pipeline::framing::ANGLE),
|
||
);
|
||
}
|
||
|
||
/// Whether the framing currently changes the image — what lights the
|
||
/// section's modified dot and enables its reset.
|
||
///
|
||
/// Asks whether it *edits*, not whether it is active: a zoomed view makes
|
||
/// the framing active without changing the photograph, and a section that
|
||
/// claimed an edit because the user scrolled would be lying.
|
||
pub fn framing_edits_image(&self) -> bool {
|
||
self.graph.framing().edits_image()
|
||
}
|
||
|
||
/// Return crop, straightening, rotation and flips to neutral, leaving
|
||
/// every colour adjustment alone.
|
||
///
|
||
/// The zoom is deliberately preserved: it is a viewing state, and resetting
|
||
/// the framing is an edit, so throwing away where the user was looking
|
||
/// would be an unrelated second effect.
|
||
pub fn reset_framing(&mut self) {
|
||
let view = self.graph.framing().view();
|
||
self.graph.framing_mut().reset();
|
||
self.graph.framing_mut().set_view(view);
|
||
self.history.record(&self.graph, Edit::Discrete);
|
||
}
|
||
|
||
/// How far the viewport is zoomed in: 1.0 fits the frame, 4.0 is 4×.
|
||
pub fn zoom(&self) -> f32 {
|
||
let v = self.graph.framing().view();
|
||
if v.width <= 0.0 {
|
||
1.0
|
||
} else {
|
||
1.0 / v.width
|
||
}
|
||
}
|
||
|
||
pub fn is_zoomed(&self) -> bool {
|
||
self.graph.framing().is_zoomed()
|
||
}
|
||
|
||
/// Zoom about a point, given in fractions of the *visible* area.
|
||
///
|
||
/// Anchoring matters: zooming about the pointer keeps whatever is under
|
||
/// it stationary, which is what makes a scroll-wheel zoom feel like it is
|
||
/// magnifying the photograph rather than sliding it around.
|
||
///
|
||
/// `factor` multiplies the current zoom — above 1 moves in.
|
||
pub fn zoom_about(&mut self, factor: f32, at_x: f32, at_y: f32) {
|
||
const MAX_ZOOM: f32 = 16.0;
|
||
|
||
let view = self.graph.framing().view();
|
||
let current = if view.width > 0.0 {
|
||
1.0 / view.width
|
||
} else {
|
||
1.0
|
||
};
|
||
let target = (current * factor).clamp(1.0, MAX_ZOOM);
|
||
// Snapped so scrolling back out reliably reaches "fit" rather than
|
||
// stopping a fraction short and leaving the image imperceptibly
|
||
// panned.
|
||
let target = if (target - 1.0).abs() < 0.01 {
|
||
1.0
|
||
} else {
|
||
target
|
||
};
|
||
|
||
let extent = (1.0 / target).clamp(CropRect::MIN_EXTENT, 1.0);
|
||
|
||
// The point under the cursor, in framed coordinates, must land back
|
||
// under the cursor afterwards.
|
||
let anchor_x = view.x + at_x.clamp(0.0, 1.0) * view.width;
|
||
let anchor_y = view.y + at_y.clamp(0.0, 1.0) * view.height;
|
||
|
||
self.set_view_clamped(
|
||
anchor_x - at_x.clamp(0.0, 1.0) * extent,
|
||
anchor_y - at_y.clamp(0.0, 1.0) * extent,
|
||
extent,
|
||
);
|
||
}
|
||
|
||
/// Pan by a fraction of the *visible* area — what a drag reports.
|
||
pub fn pan_by(&mut self, dx: f32, dy: f32) {
|
||
let view = self.graph.framing().view();
|
||
self.set_view_clamped(
|
||
view.x + dx * view.width,
|
||
view.y + dy * view.height,
|
||
view.width,
|
||
);
|
||
}
|
||
|
||
/// Back to fitting the whole frame.
|
||
pub fn reset_zoom(&mut self) {
|
||
self.graph.framing_mut().set_view(CropRect::default());
|
||
}
|
||
|
||
/// Place a square view of `extent`, keeping it inside the frame.
|
||
///
|
||
/// Clamped rather than allowed to run off the edge: panning past the
|
||
/// boundary would show undefined area beside the photograph, which reads
|
||
/// as a rendering fault rather than as the end of the image.
|
||
fn set_view_clamped(&mut self, x: f32, y: f32, extent: f32) {
|
||
let extent = extent.clamp(CropRect::MIN_EXTENT, 1.0);
|
||
let max = 1.0 - extent;
|
||
self.graph.framing_mut().set_view(CropRect {
|
||
x: x.clamp(0.0, max.max(0.0)),
|
||
y: y.clamp(0.0, max.max(0.0)),
|
||
width: extent,
|
||
height: extent,
|
||
});
|
||
}
|
||
|
||
/// The largest centred crop that, at the current straightening angle,
|
||
/// contains no undefined area. What a "straighten and fill" action
|
||
/// applies.
|
||
pub fn max_inscribed_crop(&self) -> CropRect {
|
||
let (w, h) = self.demosaiced.size();
|
||
self.graph.framing().max_inscribed_crop(w, h)
|
||
}
|
||
|
||
/// How many shader pipelines have been compiled. Surfaced so the status
|
||
/// strip can show that slider movement is not recompiling.
|
||
pub fn compiled_pipelines(&self) -> usize {
|
||
self.adjust.cached_pipelines()
|
||
}
|
||
|
||
pub fn is_neutral(&self) -> bool {
|
||
self.graph.is_neutral()
|
||
}
|
||
|
||
/// TRACES: FR-DEV-6
|
||
/// Lift this session's edit onto the clipboard.
|
||
///
|
||
/// Captured at full scope — framing included — because the decision about
|
||
/// what travels is made when the preset is *applied*. Copying, then
|
||
/// changing one's mind about the crop, must not mean copying again.
|
||
pub fn copy_settings(&self) -> Preset {
|
||
Preset::capture(&self.graph)
|
||
}
|
||
|
||
/// TRACES: FR-DEV-6
|
||
/// Replace this session's edit within `scope`.
|
||
///
|
||
/// The panel must be rebuilt from [`Self::rows`] afterwards: a paste moves
|
||
/// values the sliders are showing, and nothing here pushes them.
|
||
pub fn apply_settings(&mut self, preset: &Preset, scope: Scope) {
|
||
preset.apply(&mut self.graph, scope);
|
||
// A paste is undoable, and is the action most in need of it: it
|
||
// replaces everything in scope at once, so getting it wrong costs more
|
||
// than any single control can.
|
||
self.history.record(&self.graph, Edit::Discrete);
|
||
}
|
||
|
||
/// TRACES: FR-CAT-8
|
||
/// Load a stored edit, as read from this image's sidecar.
|
||
///
|
||
/// A replacement rather than an overlay — [`Version::apply`] resets first —
|
||
/// so a version that stores nothing opens the photograph at its defaults
|
||
/// rather than leaving the previous image's exposure standing. The file's
|
||
/// orientation survives it, since that was never an edit.
|
||
pub fn apply_version(&mut self, version: &dr_pipeline::Version) {
|
||
version.apply(&mut self.graph);
|
||
// The stored edit becomes the floor rather than a step. It is not
|
||
// something the user did in this sitting, and an undo that reached
|
||
// behind it would discard a previous session's work in one press —
|
||
// then persist that on the way out, since saving is automatic.
|
||
self.history.reset(&self.graph);
|
||
}
|
||
|
||
/// TRACES: FR-DEV-5
|
||
/// Step the edit back one, returning whether anything moved.
|
||
///
|
||
/// The panel must be rebuilt from [`Self::rows`] afterwards, for the same
|
||
/// reason a paste must: this moves values the controls are showing and
|
||
/// nothing here pushes them.
|
||
pub fn undo(&mut self) -> bool {
|
||
self.history.undo(&mut self.graph)
|
||
}
|
||
|
||
/// TRACES: FR-DEV-5
|
||
/// Step the edit forward one, returning whether anything moved.
|
||
pub fn redo(&mut self) -> bool {
|
||
self.history.redo(&mut self.graph)
|
||
}
|
||
|
||
pub fn can_undo(&self) -> bool {
|
||
self.history.can_undo()
|
||
}
|
||
|
||
pub fn can_redo(&self) -> bool {
|
||
self.history.can_redo()
|
||
}
|
||
}
|
||
|
||
/// Re-express a crop rect after the frame it is measured against turns.
|
||
///
|
||
/// The crop lives in fractions of the *framed* image — the one the quarter
|
||
/// turns have already produced — so turning the frame another quarter leaves
|
||
/// the rect describing the wrong region unless it turns with it. Without this,
|
||
/// rotating a portrait crop on a landscape photograph slides the selection
|
||
/// onto a different part of the picture, which reads as the rotation having
|
||
/// moved the image rather than the frame.
|
||
///
|
||
/// One clockwise quarter takes `(x, y)` to `(1 - y - h, x)` and exchanges the
|
||
/// extents; anticlockwise is the same map run the other way. Applied
|
||
/// `turns.rem_euclid(4)` times so the caller's wrapping and this agree.
|
||
fn rotate_crop(rect: CropRect, turns: i32) -> CropRect {
|
||
let mut r = rect;
|
||
for _ in 0..turns.rem_euclid(4) {
|
||
r = CropRect {
|
||
x: 1.0 - r.y - r.height,
|
||
y: r.x,
|
||
width: r.height,
|
||
height: r.width,
|
||
};
|
||
}
|
||
r.normalised()
|
||
}
|
||
|
||
/// 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;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Largest size fitting `(sw, sh)` inside `(max_w, max_h)`, preserving aspect.
|
||
///
|
||
/// Rendering to the letterboxed size rather than the full viewport avoids
|
||
/// shading pixels the view will not show, which at a 3:2 image in a 16:9
|
||
/// window is a fifth of them.
|
||
fn fit(sw: u32, sh: u32, max_w: u32, max_h: u32) -> (u32, u32) {
|
||
if sw == 0 || sh == 0 {
|
||
return (max_w, max_h);
|
||
}
|
||
let scale = (max_w as f32 / sw as f32).min(max_h as f32 / sh as f32);
|
||
// Never upscale past the source: there is no detail to recover, and a
|
||
// 1:1 render is cheaper.
|
||
let scale = scale.min(1.0);
|
||
(
|
||
((sw as f32 * scale).round() as u32).max(1),
|
||
((sh as f32 * scale).round() as u32).max(1),
|
||
)
|
||
}
|
||
|
||
/// The order an operation's parameters are shown in.
|
||
///
|
||
/// Declaration order, unless the operation facets them — in which case
|
||
/// parameters sharing an aspect are brought together, so the panel names
|
||
/// each run once instead of repeating "Hue / Saturation / Luminance"
|
||
/// twelve times over. The colour mixer declares band by band, which is the
|
||
/// order the shader wants; a photographer works channel by channel.
|
||
///
|
||
/// **This is presentation, and so it lives here** (ARCH §4.3a). The core
|
||
/// says which aspect a parameter belongs to; deciding that an aspect is
|
||
/// worth stacking rows by is the panel's composition to make, exactly as
|
||
/// grouping by operation is. Routing is unaffected — `param_index` stays
|
||
/// the position in the capability list however the rows are stacked.
|
||
///
|
||
/// A stable sort by the aspect's first appearance, so an operation with no
|
||
/// facets comes back untouched, and one that mixes plain parameters with
|
||
/// faceted ones keeps the plain ones first and in order.
|
||
fn presentation_order(params: &[dr_pipeline::ParamCapability]) -> Vec<usize> {
|
||
let mut aspects: Vec<&str> = Vec::new();
|
||
let rank: Vec<usize> = params
|
||
.iter()
|
||
.map(|p| match &p.facet {
|
||
None => 0,
|
||
Some(f) => {
|
||
let at = aspects.iter().position(|a| *a == f.aspect.0);
|
||
// First appearance defines the run's place, so the panel's
|
||
// sections come out in the order the operation introduced
|
||
// them rather than alphabetically.
|
||
1 + at.unwrap_or_else(|| {
|
||
aspects.push(f.aspect.0);
|
||
aspects.len() - 1
|
||
})
|
||
}
|
||
})
|
||
.collect();
|
||
|
||
let mut order: Vec<usize> = (0..params.len()).collect();
|
||
order.sort_by_key(|i| rank[*i]);
|
||
order
|
||
}
|
||
|
||
/// Suffix shown after a value. Comes from the descriptor's declared unit, so
|
||
/// this function needs no knowledge of which parameter it is formatting.
|
||
fn unit_suffix(unit: Unit) -> &'static str {
|
||
match unit {
|
||
Unit::None => "",
|
||
Unit::Stops => " EV",
|
||
Unit::Kelvin => " K",
|
||
Unit::Percent => "%",
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use dr_pipeline::EditGraph;
|
||
|
||
/// TRACES: FR-DSP-1 | AC-8
|
||
/// Copy a displayed frame back to the CPU, for assertions and nothing else.
|
||
///
|
||
/// The library has no such function on purpose: S1 removed the display
|
||
/// readback, and AC-8 is the assertion that it stayed removed. A test that
|
||
/// wants to look at the pixels therefore has to do the copy itself, which
|
||
/// is exactly the right shape — the round-trip lives in the test binary
|
||
/// and cannot be reached from a shipping one.
|
||
///
|
||
/// Doubles as the proof: this only compiles because the image *is* a wgpu
|
||
/// texture. Hand it a `SharedPixelBuffer`-backed image and it panics.
|
||
fn read_back(ctx: &GpuContext, image: &slint::Image) -> Vec<u8> {
|
||
let texture = image
|
||
.to_wgpu_29_texture()
|
||
.expect("the develop canvas must be a GPU texture, not a pixel buffer");
|
||
let (w, h) = (texture.width(), texture.height());
|
||
|
||
// Buffer rows must be aligned to COPY_BYTES_PER_ROW_ALIGNMENT.
|
||
let unpadded = w * 4;
|
||
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
|
||
let padded = unpadded.div_ceil(align) * align;
|
||
|
||
let buf = ctx.device.create_buffer(&wgpu::BufferDescriptor {
|
||
label: Some("test-readback"),
|
||
size: u64::from(padded * h),
|
||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||
mapped_at_creation: false,
|
||
});
|
||
|
||
let mut enc = ctx.device.create_command_encoder(&Default::default());
|
||
enc.copy_texture_to_buffer(
|
||
wgpu::TexelCopyTextureInfo {
|
||
texture: &texture,
|
||
mip_level: 0,
|
||
origin: wgpu::Origin3d::ZERO,
|
||
aspect: wgpu::TextureAspect::All,
|
||
},
|
||
wgpu::TexelCopyBufferInfo {
|
||
buffer: &buf,
|
||
layout: wgpu::TexelCopyBufferLayout {
|
||
offset: 0,
|
||
bytes_per_row: Some(padded),
|
||
rows_per_image: Some(h),
|
||
},
|
||
},
|
||
wgpu::Extent3d {
|
||
width: w,
|
||
height: h,
|
||
depth_or_array_layers: 1,
|
||
},
|
||
);
|
||
ctx.queue.submit(Some(enc.finish()));
|
||
|
||
let slice = buf.slice(..);
|
||
let (tx, rx) = std::sync::mpsc::channel();
|
||
slice.map_async(wgpu::MapMode::Read, move |r| {
|
||
let _ = tx.send(r);
|
||
});
|
||
ctx.device
|
||
.poll(wgpu::PollType::wait_indefinitely())
|
||
.expect("poll");
|
||
rx.recv().expect("map").expect("map");
|
||
|
||
let data = slice.get_mapped_range();
|
||
let mut out = Vec::with_capacity((unpadded * h) as usize);
|
||
for row in 0..h {
|
||
let start = (row * padded) as usize;
|
||
out.extend_from_slice(&data[start..start + unpadded as usize]);
|
||
}
|
||
drop(data);
|
||
buf.unmap();
|
||
out
|
||
}
|
||
|
||
/// TRACES: FR-DSP-1 | AC-8
|
||
#[test]
|
||
fn the_displayed_frame_is_a_texture_and_not_a_pixel_buffer() {
|
||
// The acceptance criterion itself, asserted from the side that would
|
||
// notice it regressing. `to_rgba8` returning `Some` would mean the
|
||
// frame had come back through system memory to be looked at, which is
|
||
// the ~7 ms per frame at 4K that ARCH §6.1 forbids; `to_wgpu_29_texture`
|
||
// returning `Some` means the compositor got the texture where it lay.
|
||
//
|
||
// Note this passes without a display: the import is a wrapper, and it
|
||
// is the *compositor* adopting the device that needs a screen. What
|
||
// cannot be proved here is that the picture arrives; what can be
|
||
// proved is that no copy was made on the way.
|
||
let Ok(ctx) = pollster::block_on(dr_gpu::GpuContext::new_headless()) else {
|
||
log::warn!("no GPU adapter; skipping");
|
||
return;
|
||
};
|
||
|
||
let rgba = vec![128u8; 32 * 32 * 4];
|
||
let mut session =
|
||
DevelopSession::open_rgb(&ctx, &rgba, 32, 32, dr_types::Orientation::NORMAL)
|
||
.expect("session");
|
||
let frame = session.render(32, 32).expect("render");
|
||
|
||
assert!(
|
||
frame.to_rgba8().is_none(),
|
||
"the canvas has CPU pixels, so something copied them there"
|
||
);
|
||
let texture = frame
|
||
.to_wgpu_29_texture()
|
||
.expect("the canvas is neither a texture nor a pixel buffer");
|
||
assert_eq!((texture.width(), texture.height()), (32, 32));
|
||
}
|
||
|
||
/// TRACES: FR-DSP-1 | AC-8
|
||
#[test]
|
||
fn consecutive_frames_look_different_to_the_property_system() {
|
||
// The catch that comes free with handing over a texture instead of a
|
||
// buffer. Slint repaints when the image property *changes*, and it
|
||
// decides that with `PartialEq` — which for two images over one
|
||
// `wgpu::Texture` says "unchanged". A pass that reused a single target
|
||
// would therefore render every slider move correctly and show none of
|
||
// them.
|
||
//
|
||
// `AdjustPass` alternates between two targets to prevent it. This
|
||
// asserts the consequence in the terms Slint actually uses, so it
|
||
// would still catch the regression if the mechanism were replaced.
|
||
let Ok(ctx) = pollster::block_on(dr_gpu::GpuContext::new_headless()) else {
|
||
log::warn!("no GPU adapter; skipping");
|
||
return;
|
||
};
|
||
|
||
let rgba = vec![128u8; 32 * 32 * 4];
|
||
let mut session =
|
||
DevelopSession::open_rgb(&ctx, &rgba, 32, 32, dr_types::Orientation::NORMAL)
|
||
.expect("session");
|
||
|
||
let first = session.render(32, 32).expect("first render");
|
||
let second = session.render(32, 32).expect("second render");
|
||
assert_ne!(
|
||
first, second,
|
||
"the canvas property would not change, so the frame would never be shown"
|
||
);
|
||
}
|
||
|
||
/// The whole scroll-to-zoom path, end to end, in the order the user drives
|
||
/// it: show the image fitted, *then* turn the wheel.
|
||
///
|
||
/// The lower layers each had zoom tests and each passed while this was
|
||
/// broken, because every one of them set a view before its first render.
|
||
/// That ordering hid the bug — a neutral framing compiles a prologue that
|
||
/// never reads the crop rect, and while zoom was absent from the structure
|
||
/// hash that pipeline stayed cached once zoomed. The session reported the
|
||
/// new zoom, the uniforms carried the new view, and the pixels never moved.
|
||
///
|
||
/// So this asserts on the rendered pixels rather than on `zoom()`: the
|
||
/// symptom was precisely that the state was right and the image was not.
|
||
#[test]
|
||
fn zooming_after_a_fitted_render_changes_the_pixels() {
|
||
let Ok(ctx) = pollster::block_on(dr_gpu::GpuContext::new_headless()) else {
|
||
log::warn!("no GPU adapter; skipping");
|
||
return;
|
||
};
|
||
|
||
// A gradient, so any change in the sampled region moves the pixels.
|
||
let (w, h) = (64u32, 64u32);
|
||
let mut rgba = Vec::with_capacity((w * h * 4) as usize);
|
||
for y in 0..h {
|
||
for x in 0..w {
|
||
rgba.extend_from_slice(&[(x * 4) as u8, (y * 4) as u8, 128, 255]);
|
||
}
|
||
}
|
||
let mut session =
|
||
DevelopSession::open_rgb(&ctx, &rgba, w, h, dr_types::Orientation::NORMAL)
|
||
.expect("session");
|
||
|
||
let fitted = session.render(64, 64).expect("fitted render");
|
||
session.zoom_about(4.0, 0.5, 0.5);
|
||
assert!(session.is_zoomed(), "the session did not register the zoom");
|
||
let zoomed = session.render(64, 64).expect("zoomed render");
|
||
|
||
// Both images are still readable here because consecutive frames go to
|
||
// alternating textures; see `AdjustPass::targets`. Holding two frames
|
||
// at once would be meaningless against a single reused target.
|
||
let before = read_back(&ctx, &fitted);
|
||
let after = read_back(&ctx, &zoomed);
|
||
let differing = before
|
||
.iter()
|
||
.zip(after.iter())
|
||
.filter(|(a, b)| a != b)
|
||
.count();
|
||
|
||
assert!(
|
||
differing > 0,
|
||
"zooming 4x after a fitted render produced identical pixels — the \
|
||
view reached the session but not the shader"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn magnification_follows_the_source_resolution_and_not_the_zoom_factor() {
|
||
// What decides whether the canvas is filtered. The distinction this
|
||
// guards is the reason the interface cannot answer it from `zoom()`
|
||
// alone: the same 4x on a large source is still showing more source
|
||
// pixels than screen pixels, while on a small one it is already
|
||
// inventing values between them.
|
||
let Ok(ctx) = pollster::block_on(dr_gpu::GpuContext::new_headless()) else {
|
||
log::warn!("no GPU adapter; skipping");
|
||
return;
|
||
};
|
||
|
||
// Bigger than the viewport it is shown in: `fit` scales it down, so
|
||
// every screen pixel still has several source pixels behind it.
|
||
let big = vec![128u8; (800 * 800 * 4) as usize];
|
||
let mut session =
|
||
DevelopSession::open_rgb(&ctx, &big, 800, 800, dr_types::Orientation::NORMAL)
|
||
.expect("session");
|
||
assert!(
|
||
!session.magnifies_source(200, 200),
|
||
"a downscaled image is not magnified"
|
||
);
|
||
session.zoom_about(2.0, 0.5, 0.5);
|
||
assert!(
|
||
!session.magnifies_source(200, 200),
|
||
"2x on a 4x-downscaled source is still below 1:1"
|
||
);
|
||
session.zoom_about(8.0, 0.5, 0.5);
|
||
assert!(
|
||
session.magnifies_source(200, 200),
|
||
"16x on a 4x-downscaled source magnifies and must not be filtered"
|
||
);
|
||
|
||
// Smaller than the viewport: `fit` refuses to upscale, so the render is
|
||
// 1:1 and unzoomed is exactly the boundary — not past it.
|
||
let small = vec![128u8; (100 * 100 * 4) as usize];
|
||
let mut session =
|
||
DevelopSession::open_rgb(&ctx, &small, 100, 100, dr_types::Orientation::NORMAL)
|
||
.expect("session");
|
||
assert!(
|
||
!session.magnifies_source(800, 800),
|
||
"1:1 is the boundary, not past it — filtering must not flip on a \
|
||
rounding error"
|
||
);
|
||
session.zoom_about(2.0, 0.5, 0.5);
|
||
assert!(
|
||
session.magnifies_source(800, 800),
|
||
"any zoom past a 1:1 render magnifies"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn every_capability_becomes_exactly_one_row() {
|
||
// The UI shows what the pipeline offers — no more, and nothing
|
||
// dropped. Asserted against the chain rather than a literal count,
|
||
// so operations can be added without editing this, and so the test
|
||
// actually checks the correspondence rather than restating a number.
|
||
let graph = EditGraph::default_chain();
|
||
let caps = graph.capabilities();
|
||
let expected: usize = caps.iter().map(|c| c.params.len()).sum();
|
||
|
||
assert!(expected > 0, "the chain must expose some parameters");
|
||
// Every (operation, parameter) pair must be reachable as a distinct
|
||
// row index; a collision would route two sliders to one parameter.
|
||
let mut seen = std::collections::HashSet::new();
|
||
for (oi, cap) in caps.iter().enumerate() {
|
||
for (pi, _) in cap.params.iter().enumerate() {
|
||
assert!(seen.insert((oi, pi)), "duplicate row index");
|
||
}
|
||
}
|
||
assert_eq!(seen.len(), expected);
|
||
}
|
||
|
||
#[test]
|
||
fn each_operation_becomes_exactly_one_group() {
|
||
// The panel draws one section per group, and derives the boundary
|
||
// from `group_head` rather than from a flag the core supplies. Two
|
||
// heads for one operation would draw its heading twice; none would
|
||
// swallow the operation into the section above it.
|
||
let graph = EditGraph::default_chain();
|
||
let caps = graph.capabilities();
|
||
|
||
// A row heads its group exactly when its own index equals its
|
||
// `group_head` — the same test `adjust.slint` makes.
|
||
let mut heads = 0;
|
||
for (i, row) in rows_of(&caps).iter().enumerate() {
|
||
if row.0 == i {
|
||
heads += 1;
|
||
}
|
||
}
|
||
// Every operation but framing, which has its own panel.
|
||
let generated = caps
|
||
.iter()
|
||
.filter(|c| c.id != dr_pipeline::framing::ID)
|
||
.count();
|
||
assert_eq!(heads, generated);
|
||
}
|
||
|
||
#[test]
|
||
fn regenerating_the_rows_leaves_unchanged_ones_equal() {
|
||
// **This is a dragging test wearing a data disguise.**
|
||
//
|
||
// `sync_rows` rewrites exactly the rows that compare unequal, and a
|
||
// rewritten row re-evaluates the repeater that a multi-parameter
|
||
// operation renders its parameters through — which rebuilds the items
|
||
// and destroys the `TouchArea` mid-gesture. So a row that differs from
|
||
// itself between two identical calls is a slider that takes the press,
|
||
// jumps once and then dies under the finger.
|
||
//
|
||
// It is asserted here rather than left to the eye because the failure
|
||
// is invisible in a still: every value is right, the panel looks
|
||
// perfect, and only a live drag on a *grouped* parameter shows it.
|
||
// `ModelRc` compares by identity, so any new model-valued field
|
||
// reintroduces this the moment it is built fresh per call.
|
||
let graph = EditGraph::default_chain();
|
||
let caps = graph.capabilities();
|
||
|
||
let first = rows_from(&caps);
|
||
let second = rows_from(&caps);
|
||
assert_eq!(first.len(), second.len());
|
||
|
||
for (i, (a, b)) in first.iter().zip(second.iter()).enumerate() {
|
||
// A curve row is the one legitimate exception: its `points` model
|
||
// carries live coordinates, so it genuinely is rebuilt each call
|
||
// and `sync_rows` writes the values through the existing model
|
||
// instead of swapping it. Every other row must be stable here, at
|
||
// the source, rather than relying on a caller to repair it.
|
||
if a.kind == "curve" {
|
||
continue;
|
||
}
|
||
assert!(
|
||
a == b,
|
||
"row {i} ({}) differs from itself across two identical builds, \
|
||
so every parameter event would rewrite it and break dragging",
|
||
a.param_label
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_grouped_parameter_survives_a_neighbours_change() {
|
||
// The reported bug, at the level it actually occurred. Moving
|
||
// temperature flips `group_modified` on *both* of white balance's
|
||
// rows — that much is correct and intended. What must not happen is
|
||
// the untouched rows of *other* operations also coming back unequal,
|
||
// because rewriting a group's head row is what rebuilds the repeater
|
||
// holding the live drag.
|
||
let mut graph = EditGraph::default_chain();
|
||
let before = rows_from(&graph.capabilities());
|
||
|
||
// Move the first parameter of the first multi-parameter operation,
|
||
// named by shape rather than by id so this keeps testing the property
|
||
// when the chain changes.
|
||
let caps = graph.capabilities();
|
||
let group = caps
|
||
.iter()
|
||
.find(|c| c.params.len() > 1 && c.presentation.is_none())
|
||
.expect("some operation has several plain parameters");
|
||
let target = &group.params[0];
|
||
graph.set_param(group.id, target.id, target.default + 1.0);
|
||
|
||
let after = rows_from(&graph.capabilities());
|
||
assert_eq!(before.len(), after.len());
|
||
|
||
// Curve rows excluded for the reason given in the test above: their
|
||
// points model is rebuilt by design and repaired in `sync_rows`.
|
||
let changed: Vec<&str> = before
|
||
.iter()
|
||
.zip(after.iter())
|
||
.filter(|(a, b)| a != b && a.kind != "curve")
|
||
.map(|(a, _)| a.param_label.as_str())
|
||
.collect();
|
||
|
||
// Its own group, and nothing beyond it.
|
||
assert_eq!(
|
||
changed.len(),
|
||
group.params.len(),
|
||
"moving one parameter should dirty only its own group's rows, \
|
||
but these came back changed: {changed:?}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn framing_is_not_generated_as_sliders() {
|
||
// `GeometryPanel` presents crop, rotation, flips and straightening as
|
||
// the gestures they are. If the generic path emitted them too the
|
||
// sidebar would carry both — including four "Crop Left/Top/Width/
|
||
// Height" sliders no one can compose a photograph with.
|
||
let graph = EditGraph::default_chain();
|
||
let caps = graph.capabilities();
|
||
|
||
let framing = caps
|
||
.iter()
|
||
.position(|c| c.id == dr_pipeline::framing::ID)
|
||
.expect("the chain must still expose framing — the panel reads it");
|
||
assert!(!caps[framing].params.is_empty());
|
||
|
||
// Checked against the real generator, and by *routing* rather than by
|
||
// counting: a row carries the capability index it writes back to, so
|
||
// "no row belongs to framing" is the property directly, and it cannot
|
||
// be satisfied accidentally by two miscounts cancelling out.
|
||
let rows = rows_from(&caps);
|
||
assert!(
|
||
rows.iter().all(|r| r.op_index as usize != framing),
|
||
"framing parameters leaked into the generated panel"
|
||
);
|
||
// Every other operation still arrives, so the skip is specific rather
|
||
// than the panel having quietly stopped generating.
|
||
assert!(rows.len() > caps.len() - 1);
|
||
}
|
||
|
||
#[test]
|
||
fn a_stage_is_yielded_to_the_canvas_by_what_it_declares_not_by_its_name() {
|
||
// The property that replaced `if op.id == framing::ID`. An invented
|
||
// stage preferring an on-canvas widget must be skipped on exactly the
|
||
// same terms — if this needs a name added anywhere to pass, the
|
||
// special case has grown back.
|
||
use dr_pipeline::{LocalizedKey, ParamCapability, WidgetDemand};
|
||
|
||
let param = |id: &'static str| ParamCapability {
|
||
id: ParamId(id),
|
||
label: LocalizedKey("param.invented"),
|
||
kind: ParamKind::Scalar {
|
||
min: 0.0,
|
||
max: 1.0,
|
||
scale: dr_pipeline::Scale::Linear,
|
||
unit: Unit::None,
|
||
precision: 2,
|
||
},
|
||
default: 0.0,
|
||
value: 0.0,
|
||
facet: None,
|
||
};
|
||
|
||
let on_canvas = OpCapability {
|
||
id: OpId("invented_mask"),
|
||
label: LocalizedKey("op.invented_mask"),
|
||
active: false,
|
||
presentation: Some(Presentation {
|
||
// Prefers a gradient handle; this frontend has none, so it
|
||
// falls back to the next entry, which the canvas does host.
|
||
widgets: &[WidgetKind::GradientHandle, WidgetKind::CropOverlay],
|
||
demand: WidgetDemand {
|
||
two_dimensional: true,
|
||
precise_pointing: false,
|
||
},
|
||
params: &[ParamId("a"), ParamId("b")],
|
||
}),
|
||
params: vec![param("a"), param("b")],
|
||
};
|
||
|
||
assert!(rows_from(&[on_canvas]).is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn a_group_spans_exactly_its_operations_rows() {
|
||
// `group_len` is how many rows the section reaches forward over. Too
|
||
// few silently drops controls off the bottom of a section; too many
|
||
// reads past the model and renders a neighbouring operation's
|
||
// parameters under the wrong heading.
|
||
let graph = EditGraph::default_chain();
|
||
let caps = graph.capabilities();
|
||
let rows = rows_of(&caps);
|
||
|
||
for (i, row) in rows.iter().enumerate() {
|
||
let (head, len) = *row;
|
||
assert!(head <= i, "row {i} claims a head after itself");
|
||
assert!(
|
||
head + len <= rows.len(),
|
||
"group at {head} reaches past the model"
|
||
);
|
||
// Every row the group spans must agree it belongs to that group.
|
||
for (offset, spanned) in rows[head..head + len].iter().enumerate() {
|
||
let span = head + offset;
|
||
assert_eq!(spanned.0, head, "row {span} disagrees about its group");
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_group_is_modified_when_any_of_its_parameters_is() {
|
||
// The dot on a collapsed section is the only thing saying an edit is
|
||
// hidden inside it, and it is derived here rather than asked of the
|
||
// core (ARCH §4.3a).
|
||
let mut graph = EditGraph::default_chain();
|
||
let caps = graph.capabilities();
|
||
// A fresh chain is at its defaults, so nothing is modified.
|
||
assert!(
|
||
caps.iter()
|
||
.all(|c| c.params.iter().all(|p| p.value == p.default)),
|
||
"a fresh chain must start neutral"
|
||
);
|
||
|
||
// Move one parameter of one operation off its default; only that
|
||
// operation's group may light up.
|
||
let (op_id, param_id, default) = caps
|
||
.iter()
|
||
.find_map(|c| {
|
||
c.params
|
||
.iter()
|
||
.find(|p| matches!(p.kind, ParamKind::Scalar { .. }))
|
||
.map(|p| (c.id, p.id, p.default))
|
||
})
|
||
.expect("the chain has a scalar parameter");
|
||
graph.set_param(op_id, param_id, default + 1.0);
|
||
|
||
let caps = graph.capabilities();
|
||
let modified: Vec<bool> = caps
|
||
.iter()
|
||
.map(|c| c.params.iter().any(|p| p.value != p.default))
|
||
.collect();
|
||
assert_eq!(
|
||
modified.iter().filter(|m| **m).count(),
|
||
1,
|
||
"one edit must mark exactly one group"
|
||
);
|
||
|
||
// And it goes out again when the value returns.
|
||
graph.set_param(op_id, param_id, default);
|
||
assert!(
|
||
graph
|
||
.capabilities()
|
||
.iter()
|
||
.all(|c| c.params.iter().all(|p| p.value == p.default)),
|
||
"returning a value to its default must clear the group"
|
||
);
|
||
}
|
||
|
||
/// `(group_head, group_len)` per row, flattened as
|
||
/// [`DevelopSession::rows`] flattens — without needing a GPU to build a
|
||
/// session.
|
||
///
|
||
/// A widget hint only collapses an operation to one row when it is
|
||
/// *honoured*; `rows` falls back to sliders otherwise, and mirroring that
|
||
/// here is what keeps the test honest when a hint stops applying.
|
||
/// TRACES: FR-DEV-3c
|
||
/// An operation this file has never heard of, appearing in the panel.
|
||
///
|
||
/// The acceptance test requirements.md names for FR-DEV-3c: "a test
|
||
/// operation added to the registry appears in a generated panel with no
|
||
/// frontend change". Built as a capability rather than a real node so it
|
||
/// costs the pipeline nothing — what is being asserted is the mapping from
|
||
/// descriptor to control, and that mapping does not care whether a shader
|
||
/// exists behind it.
|
||
#[test]
|
||
fn an_operation_the_frontend_has_never_heard_of_gets_controls() {
|
||
use dr_pipeline::{LocalizedKey, ParamCapability};
|
||
|
||
let invented = OpCapability {
|
||
id: OpId("invented"),
|
||
label: LocalizedKey("op.invented"),
|
||
active: false,
|
||
presentation: None,
|
||
params: vec![
|
||
ParamCapability {
|
||
id: ParamId("strength"),
|
||
label: LocalizedKey("param.invented.strength"),
|
||
kind: ParamKind::Scalar {
|
||
min: -100.0,
|
||
max: 100.0,
|
||
scale: dr_pipeline::Scale::Linear,
|
||
unit: Unit::Percent,
|
||
precision: 0,
|
||
},
|
||
default: 0.0,
|
||
value: 25.0,
|
||
facet: None,
|
||
},
|
||
ParamCapability {
|
||
id: ParamId("method"),
|
||
label: LocalizedKey("param.invented.method"),
|
||
kind: ParamKind::Enum {
|
||
variants: &[
|
||
LocalizedKey("param.invented.method.fast"),
|
||
LocalizedKey("param.invented.method.exact"),
|
||
],
|
||
},
|
||
default: 0.0,
|
||
value: 1.0,
|
||
facet: None,
|
||
},
|
||
],
|
||
};
|
||
|
||
let rows = rows_from(&[invented]);
|
||
assert_eq!(rows.len(), 2, "each parameter should become one row");
|
||
|
||
// The scalar becomes a slider carrying its declared range and unit.
|
||
assert_eq!(rows[0].kind, "scalar");
|
||
assert_eq!(rows[0].minimum, -100.0);
|
||
assert_eq!(rows[0].maximum, 100.0);
|
||
assert_eq!(rows[0].value, 25.0);
|
||
|
||
// The enum becomes a choice, with its range spanning the variant
|
||
// indices and the variant names resolved for drawing. Nothing in this
|
||
// file names the operation or either parameter to make that happen.
|
||
assert_eq!(rows[1].kind, "enum");
|
||
assert_eq!(rows[1].minimum, 0.0);
|
||
assert_eq!(rows[1].maximum, 1.0);
|
||
assert_eq!(rows[1].precision, 0);
|
||
assert_eq!(slint::Model::row_count(&rows[1].choices), 2);
|
||
// The value is the selected index, which is what the segmented control
|
||
// reads — an enum needs no separate selection field.
|
||
assert_eq!(rows[1].value, 1.0);
|
||
}
|
||
|
||
#[test]
|
||
fn an_unimplemented_widget_falls_back_to_sliders_rather_than_vanishing() {
|
||
// ARCH §4.3a: falling off the end of the preference list is not an
|
||
// error. An operation asking only for a widget this frontend does not
|
||
// draw must still yield one control per parameter, or declaring a
|
||
// preference would be a way to make an edit unreachable.
|
||
use dr_pipeline::{LocalizedKey, ParamCapability, WidgetDemand};
|
||
|
||
let wheel = OpCapability {
|
||
id: OpId("grading"),
|
||
label: LocalizedKey("op.grading"),
|
||
active: false,
|
||
presentation: Some(Presentation {
|
||
widgets: &[WidgetKind::ColourWheel],
|
||
demand: WidgetDemand {
|
||
two_dimensional: true,
|
||
precise_pointing: false,
|
||
},
|
||
params: &[ParamId("hue"), ParamId("strength")],
|
||
}),
|
||
params: vec![
|
||
ParamCapability {
|
||
id: ParamId("hue"),
|
||
label: LocalizedKey("param.grading.hue"),
|
||
kind: ParamKind::Scalar {
|
||
min: 0.0,
|
||
max: 360.0,
|
||
scale: dr_pipeline::Scale::Linear,
|
||
unit: Unit::None,
|
||
precision: 0,
|
||
},
|
||
default: 0.0,
|
||
value: 0.0,
|
||
facet: None,
|
||
},
|
||
ParamCapability {
|
||
id: ParamId("strength"),
|
||
label: LocalizedKey("param.grading.strength"),
|
||
kind: ParamKind::Scalar {
|
||
min: 0.0,
|
||
max: 1.0,
|
||
scale: dr_pipeline::Scale::Linear,
|
||
unit: Unit::None,
|
||
precision: 2,
|
||
},
|
||
default: 0.0,
|
||
value: 0.0,
|
||
facet: None,
|
||
},
|
||
],
|
||
};
|
||
|
||
assert!(!supported(WidgetKind::ColourWheel), "precondition");
|
||
let rows = rows_from(&[wheel]);
|
||
assert_eq!(rows.len(), 2, "both parameters must remain reachable");
|
||
assert!(rows.iter().all(|r| r.kind == "scalar"));
|
||
}
|
||
|
||
/// Each generated row's `(group_head, group_len)`.
|
||
///
|
||
/// Taken from the real generator rather than re-derived. This used to be a
|
||
/// hand-written simulation of `rows_from` — it walked the capabilities and
|
||
/// reproduced the grouping rules, including a copy of the framing skip —
|
||
/// which meant the tests below asserted against a second implementation
|
||
/// that had to be kept in step with the first by hand. It was not: giving
|
||
/// framing a presentation changed the real panel and the simulation
|
||
/// disagreed, which is how a passing test suite would have hidden the
|
||
/// change entirely.
|
||
fn rows_of(caps: &[OpCapability]) -> Vec<(usize, usize)> {
|
||
rows_from(caps)
|
||
.iter()
|
||
.map(|r| (r.group_head as usize, r.group_len as usize))
|
||
.collect()
|
||
}
|
||
|
||
#[test]
|
||
fn four_quarter_turns_return_a_crop_where_it_started() {
|
||
// The property that makes rotation safe to repeat: a user who turns
|
||
// past the orientation they wanted and keeps going must arrive back at
|
||
// the crop they had, not at a slowly drifting one.
|
||
let start = CropRect {
|
||
x: 0.1,
|
||
y: 0.2,
|
||
width: 0.3,
|
||
height: 0.4,
|
||
};
|
||
let mut r = start;
|
||
for _ in 0..4 {
|
||
r = rotate_crop(r, 1);
|
||
}
|
||
assert!((r.x - start.x).abs() < 1e-5, "x drifted to {}", r.x);
|
||
assert!((r.y - start.y).abs() < 1e-5, "y drifted to {}", r.y);
|
||
assert!((r.width - start.width).abs() < 1e-5);
|
||
assert!((r.height - start.height).abs() < 1e-5);
|
||
}
|
||
|
||
#[test]
|
||
fn a_quarter_turn_exchanges_a_crops_extents() {
|
||
// A portrait selection on a landscape frame must come out landscape.
|
||
// Were the extents left alone, the rect would keep its old shape while
|
||
// the frame changed to the other one, and the crop would spill off the
|
||
// photograph.
|
||
let r = rotate_crop(
|
||
CropRect {
|
||
x: 0.0,
|
||
y: 0.0,
|
||
width: 0.25,
|
||
height: 1.0,
|
||
},
|
||
1,
|
||
);
|
||
assert!((r.width - 1.0).abs() < 1e-5, "width was {}", r.width);
|
||
assert!((r.height - 0.25).abs() < 1e-5, "height was {}", r.height);
|
||
}
|
||
|
||
#[test]
|
||
fn rotating_a_crop_keeps_it_inside_the_frame() {
|
||
// Whatever the angle and wherever the rect, the result must still be a
|
||
// rect the pipeline can render: outside the unit square it would
|
||
// sample undefined area, and degenerate it is a zero-sized texture.
|
||
for turns in -5..=5 {
|
||
for rect in [
|
||
CropRect {
|
||
x: 0.0,
|
||
y: 0.0,
|
||
width: 1.0,
|
||
height: 1.0,
|
||
},
|
||
CropRect {
|
||
x: 0.7,
|
||
y: 0.8,
|
||
width: 0.3,
|
||
height: 0.2,
|
||
},
|
||
CropRect {
|
||
x: 0.0,
|
||
y: 0.45,
|
||
width: 0.02,
|
||
height: 0.02,
|
||
},
|
||
] {
|
||
let r = rotate_crop(rect, turns);
|
||
assert!(
|
||
r.x >= 0.0 && r.y >= 0.0,
|
||
"{turns} turns of {rect:?} gave {r:?}"
|
||
);
|
||
assert!(
|
||
r.x + r.width <= 1.0 + 1e-5 && r.y + r.height <= 1.0 + 1e-5,
|
||
"{turns} turns of {rect:?} left the frame: {r:?}"
|
||
);
|
||
assert!(
|
||
r.width >= CropRect::MIN_EXTENT && r.height >= CropRect::MIN_EXTENT,
|
||
"{turns} turns of {rect:?} went degenerate: {r:?}"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn opposite_quarter_turns_cancel() {
|
||
// The rotate-left and rotate-right buttons must undo one another, or
|
||
// correcting an over-rotation would land somewhere new each time.
|
||
let start = CropRect {
|
||
x: 0.15,
|
||
y: 0.05,
|
||
width: 0.5,
|
||
height: 0.25,
|
||
};
|
||
let there_and_back = rotate_crop(rotate_crop(start, 1), -1);
|
||
assert!((there_and_back.x - start.x).abs() < 1e-5);
|
||
assert!((there_and_back.y - start.y).abs() < 1e-5);
|
||
assert!((there_and_back.width - start.width).abs() < 1e-5);
|
||
assert!((there_and_back.height - start.height).abs() < 1e-5);
|
||
}
|
||
|
||
#[test]
|
||
fn a_full_crop_survives_rotation_as_a_full_crop() {
|
||
// The common case: rotating an uncropped photograph must not quietly
|
||
// introduce a crop, which would shrink the exported image.
|
||
assert!(rotate_crop(CropRect::default(), 1).is_full());
|
||
assert!(rotate_crop(CropRect::default(), -3).is_full());
|
||
}
|
||
|
||
#[test]
|
||
fn an_operation_without_facets_keeps_its_declared_order() {
|
||
// Every operation but the mixer. Reordering one of these would move
|
||
// Highlights below Shadows for no reason anybody could see in the
|
||
// code, so the stable sort has to be a no-op when nothing is faceted.
|
||
let graph = EditGraph::default_chain();
|
||
for cap in graph.capabilities() {
|
||
if cap.params.iter().any(|p| p.facet.is_some()) {
|
||
continue;
|
||
}
|
||
let order = presentation_order(&cap.params);
|
||
assert_eq!(
|
||
order,
|
||
(0..cap.params.len()).collect::<Vec<_>>(),
|
||
"{} was reordered",
|
||
cap.id
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn faceted_parameters_are_stacked_one_run_per_aspect() {
|
||
// The panel names a run once and then draws its rows. That only works
|
||
// if a run is *contiguous*: the mixer declares band by band — red hue,
|
||
// red sat, red lum, orange hue — so shown in declaration order every
|
||
// single row would begin a new run, and the panel would draw
|
||
// thirty-six headings over thirty-six sliders.
|
||
let graph = EditGraph::default_chain();
|
||
let cap = graph
|
||
.capabilities()
|
||
.into_iter()
|
||
.find(|c| c.params.iter().any(|p| p.facet.is_some()))
|
||
.expect("the chain has a faceted operation");
|
||
|
||
let mut seen: Vec<&str> = Vec::new();
|
||
let mut previous: Option<&str> = None;
|
||
for i in presentation_order(&cap.params) {
|
||
let aspect = cap.params[i]
|
||
.facet
|
||
.as_ref()
|
||
.expect("this operation facets every parameter")
|
||
.aspect
|
||
.0;
|
||
if previous != Some(aspect) {
|
||
assert!(
|
||
!seen.contains(&aspect),
|
||
"{aspect} is split into two runs — a heading would be \
|
||
drawn over each half"
|
||
);
|
||
seen.push(aspect);
|
||
previous = Some(aspect);
|
||
}
|
||
}
|
||
assert!(seen.len() > 1, "the fixture must have several aspects");
|
||
}
|
||
|
||
#[test]
|
||
fn reordering_rows_does_not_move_where_a_change_is_routed() {
|
||
// The rows are stacked for reading; `param_index` still addresses the
|
||
// capability list. Were the two confused, dragging a band's Hue would
|
||
// silently write to whichever parameter happened to sit at that
|
||
// position — an edit landing on the wrong control, which reads as the
|
||
// renderer being broken rather than the panel.
|
||
let graph = EditGraph::default_chain();
|
||
let cap = graph
|
||
.capabilities()
|
||
.into_iter()
|
||
.find(|c| c.params.iter().any(|p| p.facet.is_some()))
|
||
.expect("the chain has a faceted operation");
|
||
|
||
let mut order = presentation_order(&cap.params);
|
||
order.sort_unstable();
|
||
assert_eq!(
|
||
order,
|
||
(0..cap.params.len()).collect::<Vec<_>>(),
|
||
"the order must be a permutation: every parameter reachable from \
|
||
exactly one row, and every row addressing a parameter that exists"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn every_faceted_parameter_resolves_to_a_band_name() {
|
||
// The bug this closes: `labels.rs` had no `param.mixer.*` entries, so
|
||
// all thirty-six keys fell through to a derived label that yields the
|
||
// bare channel name — twelve rows reading "Hue" with nothing saying
|
||
// which band. A row identified only by a swatch depends on this
|
||
// resolving, since the name is what a screen reader speaks and what
|
||
// anyone who cannot separate two squares by eye has to go on.
|
||
let graph = EditGraph::default_chain();
|
||
for cap in graph.capabilities() {
|
||
for p in &cap.params {
|
||
let Some(facet) = &p.facet else { continue };
|
||
let subject = labels::resolve(facet.subject.0);
|
||
let aspect = labels::resolve(facet.aspect.0);
|
||
assert!(!subject.is_empty(), "{} has no subject name", p.id);
|
||
assert!(!aspect.is_empty(), "{} has no aspect name", p.id);
|
||
// Not the channel name repeated: that is exactly the failure
|
||
// the catalogue entries were added to fix.
|
||
assert_ne!(subject, aspect, "{} is named after its channel", p.id);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn unit_suffixes_come_from_the_descriptor() {
|
||
assert_eq!(unit_suffix(Unit::Stops), " EV");
|
||
assert_eq!(unit_suffix(Unit::None), "");
|
||
}
|
||
|
||
#[test]
|
||
fn fitting_preserves_aspect_ratio() {
|
||
// A 3:2 image in a 16:9 window must letterbox, not stretch.
|
||
let (w, h) = fit(6000, 4000, 1600, 900);
|
||
assert_eq!(h, 900);
|
||
assert!(
|
||
((w as f32 / h as f32) - 1.5).abs() < 0.01,
|
||
"got {w}x{h}, aspect {}",
|
||
w as f32 / h as f32
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn fitting_never_upscales_past_the_source() {
|
||
// Rendering a 400px image into a 4K window at 4K shades 25x the
|
||
// pixels for no additional detail.
|
||
let (w, h) = fit(400, 300, 3840, 2160);
|
||
assert_eq!((w, h), (400, 300));
|
||
}
|
||
|
||
#[test]
|
||
fn fitting_handles_a_degenerate_source() {
|
||
let (w, h) = fit(0, 0, 800, 600);
|
||
assert_eq!((w, h), (800, 600));
|
||
}
|
||
|
||
#[test]
|
||
fn fitting_is_bounded_by_the_narrow_axis() {
|
||
// A tall window on a wide image must be limited by width.
|
||
let (w, h) = fit(4000, 1000, 800, 4000);
|
||
assert_eq!(w, 800);
|
||
assert_eq!(h, 200);
|
||
}
|
||
|
||
#[test]
|
||
fn the_curve_collapses_to_a_single_row() {
|
||
// Ten point parameters must appear as one curve control, not ten
|
||
// 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::POINTS * 2);
|
||
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"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[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:?}");
|
||
}
|
||
}
|
||
|
||
/// A frame black on the left half and white on the right, at `size`
|
||
/// square. Both ends of the histogram are occupied and both clipping
|
||
/// counters are non-zero, and cropping to one half leaves exactly one of
|
||
/// them so.
|
||
fn split_frame(size: u32) -> Vec<u8> {
|
||
let mut rgba = Vec::with_capacity((size * size * 4) as usize);
|
||
for _ in 0..size {
|
||
for x in 0..size {
|
||
let v = if x < size / 2 { 0u8 } else { 255 };
|
||
rgba.extend_from_slice(&[v, v, v, 255]);
|
||
}
|
||
}
|
||
rgba
|
||
}
|
||
|
||
/// TRACES: FR-DSP-7
|
||
#[test]
|
||
fn the_histogram_counts_the_frame_that_is_actually_on_the_canvas() {
|
||
// The wiring, end to end and against exact numbers: a 64x64 frame that
|
||
// is half black and half white must come back as 2048 pixels at level
|
||
// 0, 2048 at 255, and both clipping counters at 2048.
|
||
//
|
||
// Asserted at the session rather than at the pass because the mistake
|
||
// this catches is not arithmetic — `dr_gpu` has its own tests for that
|
||
// — it is counting the *wrong texture*. Reading a stale target, or the
|
||
// demosaiced source instead of the adjusted output, produces a
|
||
// perfectly well-formed histogram of an image the photographer is not
|
||
// looking at, which is the one failure mode that cannot be seen.
|
||
let Ok(ctx) = pollster::block_on(dr_gpu::GpuContext::new_headless()) else {
|
||
log::warn!("no GPU adapter; skipping");
|
||
return;
|
||
};
|
||
|
||
let rgba = split_frame(64);
|
||
let mut session =
|
||
DevelopSession::open_rgb(&ctx, &rgba, 64, 64, dr_types::Orientation::NORMAL)
|
||
.expect("session");
|
||
session.render(64, 64).expect("render");
|
||
|
||
let hist = session.histogram().expect("a rendered session must count");
|
||
assert_eq!(hist.pixels(), 64 * 64);
|
||
assert_eq!(hist.red()[0], 2048, "the black half");
|
||
assert_eq!(hist.red()[255], 2048, "the white half");
|
||
assert_eq!(hist.clipped_shadows(), 2048);
|
||
assert_eq!(hist.clipped_highlights(), 2048);
|
||
}
|
||
|
||
/// TRACES: FR-DSP-7
|
||
#[test]
|
||
fn the_histogram_follows_the_edit_rather_than_the_file() {
|
||
// The property that makes it *live*. A histogram computed once from the
|
||
// source would pass the test above and be useless — the whole reason
|
||
// FR-DSP-7 exists is to show what an adjustment is doing, so cropping
|
||
// away the white half must leave a histogram with no white in it and
|
||
// no highlight clipping to report.
|
||
let Ok(ctx) = pollster::block_on(dr_gpu::GpuContext::new_headless()) else {
|
||
log::warn!("no GPU adapter; skipping");
|
||
return;
|
||
};
|
||
|
||
let rgba = split_frame(64);
|
||
let mut session =
|
||
DevelopSession::open_rgb(&ctx, &rgba, 64, 64, dr_types::Orientation::NORMAL)
|
||
.expect("session");
|
||
|
||
session.set_crop(CropRect {
|
||
x: 0.0,
|
||
y: 0.0,
|
||
width: 0.5,
|
||
height: 1.0,
|
||
});
|
||
session.render(64, 64).expect("render");
|
||
|
||
let hist = session.histogram().expect("histogram");
|
||
assert_eq!(hist.pixels(), 32 * 64, "the crop halved the frame");
|
||
assert_eq!(hist.red()[0], 32 * 64);
|
||
assert_eq!(hist.red()[255], 0, "the white half was cropped away");
|
||
assert_eq!(hist.clipped_highlights(), 0);
|
||
assert_eq!(hist.clipped_shadows(), 32 * 64);
|
||
}
|
||
|
||
#[test]
|
||
fn routing_indices_map_back_to_the_right_parameter() {
|
||
// A wrong index would silently move the wrong slider's value, which
|
||
// is exactly the kind of bug that looks like a rendering fault.
|
||
let graph = EditGraph::default_chain();
|
||
let caps = graph.capabilities();
|
||
for (oi, op) in caps.iter().enumerate() {
|
||
for (pi, p) in op.params.iter().enumerate() {
|
||
assert_eq!(caps[oi].params[pi].id, p.id);
|
||
assert_eq!(caps[oi].id, op.id);
|
||
}
|
||
}
|
||
}
|
||
}
|