//! 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, } 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); 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 { 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 { thread_local! { static EMPTY: slint::ModelRc = slint::ModelRc::new(slint::VecModel::from(Vec::::new())); } EMPTY.with(Clone::clone) } /// The empty choices model, shared by every row that is not an enum. fn no_choices() -> slint::ModelRc { thread_local! { static EMPTY: slint::ModelRc = slint::ModelRc::new(slint::VecModel::from(Vec::::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 { 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 = 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 { // 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(), // 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 { 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 { // 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 { 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 { 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 { let mut aspects: Vec<&str> = Vec::new(); let rank: Vec = 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 = (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 { 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 = 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::>(), "{} 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::>(), "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 { 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); } } } }