Files
DarkRoom/ui/dr-ui/src/develop.rs
T
dtourolleandClaude Opus 5 489465faf0 Show the photograph the way it was taken
Nothing read EXIF orientation, so every frame from a body held sideways
lay on its side — in the grid, in develop, and in the read-only preview.

The tag is honoured as part of *reading the file*, at the same standing
as a RAW's masked-photosite crop, never as an edit. It lives as a
baseline on Framing rather than as a starting value for quarter_turns,
which is what keeps four things true: a sideways file opens unmodified,
reset returns it to upright rather than to the sensor's scan order, its
sidecar stays empty, and the rotate button still moves the image 90°
whatever the file underneath it says.

Framing::effective composes the baseline with the user's own turns
through the group law rather than by adding turns and OR-ing flags. The
naive version gets one case wrong — an odd baseline turn plus a user
mirror — and gets it wrong quietly, because the result is still a
plausible orientation. The composition collapses to a single
permutation, so obeying the tag costs nothing per pixel.

dr_decode::orientation is a header-only IFD walk, separate from
metadata() for the reason the entry points are separate at all: the grid
asks once per cell and must not build a rawler decoder to get one tag.
CR3 and RAF fall back to the full read, being neither TIFF nor JPEG.

Written down as FR-DEV-3h.

Known gap: thumbnails cached before this stay sideways. The store is
keyed by file and size, and its shards sync — invalidating them would
have every client re-download 25 MB a shard, which is not this commit's
call to make.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 15:37:05 +02:00

1297 lines
50 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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};
use dr_pipeline::ops::curve;
use dr_pipeline::{
CropRect, EditGraph, OpCapability, OpId, ParamId, ParamKind, Presentation, Unit, WidgetKind,
};
use crate::labels;
use crate::ParamRow;
/// A loaded image plus its edit state.
pub struct DevelopSession {
graph: EditGraph,
demosaiced: DemosaicedImage,
adjust: AdjustPass,
}
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);
Self {
graph,
demosaiced,
adjust: AdjustPass::new(ctx),
}
}
/// 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> {
let mut rows = Vec::new();
for (op_index, op) in self.graph.capabilities().iter().enumerate() {
// Framing has a panel of its own.
//
// The one place this side names a stage, and the exception proves
// the rule: every *other* operation is rendered from its
// descriptor alone. Framing is skipped because its parameters are
// not sliders in any useful sense — four crop edges are dragged on
// the photograph and a quarter turn is a button — so it is
// presented by `GeometryPanel` instead of generated here. Emitting
// both would show the same eight values twice, in one good control
// surface and one bad one.
if op.id == dr_pipeline::framing::ID {
continue;
}
// 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 {
// A `match` rather than an `if let`: when a second widget
// kind is added, this stops compiling until it is handled,
// rather than silently falling through to sliders.
let row = match presentation.widget {
WidgetKind::Curve => self.curve_row(op_index, group_head, op, presentation),
};
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;
for (param_index, p) in op.params.iter().enumerate() {
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, ""),
};
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: labels::resolve(p.label.0).into(),
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: slint::ModelRc::new(slint::VecModel::from(Vec::<f32>::new())),
});
}
}
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(
&self,
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(),
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)),
})
}
/// 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);
}
}
/// 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);
}
/// 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);
}
pub fn reset_all(&mut self) {
self.graph.reset();
}
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))
}
/// 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 readback at the end is the temporary bridge documented on
/// `AdjustPass::read_output`: ARCH §6.1 forbids it, and spike S1 removes
/// it by importing the texture into Slint directly.
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();
self.adjust
.render(&self.demosaiced, &shader, w, h)
.map_err(|e| e.to_string())?;
let (pixels, rw, rh) = self.adjust.read_output().map_err(|e| e.to_string())?;
let buffer =
slint::SharedPixelBuffer::<slint::Rgba8Pixel>::clone_from_slice(&pixels, rw, rh);
Ok(slint::Image::from_rgba8(buffer))
}
/// 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)
}
/// 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);
}
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);
}
/// 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)),
);
}
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)),
);
}
/// 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,
);
}
/// 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);
}
/// 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()
}
}
/// 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),
)
}
/// 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;
/// 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");
let before = fitted.to_rgba8().expect("fitted pixels");
let after = zoomed.to_rgba8().expect("zoomed pixels");
let differing = before
.as_bytes()
.iter()
.zip(after.as_bytes().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 framing_is_not_generated_as_sliders() {
// The geometry panel 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 that no one can compose a photograph with.
let graph = EditGraph::default_chain();
let caps = graph.capabilities();
assert!(
caps.iter().any(|c| c.id == dr_pipeline::framing::ID),
"the chain must still expose framing — the panel reads it"
);
// Asserted through the row count rather than by inspecting labels: a
// leaked framing group would add its eight parameters as eight rows,
// and the difference is exactly what `rows_of` must not contain.
let framing_params = caps
.iter()
.find(|c| c.id == dr_pipeline::framing::ID)
.map(|c| c.params.len())
.expect("framing is in the chain");
assert!(framing_params > 0);
let generated = rows_of(&caps).len();
let with_framing = rows_of_unfiltered(&caps).len();
assert_eq!(
with_framing - generated,
framing_params,
"framing parameters leaked into the generated panel"
);
}
/// `rows_of` without the framing skip — the shape the panel would have if
/// framing were generated, which is what the test above measures against.
fn rows_of_unfiltered(caps: &[OpCapability]) -> Vec<(usize, usize)> {
let mut rows = Vec::new();
for op in caps {
let head = rows.len();
let collapses = op
.presentation
.as_ref()
.is_some_and(|p| p.params.len() == op.params.len());
let len = if collapses { 1 } else { op.params.len() };
for _ in 0..len {
rows.push((head, len));
}
}
rows
}
#[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.
fn rows_of(caps: &[OpCapability]) -> Vec<(usize, usize)> {
let mut rows = Vec::new();
for op in caps {
// Framing is presented by `GeometryPanel`, not generated — mirror
// the skip, or these tests assert against a panel that is not the
// one the interface builds.
if op.id == dr_pipeline::framing::ID {
continue;
}
let head = rows.len();
let collapses = op
.presentation
.as_ref()
.is_some_and(|p| p.params.len() == op.params.len());
let len = if collapses { 1 } else { op.params.len() };
for _ in 0..len {
rows.push((head, len));
}
}
rows
}
#[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 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");
assert_eq!(presentation.widget, WidgetKind::Curve);
// 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:?}");
}
}
#[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);
}
}
}
}