//! 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 { 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 { 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 { 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::::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 { // 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 = 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 { 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 { // 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::::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 = 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); } } } }