`cargo fmt --check` is a required step and had drifted across 45 files. Most of it arrived this week: several operations were written in parallel worktrees and merged by hand, and a hand-merge resolves conflicts without ever running the formatter over the result. No behaviour changes — this is `cargo fmt --all` and nothing else, kept as its own commit so the next reader can skip it wholesale rather than search it for one that matters. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
4093 lines
167 KiB
Rust
4093 lines
167 KiB
Rust
//! The develop session — capabilities in, rendered image out.
|
||
//!
|
||
//! This is the only place the UI touches the pipeline, and it does so through
|
||
//! two calls: [`dr_pipeline::EditGraph::capabilities`] to learn what controls
|
||
//! to build, and `set_param` to change one. It never names an operation, and
|
||
//! it knows nothing about shaders.
|
||
//!
|
||
//! Whether a control is a slider or a switch follows from the parameter's
|
||
//! declared [`ParamKind`], not from which parameter it is (ARCH §4.3), so a
|
||
//! new operation appears in the panel with no change here (FR-DEV-3c).
|
||
|
||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||
use std::sync::Arc;
|
||
|
||
use dr_decode::RawImage;
|
||
use dr_gpu::{
|
||
AdjustPass, DemosaicedImage, Demosaicer, GpuContext, Histogram, HistogramPass, MaskPass,
|
||
};
|
||
use dr_pipeline::mask::{MaskLayer, MaskSource};
|
||
|
||
use crate::segmentation::{self, Segmentation};
|
||
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.
|
||
/// Longest edge the model and the masks work at.
|
||
///
|
||
/// ~1.3 MP at 3:2. Large enough that an outline is within a pixel or two of
|
||
/// where it belongs, small enough that a distance transform over it is a few
|
||
/// milliseconds and its field a few megabytes.
|
||
const SEGMENT_PROXY_EDGE: u32 = 1600;
|
||
|
||
/// Which photograph a piece of background work was started for.
|
||
///
|
||
/// Minted per session, never reused, and carried by the work rather than
|
||
/// looked up when it finishes. A segmentation takes most of a second, so the
|
||
/// user can be two frames further on by the time one lands, and the answer to
|
||
/// "is this still wanted" has to be decided from what the work *was* rather
|
||
/// than from what happens to be open.
|
||
///
|
||
/// The alternative — a counter beside the session slot, bumped on every open —
|
||
/// is written from four places in `lib.rs` and would apply one photograph's
|
||
/// subjects to another the first time somebody added a fifth and forgot.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub struct SessionId(u64);
|
||
|
||
impl SessionId {
|
||
pub(crate) fn next() -> Self {
|
||
static NEXT: AtomicU64 = AtomicU64::new(1);
|
||
Self(NEXT.fetch_add(1, Ordering::Relaxed))
|
||
}
|
||
}
|
||
|
||
/// Says that nobody is waiting for a job's answer any more.
|
||
///
|
||
/// Not a cancellation in the sense of stopping the work: the model is one
|
||
/// opaque call of about half a second and `ort` offers no way in. This is
|
||
/// checked at the seams there are — before the job starts, and again between
|
||
/// the proxy readback and the inference — so a job abandoned while the user
|
||
/// was still paging usually costs nothing, and one abandoned mid-inference
|
||
/// costs only the run it was already committed to.
|
||
///
|
||
/// What it buys in every case is that the next photograph's segmentation is
|
||
/// the only one anybody is waiting on.
|
||
#[derive(Debug, Clone, Default)]
|
||
pub struct Abandon(Arc<AtomicBool>);
|
||
|
||
impl Abandon {
|
||
pub fn now(&self) {
|
||
self.0.store(true, Ordering::Relaxed);
|
||
}
|
||
|
||
pub fn asked(&self) -> bool {
|
||
self.0.load(Ordering::Relaxed)
|
||
}
|
||
}
|
||
|
||
/// What a finished [`SegmentationJob`] hands back.
|
||
///
|
||
/// The rasteriser travels with the subjects because it is needed the instant
|
||
/// they arrive and nowhere before. Building it is a shader compile — 23 ms on
|
||
/// a desktop, and compiling shaders is among the slowest things a mobile
|
||
/// driver does — so building it on adoption put a dropped frame on the one
|
||
/// redraw the user is waiting for. Here it is on the thread that was waiting
|
||
/// anyway.
|
||
///
|
||
/// `None` where the device has no mask rasteriser at all: the session keeps
|
||
/// the photograph and loses local adjustments, which is the same bargain the
|
||
/// histogram makes.
|
||
pub struct Segmented {
|
||
seg: Segmentation,
|
||
masks: Option<MaskPass>,
|
||
}
|
||
|
||
/// TRACES: FR-DEV-3
|
||
/// A segmentation lifted out of the session that asked for it.
|
||
///
|
||
/// [`DevelopSession`] cannot go to a worker. Not because of what it holds —
|
||
/// the device, the source texture and the passes are all `Send` — but because
|
||
/// it lives behind one `Rc<RefCell<Option<…>>>` that every callback in the
|
||
/// window reaches through, and the window has to keep reaching through it
|
||
/// while the work runs. Handing the session over would freeze the interface
|
||
/// exactly as thoroughly as blocking on it did.
|
||
///
|
||
/// So the work takes a copy of the two things it needs. The device is `Arc`s,
|
||
/// the source is shared rather than copied, and the answer comes back as plain
|
||
/// data.
|
||
pub struct SegmentationJob {
|
||
ctx: GpuContext,
|
||
source: Arc<DemosaicedImage>,
|
||
session: SessionId,
|
||
abandon: Abandon,
|
||
}
|
||
|
||
impl SegmentationJob {
|
||
/// The photograph this was started for.
|
||
pub fn session(&self) -> SessionId {
|
||
self.session
|
||
}
|
||
|
||
/// The handle that tells this job its answer is no longer wanted.
|
||
pub fn abandon(&self) -> Abandon {
|
||
self.abandon.clone()
|
||
}
|
||
|
||
/// TRACES: FR-DEV-3
|
||
/// Find the subjects. **Blocking, and roughly two thirds of a second.**
|
||
///
|
||
/// `Ok(None)` means abandoned rather than found-nothing: an image with no
|
||
/// recognisable subject in it still comes back as `Ok(Some(_))` with an
|
||
/// empty instance list, and the panel says so.
|
||
///
|
||
/// There is deliberately no `&mut DevelopSession` in scope here. That is
|
||
/// the whole point of the split — a caller cannot accidentally hold the
|
||
/// session across the half second, because it was never given one.
|
||
pub fn run(&self, options: &segmentation::Options) -> Result<Option<Segmented>, String> {
|
||
if self.abandon.asked() {
|
||
return Ok(None);
|
||
}
|
||
|
||
// Timed and logged because this is the feature's largest cost and the
|
||
// split between the two halves decides where any further work goes. A
|
||
// number from the device it actually runs on beats an estimate from
|
||
// the desktop.
|
||
let started = std::time::Instant::now();
|
||
let (rgb, rw, rh) = self.neutral_proxy(SEGMENT_PROXY_EDGE)?;
|
||
let proxied = started.elapsed();
|
||
|
||
// The one seam inside the run. Past here the model owns the thread
|
||
// until it is done.
|
||
if self.abandon.asked() {
|
||
return Ok(None);
|
||
}
|
||
|
||
let seg = segmentation::compute(&self.ctx, &rgb, rw, rh, options)?;
|
||
log::info!(
|
||
"segmented {rw}×{rh}: {} subject(s), proxy {:.0} ms, total {:.0} ms",
|
||
seg.instances().len(),
|
||
proxied.as_secs_f32() * 1000.0,
|
||
started.elapsed().as_secs_f32() * 1000.0,
|
||
);
|
||
|
||
let masks = MaskPass::new(&self.ctx)
|
||
.inspect_err(|e| log::warn!("no mask rasteriser on this device: {e}"))
|
||
.ok();
|
||
Ok(Some(Segmented { seg, masks }))
|
||
}
|
||
|
||
/// Render the *unedited* image to a CPU buffer at proxy size.
|
||
///
|
||
/// The model reads the photograph as captured, not as edited: the
|
||
/// segmentation must survive an exposure change, or every slider would
|
||
/// invalidate the masks that depend on it (docs/segmentation.md §3).
|
||
///
|
||
/// A throwaway [`AdjustPass`] with a neutral graph rather than the
|
||
/// session's own — which this could not reach from here in any case, and
|
||
/// must not: reusing it would overwrite the frame the histogram reads and
|
||
/// leave the view showing an unedited image until the next redraw.
|
||
///
|
||
/// This is `export_pixels`, which is ungated: an export is not the display
|
||
/// round-trip AC-8 forbids, and neither is this.
|
||
fn neutral_proxy(&self, max_edge: u32) -> Result<(Vec<f32>, usize, usize), String> {
|
||
let (sw, sh) = self.source.size();
|
||
let scale = (max_edge as f32 / sw.max(sh) as f32).min(1.0);
|
||
let (w, h) = (
|
||
((sw as f32 * scale) as u32).max(1),
|
||
((sh as f32 * scale) as u32).max(1),
|
||
);
|
||
|
||
let neutral = EditGraph::default_chain();
|
||
let mut pass = AdjustPass::new(&self.ctx);
|
||
pass.render(&self.source, &neutral.compose(), w, h)
|
||
.map_err(|e| format!("could not render the segmentation proxy: {e}"))?;
|
||
let (rgba, pw, ph) = pass
|
||
.export_pixels()
|
||
.map_err(|e| format!("could not read the segmentation proxy: {e}"))?;
|
||
|
||
// Straight to float RGB, dropping alpha. The values stay display-
|
||
// encoded because that is what the model was trained on — one of the
|
||
// few places in this codebase where not linearising is correct.
|
||
let rgb = rgba
|
||
.chunks_exact(4)
|
||
.flat_map(|p| {
|
||
[
|
||
p[0] as f32 / 255.0,
|
||
p[1] as f32 / 255.0,
|
||
p[2] as f32 / 255.0,
|
||
]
|
||
})
|
||
.collect();
|
||
Ok((rgb, pw as usize, ph as usize))
|
||
}
|
||
}
|
||
|
||
pub struct DevelopSession {
|
||
/// This session's name, for work that outlives the frame it started on.
|
||
id: SessionId,
|
||
/// Kept so the session can build GPU resources after construction.
|
||
///
|
||
/// The distance fields behind a subject mask are made when a layer is
|
||
/// *shaped*, not when the image opens, and cloning a `GpuContext` is two
|
||
/// `Arc` bumps.
|
||
ctx: GpuContext,
|
||
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: Arc<DemosaicedImage>,
|
||
adjust: AdjustPass,
|
||
/// TRACES: FR-DSP-7
|
||
/// Optional, because a session that cannot count its frames is still a
|
||
/// session that can develop them. If the reduction fails to build — an
|
||
/// old driver, a device without the storage-buffer atomics it needs — the
|
||
/// photographer loses the histogram and keeps the photograph.
|
||
histogram: Option<HistogramPass>,
|
||
|
||
/// TRACES: FR-DEV-3
|
||
/// The region map local masks select from, once it has been computed.
|
||
///
|
||
/// `None` until the photographer asks for it. Segmentation costs about
|
||
/// half a second and most edits never need one, so running it on open
|
||
/// would tax every photograph for a feature used on some of them.
|
||
segmentation: Option<Segmentation>,
|
||
/// Rasterises the mask layers. Built lazily for the same reason.
|
||
masks: Option<MaskPass>,
|
||
/// One signed distance field per active subject layer, on the GPU.
|
||
subjects: Option<dr_gpu::SubjectMasks>,
|
||
/// What `subjects` was built from.
|
||
///
|
||
/// The fields are expensive — an exact distance transform over the proxy
|
||
/// for each layer — and almost nothing changes them. Feather, falloff and
|
||
/// simple growing are arithmetic the shader does on the field it already
|
||
/// has, so this deliberately does *not* include them: dragging those
|
||
/// sliders must not rebuild anything.
|
||
subject_key: u64,
|
||
/// Which layer the develop panel is editing, if any.
|
||
///
|
||
/// This is what lets one panel serve both scopes: with a layer selected,
|
||
/// the sliders read and write *its* chain, and the photographer is
|
||
/// adjusting a region rather than the frame.
|
||
active_mask: Option<String>,
|
||
/// Whether to draw the false-coloured region overlay.
|
||
show_overlay: bool,
|
||
/// Which attribute the panel is filtered to, or all of them.
|
||
///
|
||
/// `None` is "show everything" and is what a frontend that ignores
|
||
/// attributes leaves it at — the tabs are the interface's idea, not the
|
||
/// core's, and nothing breaks without them (ARCH §4.3a).
|
||
active_tab: Option<dr_pipeline::Attribute>,
|
||
/// TRACES: FR-DEV-3
|
||
/// Which of the curve widget's subjects the panel is plotting.
|
||
///
|
||
/// The tone curve is four curves — one over tone and one per colour
|
||
/// channel — and one square plot draws one of them at a time. The index
|
||
/// is into the subjects the operation's parameters are faceted on, in the
|
||
/// order it declares them, so nothing here knows that "red" exists.
|
||
///
|
||
/// **Interface state, not part of the edit.** It changes no pixel, so it
|
||
/// is not a parameter, it is not in the graph, it is not in the sidecar
|
||
/// and it is not on the undo stack — the same standing as which tab is
|
||
/// open. One value rather than one per operation, for the same reason
|
||
/// `curve_samples` is one polyline: the panel draws one curve.
|
||
curve_channel: usize,
|
||
}
|
||
|
||
impl DevelopSession {
|
||
/// Demosaic an image and prepare its edit graph.
|
||
///
|
||
/// `orientation` is the file's EXIF orientation, not an edit: a sensor is
|
||
/// scanned the same way whichever way the body was held, so this is what
|
||
/// makes a portrait frame open upright. It is fixed for the life of the
|
||
/// session and survives a reset.
|
||
pub fn open(
|
||
ctx: &GpuContext,
|
||
raw: &RawImage,
|
||
orientation: dr_types::Orientation,
|
||
) -> Result<Self, String> {
|
||
let demosaicer = Demosaicer::new(ctx).map_err(|e| e.to_string())?;
|
||
let demosaiced = demosaicer.run(raw).map_err(|e| e.to_string())?;
|
||
Ok(Self::with_source(ctx, demosaiced, orientation))
|
||
}
|
||
|
||
/// Prepare an edit graph over an already-processed RGB image.
|
||
///
|
||
/// The JPEG path. A JPEG is already demosaiced, so there is no sensor
|
||
/// stage to run — but everything after it is identical, which is why this
|
||
/// shares [`Self::with_source`] rather than duplicating the session.
|
||
///
|
||
/// Worth being honest about what this cannot recover: an 8-bit JPEG has
|
||
/// clipped highlights and quantised shadows that no edit brings back, so
|
||
/// exposure has far less latitude here than on sensor data. The controls
|
||
/// are the same controls; the file simply carries less to work with.
|
||
pub fn open_rgb(
|
||
ctx: &GpuContext,
|
||
rgba: &[u8],
|
||
width: u32,
|
||
height: u32,
|
||
orientation: dr_types::Orientation,
|
||
) -> Result<Self, String> {
|
||
let source =
|
||
DemosaicedImage::from_rgba8(ctx, rgba, width, height).map_err(|e| e.to_string())?;
|
||
Ok(Self::with_source(ctx, source, orientation))
|
||
}
|
||
|
||
fn with_source(
|
||
ctx: &GpuContext,
|
||
demosaiced: DemosaicedImage,
|
||
orientation: dr_types::Orientation,
|
||
) -> Self {
|
||
let mut graph = EditGraph::default_chain();
|
||
graph.set_orientation(orientation);
|
||
let history = History::new(&graph);
|
||
Self {
|
||
id: SessionId::next(),
|
||
ctx: ctx.clone(),
|
||
graph,
|
||
history,
|
||
demosaiced: Arc::new(demosaiced),
|
||
adjust: AdjustPass::new(ctx),
|
||
histogram: HistogramPass::new(ctx)
|
||
.inspect_err(|e| log::warn!("no histogram on this device: {e}"))
|
||
.ok(),
|
||
segmentation: None,
|
||
masks: None,
|
||
subjects: None,
|
||
subject_key: 0,
|
||
active_mask: None,
|
||
show_overlay: false,
|
||
active_tab: None,
|
||
curve_channel: 0,
|
||
}
|
||
}
|
||
|
||
/// The controls the interface should show.
|
||
///
|
||
/// Built entirely from the capability list. The `kind` string chooses the
|
||
/// widget; nothing switches on a parameter's identity.
|
||
pub fn rows(&self) -> Vec<ParamRow> {
|
||
let caps = self.scoped_capabilities();
|
||
match self.active_tab {
|
||
Some(attribute) => rows_filtered(
|
||
&caps,
|
||
|op| op.attributes.contains(&attribute),
|
||
self.curve_channel,
|
||
),
|
||
None => rows_filtered(&caps, |_| true, self.curve_channel),
|
||
}
|
||
}
|
||
|
||
/// The capability list the panel is currently describing.
|
||
///
|
||
/// A selected mask layer takes over the panel, so every control the global
|
||
/// chain offers is offered on a layer too — including operations added
|
||
/// later, which need no work to become local.
|
||
fn scoped_capabilities(&self) -> Vec<OpCapability> {
|
||
match self.active_layer() {
|
||
Some(layer) => layer.capabilities(),
|
||
None => self.graph.capabilities(),
|
||
}
|
||
}
|
||
|
||
/// The attributes worth offering as tabs, in declaration order.
|
||
///
|
||
/// **Derived from the chain, never listed here.** The groups are whatever
|
||
/// the operations say they are about, so a new operation joins the right
|
||
/// tab by declaring its nature and this file goes on naming none of them
|
||
/// (FR-DEV-3a). An attribute nothing carries is left out rather than
|
||
/// offered as a tab that opens onto nothing.
|
||
///
|
||
/// Geometry is excluded: its one operation prefers an on-canvas widget and
|
||
/// is skipped by the row builder, so a Geometry tab would be empty of rows
|
||
/// while `GeometryPanel` holds the real controls.
|
||
pub fn tabs(&self) -> Vec<(dr_pipeline::Attribute, String)> {
|
||
use dr_pipeline::Attribute;
|
||
let caps = self.scoped_capabilities();
|
||
Attribute::ALL
|
||
.into_iter()
|
||
.filter(|a| *a != Attribute::Geometry)
|
||
.filter(|a| {
|
||
caps.iter().any(|c| {
|
||
c.attributes.contains(a)
|
||
&& !rows_filtered(&caps, |o| o.attributes.contains(a), 0).is_empty()
|
||
})
|
||
})
|
||
.map(|a| (a, crate::labels::resolve(a.label().0)))
|
||
.collect()
|
||
}
|
||
|
||
/// Which tab is selected, as an index into [`Self::tabs`]. `-1` is "all".
|
||
pub fn active_tab(&self) -> i32 {
|
||
let Some(active) = self.active_tab else {
|
||
return -1;
|
||
};
|
||
self.tabs()
|
||
.iter()
|
||
.position(|(a, _)| *a == active)
|
||
.map_or(-1, |i| i as i32)
|
||
}
|
||
|
||
/// Select a tab by its index in [`Self::tabs`], or `-1` for all.
|
||
pub fn set_active_tab(&mut self, index: i32) {
|
||
self.active_tab = usize::try_from(index)
|
||
.ok()
|
||
.and_then(|i| self.tabs().get(i).map(|(a, _)| *a));
|
||
}
|
||
|
||
fn active_layer(&self) -> Option<&MaskLayer> {
|
||
let id = self.active_mask.as_ref()?;
|
||
self.graph.masks().get(id)
|
||
}
|
||
|
||
fn active_layer_mut(&mut self) -> Option<&mut MaskLayer> {
|
||
let id = self.active_mask.clone()?;
|
||
self.graph.masks_mut().get_mut(&id)
|
||
}
|
||
}
|
||
|
||
// The empty nested models, each a single shared identity.
|
||
//
|
||
// **`ModelRc` compares by identity, not by contents**, and `sync_rows` decides
|
||
// which controls to invalidate by comparing each freshly built row against the
|
||
// one on screen. A brand-new empty model per row per call therefore makes every
|
||
// row differ from *itself* on every parameter event, and the panel rewrites all
|
||
// of them.
|
||
//
|
||
// That is not merely wasteful — it breaks dragging. An operation with several
|
||
// parameters renders them through a repeater whose model is read off the
|
||
// group's head row; rewriting that row re-evaluates the repeater, rebuilding
|
||
// its items and destroying the `TouchArea` that holds the gesture. The slider
|
||
// takes the press, jumps once, then goes dead under the finger. Only
|
||
// multi-parameter operations show it, because a lone parameter has no inner
|
||
// repeater to rebuild — which is exactly how it hid: exposure and contrast drag
|
||
// perfectly while temperature and tint do not.
|
||
//
|
||
// Most rows carry neither points nor choices, so the empty case is the common
|
||
// one and it costs nothing to make it a constant.
|
||
|
||
/// The empty points model, shared by every row that is not a curve.
|
||
fn no_points() -> slint::ModelRc<f32> {
|
||
thread_local! {
|
||
static EMPTY: slint::ModelRc<f32> =
|
||
slint::ModelRc::new(slint::VecModel::from(Vec::<f32>::new()));
|
||
}
|
||
EMPTY.with(Clone::clone)
|
||
}
|
||
|
||
/// The empty choices model, shared by every row that is not an enum.
|
||
fn no_choices() -> slint::ModelRc<slint::SharedString> {
|
||
thread_local! {
|
||
static EMPTY: slint::ModelRc<slint::SharedString> =
|
||
slint::ModelRc::new(slint::VecModel::from(Vec::<slint::SharedString>::new()));
|
||
}
|
||
EMPTY.with(Clone::clone)
|
||
}
|
||
|
||
/// Whether this frontend has an implementation of `widget` **anywhere**.
|
||
///
|
||
/// "Anywhere" is doing real work: a widget may be drawn in the panel, as the
|
||
/// tone curve is, or hosted on the canvas, as the crop is. Both count as
|
||
/// implemented, and the difference is settled afterwards by
|
||
/// [`WidgetKind::is_on_canvas`] rather than by two separate lists that could
|
||
/// disagree about the same kind.
|
||
///
|
||
/// A kind answering `false` here is not an error — the operation's parameters
|
||
/// are ordinary scalars, so it falls back to sliders and stays fully editable
|
||
/// (ARCH §4.3a).
|
||
pub(crate) fn supported(widget: WidgetKind) -> bool {
|
||
match widget {
|
||
// Drawn in the panel.
|
||
WidgetKind::ToneCurve => true,
|
||
// Hosted on the canvas: the overlay is drawn over the photograph and
|
||
// the panel contributes `GeometryPanel`, the affordance that turns it
|
||
// on.
|
||
WidgetKind::CropOverlay => true,
|
||
// Not implemented. Listed rather than caught by a wildcard so the next
|
||
// kind added to the core surfaces here as a compile error.
|
||
WidgetKind::ColourWheel
|
||
| WidgetKind::GradientHandle
|
||
| WidgetKind::BrushMask
|
||
| WidgetKind::WhitePoint => false,
|
||
}
|
||
}
|
||
|
||
/// The panel model for a set of capabilities.
|
||
///
|
||
/// Free-standing rather than a method, and that is the point: it needs no GPU,
|
||
/// no decoded image and no session, so the whole descriptor-to-panel path can
|
||
/// be exercised against a hand-built capability list. That is what the
|
||
/// FR-DEV-3c acceptance test asks for — an operation the frontend has never
|
||
/// heard of appearing in a generated panel — and it cannot be asserted at all
|
||
/// if generating a row requires a device.
|
||
///
|
||
/// `#[cfg(test)]` since the panel began passing the selected curve down: the
|
||
/// session always has one to pass, and a wrapper that quietly picked the first
|
||
/// would be a second answer to a question the session already answers.
|
||
#[cfg(test)]
|
||
pub(crate) fn rows_from(caps: &[OpCapability]) -> Vec<ParamRow> {
|
||
rows_filtered(caps, |_| true, 0)
|
||
}
|
||
|
||
/// The panel model for the capabilities `keep` accepts.
|
||
///
|
||
/// **`op_index` counts over every capability, not over the kept ones.** It is
|
||
/// how a row routes back to the core, so filtering must not renumber it — a
|
||
/// row that survived a filter has to still name the operation it came from.
|
||
/// `group_head` is the opposite: a position within the *emitted* rows, because
|
||
/// the panel walks back to it through the model it was given.
|
||
///
|
||
/// Getting that backwards is how a slider ends up driving a different
|
||
/// operation, which is the kind of fault that looks like a rendering bug.
|
||
///
|
||
/// `curve_channel` is which subject a multi-subject widget is showing — the
|
||
/// tone curve's four curves are one plot with a selector over it. It is passed
|
||
/// in rather than read from anywhere because this function is deliberately
|
||
/// free-standing: the descriptor-to-panel path has to be exercisable against a
|
||
/// hand-built capability list with no session behind it.
|
||
pub(crate) fn rows_filtered(
|
||
caps: &[OpCapability],
|
||
keep: impl Fn(&OpCapability) -> bool,
|
||
curve_channel: usize,
|
||
) -> Vec<ParamRow> {
|
||
let mut rows = Vec::new();
|
||
for (op_index, op) in caps.iter().enumerate() {
|
||
if !keep(op) {
|
||
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 {
|
||
// **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, curve_channel)
|
||
}
|
||
// Canvas-hosted kinds returned above; the rest are not
|
||
// implemented and reached sliders via `choose`.
|
||
WidgetKind::ColourWheel
|
||
| WidgetKind::CropOverlay
|
||
| WidgetKind::GradientHandle
|
||
| WidgetKind::BrushMask
|
||
| WidgetKind::WhitePoint => None,
|
||
};
|
||
if let Some(row) = row {
|
||
rows.push(row);
|
||
continue;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Whether anything in this operation has been touched, aggregated
|
||
// before the rows are built so every row of the group can carry
|
||
// the same answer — the panel's heading is one of them and cannot
|
||
// see the others.
|
||
//
|
||
// Derived here rather than asked of the core: a group is a
|
||
// composition this side invented, so whether one is modified is
|
||
// this side's question to answer (ARCH §4.3a).
|
||
let group_modified = op.params.iter().any(|p| p.value != p.default);
|
||
let group_len = op.params.len() as i32;
|
||
|
||
// The aspect the previous row belonged to, so a run can be told
|
||
// from its continuation. Reset per operation: two operations that
|
||
// happened to facet on the same key are still two groups.
|
||
let mut previous_aspect: Option<&str> = None;
|
||
|
||
for param_index in presentation_order(&op.params) {
|
||
let p = &op.params[param_index];
|
||
// Empty for every kind but `Enum`, which is what the panel
|
||
// keys on to build a segmented control rather than a slider.
|
||
let mut choices: Vec<slint::SharedString> = Vec::new();
|
||
let (kind, min, max, precision, unit) = match &p.kind {
|
||
ParamKind::Scalar {
|
||
min,
|
||
max,
|
||
unit,
|
||
precision,
|
||
..
|
||
} => (
|
||
"scalar",
|
||
*min,
|
||
*max,
|
||
i32::from(*precision),
|
||
unit_suffix(*unit),
|
||
),
|
||
ParamKind::Bool => ("bool", 0.0, 1.0, 0, ""),
|
||
// The value is a variant index, so the range is the list's
|
||
// own bounds and the precision is whole numbers. Labels are
|
||
// resolved here, against this crate's catalogue, because
|
||
// the core deals in localisation keys only (NFR-A11Y-1).
|
||
ParamKind::Enum { variants } => {
|
||
choices = variants
|
||
.iter()
|
||
.map(|v| labels::resolve(v.0).into())
|
||
.collect();
|
||
("enum", 0.0, variants.len().saturating_sub(1) as f32, 0, "")
|
||
}
|
||
};
|
||
|
||
// A faceted parameter is named by its *subject* — the band —
|
||
// because its aspect is already written above the run it sits
|
||
// in. Unfaceted parameters keep their own label, which is
|
||
// every operation but the mixer.
|
||
let param_label = match &p.facet {
|
||
Some(f) => labels::resolve(f.subject.0),
|
||
None => labels::resolve(p.label.0),
|
||
};
|
||
let aspect = p.facet.as_ref().map(|f| f.aspect.0);
|
||
let starts_facet = aspect.is_some() && aspect != previous_aspect;
|
||
previous_aspect = aspect;
|
||
|
||
rows.push(ParamRow {
|
||
op_index: op_index as i32,
|
||
param_index: param_index as i32,
|
||
op_label: labels::resolve(op.label.0).into(),
|
||
param_label: param_label.into(),
|
||
facet_label: aspect.map(labels::resolve).unwrap_or_default().into(),
|
||
starts_facet,
|
||
// -1 rather than an `Option`, which a Slint struct cannot
|
||
// carry: 0° is red, so no value in range can stand for
|
||
// "no swatch".
|
||
swatch_hue: p.facet.as_ref().and_then(|f| f.subject_hue).unwrap_or(-1.0),
|
||
group_head: group_head as i32,
|
||
group_len,
|
||
group_modified,
|
||
kind: kind.into(),
|
||
value: p.value,
|
||
default_value: p.default,
|
||
minimum: min,
|
||
maximum: max,
|
||
precision,
|
||
unit: unit.into(),
|
||
// Only curve rows carry points.
|
||
points: no_points(),
|
||
// The shared empty model unless this row really has choices —
|
||
// see `no_choices` for why the identity matters.
|
||
choices: if choices.is_empty() {
|
||
no_choices()
|
||
} else {
|
||
slint::ModelRc::new(slint::VecModel::from(choices))
|
||
},
|
||
});
|
||
}
|
||
}
|
||
rows
|
||
}
|
||
|
||
/// One run of a curve widget's parameters: the points of a single curve.
|
||
///
|
||
/// A widget may span several curves — the tone curve is one plot over a master
|
||
/// curve and three colour channels — and it says so the way the colour mixer
|
||
/// says it has twelve bands: by faceting each parameter with the *subject* it
|
||
/// acts on. Consecutive parameters sharing a subject are one curve.
|
||
struct CurveRun {
|
||
/// The subject's localisation key, or `None` where the widget's parameters
|
||
/// carry no facet at all and are therefore a single unnamed curve.
|
||
subject: Option<&'static str>,
|
||
/// Where this run's points begin in the operation's parameter list. What
|
||
/// a drag routes back through, so it must be a position in `op.params`
|
||
/// and not in the presentation's list.
|
||
base: usize,
|
||
/// How many coordinates it holds.
|
||
len: usize,
|
||
}
|
||
|
||
/// TRACES: FR-DEV-3a
|
||
/// The curves a curve widget spans, in the order the operation declares them.
|
||
///
|
||
/// **This is the whole of the panel's knowledge of colour channels: none.** It
|
||
/// groups by whatever subject the parameters carry, so an operation offering a
|
||
/// master curve and three channels gets a four-way selector, one offering a
|
||
/// single unfaceted curve gets no selector at all, and one that grows a fifth
|
||
/// curve tomorrow needs no change here.
|
||
///
|
||
/// Returns `None` where the parameters do not look like point coordinates —
|
||
/// an odd count, a run that is not contiguous in the capability list — in
|
||
/// which case the caller falls back to sliders rather than drawing a widget
|
||
/// over a layout it has guessed at.
|
||
fn curve_runs(op: &OpCapability, presentation: &Presentation) -> Option<Vec<CurveRun>> {
|
||
// Points are x/y pairs, so an odd count means the operation and this code
|
||
// disagree about the layout.
|
||
if presentation.params.len() < 2 || !presentation.params.len().is_multiple_of(2) {
|
||
log::warn!("{}: curve widget needs an even parameter count", op.id);
|
||
return None;
|
||
}
|
||
|
||
let mut runs: Vec<CurveRun> = Vec::new();
|
||
for id in presentation.params {
|
||
// The widget addresses points by offset from the first of its run, so
|
||
// a run has to be contiguous in the capability list.
|
||
let at = op.params.iter().position(|p| p.id == *id)?;
|
||
let subject = op.params[at].facet.as_ref().map(|f| f.subject.0);
|
||
|
||
match runs.last_mut() {
|
||
Some(run) if run.subject == subject && run.base + run.len == at => run.len += 1,
|
||
_ => runs.push(CurveRun {
|
||
subject,
|
||
base: at,
|
||
len: 1,
|
||
}),
|
||
}
|
||
}
|
||
|
||
if runs.iter().any(|r| !r.len.is_multiple_of(2)) {
|
||
log::warn!("{}: a curve's points are not contiguous", op.id);
|
||
return None;
|
||
}
|
||
Some(runs)
|
||
}
|
||
|
||
/// One row standing for a whole curve.
|
||
///
|
||
/// `channel` picks which of the widget's curves is plotted; it is clamped
|
||
/// rather than validated, because the selection is interface state that
|
||
/// outlives a change of photograph and the new image's operation may have
|
||
/// fewer curves than the old one's.
|
||
///
|
||
/// Returns `None` if the operation's parameters do not look like point
|
||
/// coordinates, in which case the caller falls back to sliders rather than
|
||
/// rendering a broken widget.
|
||
fn curve_row(
|
||
op_index: usize,
|
||
group_head: usize,
|
||
op: &OpCapability,
|
||
presentation: &Presentation,
|
||
channel: usize,
|
||
) -> Option<ParamRow> {
|
||
let runs = curve_runs(op, presentation)?;
|
||
let run = runs.get(channel.min(runs.len().saturating_sub(1)))?;
|
||
|
||
let points: Vec<f32> = op.params[run.base..run.base + run.len]
|
||
.iter()
|
||
.map(|p| p.value)
|
||
.collect();
|
||
|
||
Some(ParamRow {
|
||
op_index: op_index as i32,
|
||
// The first point parameter *of the curve on show*; the widget offsets
|
||
// from here, so switching curve is what re-points the drag.
|
||
param_index: run.base as i32,
|
||
op_label: labels::resolve(op.label.0).into(),
|
||
param_label: String::new().into(),
|
||
// A widget spanning a whole operation is not a row in anyone's
|
||
// grid, so it heads no run and carries no swatch.
|
||
facet_label: String::new().into(),
|
||
starts_facet: false,
|
||
swatch_hue: -1.0,
|
||
group_head: group_head as i32,
|
||
// One widget standing for every parameter of the operation, so
|
||
// the group it heads is itself and nothing else.
|
||
group_len: 1,
|
||
group_modified: op.params.iter().any(|p| p.value != p.default),
|
||
kind: "curve".into(),
|
||
value: 0.0,
|
||
default_value: 0.0,
|
||
minimum: 0.0,
|
||
maximum: 1.0,
|
||
precision: 4,
|
||
unit: String::new().into(),
|
||
points: slint::ModelRc::new(slint::VecModel::from(points)),
|
||
// A curve is not a choice between named alternatives. The curves it
|
||
// can switch between are named on the panel rather than on the row —
|
||
// see `DevelopSession::curve_channels` for why they cannot ride here.
|
||
choices: no_choices(),
|
||
})
|
||
}
|
||
|
||
impl DevelopSession {
|
||
/// TRACES: FR-DEV-3
|
||
/// The names of the curves the widget can switch between.
|
||
///
|
||
/// Empty where there is only one, which is also the answer for a frontend
|
||
/// with no curve at all: a selector over a single choice is a row of
|
||
/// nothing.
|
||
///
|
||
/// **Derived from the facets, so nothing here names a colour channel.**
|
||
/// The operation says its forty points are one control applied to four
|
||
/// subjects and publishes a localisation key for each; this resolves the
|
||
/// keys and hands over four words. An operation that grew a fifth curve
|
||
/// would appear here on its own.
|
||
///
|
||
/// A panel property rather than a field on the curve's `ParamRow`, and the
|
||
/// reason is Slint's: a row's models are compared by identity, so a fresh
|
||
/// list of names built on every parameter event would make the row look
|
||
/// changed every time, and rewriting a row rebuilds the repeater item
|
||
/// underneath it — destroying the `TouchArea` holding the drag in
|
||
/// progress. The same hazard `rows`'s in-place point update exists to
|
||
/// avoid. Nothing in this list is a drag target, so up here it is safe to
|
||
/// replace wholesale, exactly as [`Self::curve_samples`] is.
|
||
pub fn curve_channels(&self) -> Vec<String> {
|
||
for op in &self.scoped_capabilities() {
|
||
let Some(presentation) = &op.presentation else {
|
||
continue;
|
||
};
|
||
if presentation.choose(supported) != Some(WidgetKind::ToneCurve) {
|
||
continue;
|
||
}
|
||
let Some(runs) = curve_runs(op, presentation) else {
|
||
continue;
|
||
};
|
||
if runs.len() < 2 {
|
||
continue;
|
||
}
|
||
return runs
|
||
.iter()
|
||
.map(|r| r.subject.map(labels::resolve).unwrap_or_default())
|
||
.collect();
|
||
}
|
||
Vec::new()
|
||
}
|
||
|
||
/// Which curve the widget is plotting, as an index into
|
||
/// [`Self::curve_channels`].
|
||
pub fn curve_channel(&self) -> i32 {
|
||
self.curve_channel as i32
|
||
}
|
||
|
||
/// Plot a different one of the operation's curves.
|
||
///
|
||
/// Out-of-range indices are ignored rather than clamped: the only thing
|
||
/// that can send one is a stale interface event, and quietly moving the
|
||
/// selection somewhere the user did not point is worse than doing nothing.
|
||
pub fn set_curve_channel(&mut self, index: i32) {
|
||
let Ok(index) = usize::try_from(index) else {
|
||
return;
|
||
};
|
||
if index < self.curve_channels().len() {
|
||
self.curve_channel = index;
|
||
}
|
||
}
|
||
|
||
/// The plotted curve's shape, sampled for drawing.
|
||
///
|
||
/// Evaluated with `dr_pipeline`'s own spline, so the line the user drags
|
||
/// is the line the shader applies. The alternative — reading the curve
|
||
/// back off the GPU — is the round-trip ARCH §6.1 forbids, to draw a
|
||
/// polyline.
|
||
///
|
||
/// The line drawn is the *selected* curve's own shape, not the composition
|
||
/// of it with the master. Two curves overlaid on one grid is a plot of two
|
||
/// things, and the one being dragged has to be the one whose points are
|
||
/// under the pointer.
|
||
pub fn curve_samples(&self) -> Vec<f32> {
|
||
const SAMPLES: usize = 96;
|
||
|
||
// The selection is an index over the subjects the panel found, which
|
||
// for this operation is its channel order. Clamped rather than
|
||
// trusted: a selection made on one photograph outlives the change to
|
||
// the next.
|
||
let channel = curve::Channel::ALL[self.curve_channel.min(curve::CHANNELS - 1)];
|
||
|
||
let mut xs = [0.0f32; curve::POINTS];
|
||
let mut ys = [0.0f32; curve::POINTS];
|
||
let mut found = false;
|
||
|
||
for cap in self.graph.capabilities() {
|
||
if cap.id != curve::ID {
|
||
continue;
|
||
}
|
||
found = true;
|
||
// By id rather than by position, so which curve is plotted is
|
||
// decided by naming it and not by arithmetic over the parameter
|
||
// list.
|
||
let value = |id| {
|
||
cap.params
|
||
.iter()
|
||
.find(|p| p.id == id)
|
||
.map_or(0.0, |p| p.value)
|
||
};
|
||
for i in 0..curve::POINTS {
|
||
xs[i] = value(curve::coordinate(channel, i, curve::Axis::X));
|
||
ys[i] = value(curve::coordinate(channel, i, curve::Axis::Y));
|
||
}
|
||
}
|
||
if !found {
|
||
return Vec::new();
|
||
}
|
||
|
||
// Sorted the same way the operation sorts before handing points to
|
||
// the shader, or a dragged-past point would draw differently from
|
||
// how it renders.
|
||
sort_with_gap(&mut xs);
|
||
|
||
(0..SAMPLES)
|
||
.map(|i| {
|
||
let x = i as f32 / (SAMPLES - 1) as f32;
|
||
curve::evaluate(&xs, &ys, x).clamp(0.0, 1.0)
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// 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.scoped_capabilities();
|
||
let Some(cap) = usize::try_from(op_index).ok().and_then(|i| caps.get(i)) else {
|
||
return;
|
||
};
|
||
if self.active_mask.is_some() {
|
||
let params: Vec<_> = cap.params.iter().map(|p| (p.id, p.default)).collect();
|
||
let id = cap.id.0;
|
||
if let Some(layer) = self.active_layer_mut() {
|
||
for (param, default) in params {
|
||
layer.set_param(id, param, default);
|
||
}
|
||
}
|
||
self.history.record(&self.graph, Edit::Discrete);
|
||
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;
|
||
};
|
||
if self.active_mask.is_some() {
|
||
if let Some(layer) = self.active_layer_mut() {
|
||
layer.set_param(op.0, param, value);
|
||
}
|
||
// Coalesced the same way a global drag is: a slider dragged across
|
||
// a masked layer is still one gesture and must undo as one.
|
||
let edit = Edit::for_param(&self.graph, op, param);
|
||
self.history.record(&self.graph, edit);
|
||
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);
|
||
}
|
||
|
||
/// Rasterise the current mask stack, if there is one.
|
||
///
|
||
/// Returns `None` for a stack with no active layers, which is the common
|
||
/// case and the one that must cost nothing: the adjust pass then binds its
|
||
/// own placeholder and the generated shader has no layer block to read it
|
||
/// with.
|
||
/// Render `shader` at `w`×`h` with this edit's masks bound.
|
||
///
|
||
/// **Every path that produces pixels must come through here.** The
|
||
/// generated shader always declares the mask binding and always emits a
|
||
/// layer block for each active layer; binding the empty placeholder
|
||
/// instead multiplies every one of them by zero. That is not an error and
|
||
/// logs nothing — the local adjustments simply are not there. Exports and
|
||
/// thumbnails both did exactly that.
|
||
///
|
||
/// The mask array is rasterised in source space at proxy size and sampled
|
||
/// through the framing map, so one array is correct at every output size:
|
||
/// a 256px thumbnail and a 24 MP export bind the same texture.
|
||
///
|
||
/// **And the detail stage with it.** The neighbourhood operations — noise
|
||
/// reduction, capture sharpening, and the rest of FR-DEV-3's kernels —
|
||
/// cannot be fused into the single dispatch, so an edit using one composes
|
||
/// a fused pass that hands on *linear* values and a chain of passes that
|
||
/// finishes the job (see `dr_pipeline::detail`). Those two halves must be
|
||
/// composed from one graph and dispatched together, or the fused shader's
|
||
/// storage format does not match the texture bound to it; going through
|
||
/// `render_detailed` here is what makes that true of every path at once.
|
||
/// It falls through to the plain render when the chain is empty, which is
|
||
/// almost every edit, so this costs nothing to the frames that do not
|
||
/// need it.
|
||
///
|
||
/// `space` has to be the space `shader` was composed for. It is the last
|
||
/// pass of the detail chain that performs the output transform when there
|
||
/// is one, so the two would otherwise be free to disagree about which
|
||
/// primaries the file is in — and the result would be a correctly
|
||
/// labelled file with the wrong colours in it (FR-EXP-2).
|
||
fn render_with_masks(
|
||
&mut self,
|
||
shader: &dr_pipeline::operation::ComposedShader,
|
||
w: u32,
|
||
h: u32,
|
||
space: dr_types::ColourSpace,
|
||
) -> Result<(), String> {
|
||
let ctx = self.ctx.clone();
|
||
self.ensure_subject_fields(&ctx);
|
||
|
||
let masks = self
|
||
.rasterise_masks()
|
||
.then(|| self.masks.as_ref().and_then(|p| p.array()))
|
||
.flatten();
|
||
|
||
// The neighbourhood stage, composed at the size actually being drawn.
|
||
//
|
||
// It has to be composed *per render* rather than cached with the edit,
|
||
// because a kernel is the one thing in this pipeline that is not
|
||
// scale-free: a sharpening radius is stated in source pixels and the
|
||
// develop view renders at whatever the viewport needs (FR-DSP-1), so
|
||
// the conversion is different for the canvas, the thumbnail and the
|
||
// export. `render_scale` works the ratio out from the framing, so a
|
||
// crop and a zoom are already accounted for, and zooming to 1:1
|
||
// restores an exact preview with no second render path to maintain.
|
||
//
|
||
// Empty for every edit with no active neighbourhood operation — which
|
||
// is almost all of them — and `render_detailed` then falls straight
|
||
// through to the single masked dispatch this used to call.
|
||
let scale = self.graph.render_scale(self.demosaiced.size(), (w, h));
|
||
let detail = self.graph.compose_detail_for(scale, space);
|
||
// Detail passes read what the colour pass wrote, so the key they are
|
||
// cached against is the colour key: moving a sharpening slider re-runs
|
||
// this stage and not the fused one (FR-DEV-3d).
|
||
let colour_key = self
|
||
.graph
|
||
.invalidation()
|
||
.through(dr_pipeline::Affects::Colour);
|
||
|
||
self.adjust
|
||
.render_detailed(&self.demosaiced, shader, w, h, masks, &detail, colour_key)
|
||
.map(|_| ())
|
||
.map_err(|e| e.to_string())
|
||
}
|
||
|
||
/// Returns whether the array is now valid for the current stack.
|
||
///
|
||
/// Split from reading the array back because the render below needs
|
||
/// `self.adjust` mutably while holding `self.masks` immutably. Those are
|
||
/// disjoint fields and the borrow checker will allow it — but only when
|
||
/// each is reached directly rather than through a method taking `self`.
|
||
/// What the uploaded distance fields depend on.
|
||
///
|
||
/// The instance each layer names, and the morphology that *rebuilds* a
|
||
/// field rather than offsetting it. Nothing else: see `subject_key`.
|
||
fn subject_signature(&self) -> u64 {
|
||
use dr_pipeline::mask::{MaskSource, Morphology};
|
||
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
|
||
let mut mix = |v: u64| {
|
||
for byte in v.to_le_bytes() {
|
||
h ^= byte as u64;
|
||
h = h.wrapping_mul(0x1000_0000_01b3);
|
||
}
|
||
};
|
||
|
||
for layer in self.graph.masks().active() {
|
||
match &layer.source {
|
||
MaskSource::Subject { index, .. } => {
|
||
mix(1);
|
||
mix(*index as u64);
|
||
// Only the compound operations change the field itself.
|
||
if layer.morphology.is_compound() {
|
||
mix(match layer.morphology {
|
||
Morphology::Close => 2,
|
||
Morphology::Open => 3,
|
||
_ => 0,
|
||
});
|
||
mix(layer.morph_radius.to_bits() as u64);
|
||
}
|
||
}
|
||
_ => mix(0),
|
||
}
|
||
}
|
||
h
|
||
}
|
||
|
||
/// Rebuild the distance fields if anything they depend on moved.
|
||
fn ensure_subject_fields(&mut self, ctx: &GpuContext) {
|
||
use dr_pipeline::mask::MaskSource;
|
||
|
||
let key = self.subject_signature();
|
||
if key == self.subject_key && self.subjects.is_some() {
|
||
return;
|
||
}
|
||
|
||
let Some(seg) = self.segmentation.as_ref() else {
|
||
return;
|
||
};
|
||
let (pw, ph) = seg.proxy_size();
|
||
|
||
// In `active()` order, because that is the order the rasteriser walks
|
||
// and the order it indexes these by.
|
||
let mut fields: Vec<Vec<f32>> = Vec::new();
|
||
for layer in self.graph.masks().active() {
|
||
let field = match &layer.source {
|
||
MaskSource::Subject { index, .. } => seg
|
||
.instance_mask(*index as usize)
|
||
.map(|coverage| {
|
||
dr_segment::Shaped::build(
|
||
coverage,
|
||
pw,
|
||
ph,
|
||
128,
|
||
morphology_for(layer.morphology),
|
||
// Radii are fractions of the shorter edge; the
|
||
// field is in proxy pixels.
|
||
layer.morph_radius * pw.min(ph) as f32,
|
||
)
|
||
.distance
|
||
})
|
||
.unwrap_or_default(),
|
||
// A placeholder of the right size, so the slot indices line up
|
||
// with `active()` whatever mix of sources the stack holds.
|
||
_ => vec![-1.0; pw * ph],
|
||
};
|
||
fields.push(field);
|
||
}
|
||
|
||
if fields.is_empty() {
|
||
self.subjects = None;
|
||
self.subject_key = key;
|
||
return;
|
||
}
|
||
|
||
let refs: Vec<&[f32]> = fields.iter().map(|f| f.as_slice()).collect();
|
||
self.subjects = dr_gpu::SubjectMasks::upload(ctx, &refs, pw as u32, ph as u32)
|
||
.inspect_err(|e| log::warn!("could not upload the subject fields: {e}"))
|
||
.ok();
|
||
self.subject_key = key;
|
||
}
|
||
|
||
fn rasterise_masks(&mut self) -> bool {
|
||
if self.graph.masks().is_neutral() {
|
||
return false;
|
||
}
|
||
// **Source space, at the segmentation's proxy size** — not the
|
||
// viewport's. The generated shader samples this after the framing map,
|
||
// so a mask drawn here stays on the photograph through a zoom, a pan
|
||
// and a crop. Rasterising at viewport size, as this first did, pinned
|
||
// the mask to the screen instead: zooming slid the picture underneath
|
||
// one that stayed put.
|
||
//
|
||
// It also means the array does not reallocate when the window
|
||
// resizes, and does not need redrawing when the view moves.
|
||
let (pw, ph) = self.mask_raster_size();
|
||
let subjects = self.subjects.as_ref();
|
||
|
||
// **Built here, not in `segment`.** A gradient needs no segmentation —
|
||
// a graduated filter over a sky never had to know what a sky is — but
|
||
// the rasteriser was only ever constructed on the way out of one, so
|
||
// adding a gradient to a photograph nobody had segmented produced an
|
||
// array that was never rasterised and a layer that drew nothing at
|
||
// all. Silently: the generated shader still emits the layer's block
|
||
// and the empty placeholder multiplies it by zero, which is the same
|
||
// failure exports and thumbnails had.
|
||
//
|
||
// The shader compile this costs is paid once, on the first frame after
|
||
// the first mask is added — a button press, not a frame anyone is
|
||
// dragging through. `is_neutral` above is what keeps it off the path
|
||
// of every photograph that has no local adjustment at all.
|
||
if self.masks.is_none() {
|
||
let ctx = self.ctx.clone();
|
||
self.masks = dr_gpu::MaskPass::new(&ctx)
|
||
.inspect_err(|e| log::warn!("no mask rasteriser on this device: {e}"))
|
||
.ok();
|
||
}
|
||
let Some(pass) = self.masks.as_mut() else {
|
||
return false;
|
||
};
|
||
// No label field: region masks were the watershed's, and nothing
|
||
// produces one any more. A stored layer that still names regions is
|
||
// skipped by the rasteriser rather than drawn wrong.
|
||
pass.render(self.graph.masks(), None, subjects, pw, ph)
|
||
.inspect_err(|e| log::warn!("mask rasterisation failed: {e}"))
|
||
.is_ok()
|
||
}
|
||
|
||
/// The size the mask array is rasterised at, in source space.
|
||
///
|
||
/// **A property of the photograph, not of the segmentation.** The two come
|
||
/// out the same because both are the source scaled to `SEGMENT_PROXY_EDGE`,
|
||
/// and they have to: a subject layer's distance field is built at the
|
||
/// segmentation's proxy and sampled against this array, so the two sizes
|
||
/// agreeing is a requirement rather than a coincidence. Reading the size
|
||
/// *off* the segmentation is what made it look like a dependency, and made
|
||
/// a gradient — which indexes nothing — wait for a model to run.
|
||
///
|
||
/// The aspect must be the source's either way. A gradient's geometry is
|
||
/// measured against the frame's own proportions, so a square array over a
|
||
/// 3:2 photograph would stretch every circle it drew.
|
||
fn mask_raster_size(&self) -> (u32, u32) {
|
||
if let Some(seg) = self.segmentation.as_ref() {
|
||
let (pw, ph) = seg.proxy_size();
|
||
return (pw as u32, ph as u32);
|
||
}
|
||
let (sw, sh) = self.demosaiced.size();
|
||
let scale = (SEGMENT_PROXY_EDGE as f32 / sw.max(sh).max(1) as f32).min(1.0);
|
||
(
|
||
((sw as f32 * scale) as u32).max(1),
|
||
((sh as f32 * scale) as u32).max(1),
|
||
)
|
||
}
|
||
|
||
// ----------------------------------------------------------------------
|
||
// Segmentation (S15, docs/segmentation.md)
|
||
// ----------------------------------------------------------------------
|
||
|
||
/// This session's name, carried by any work started against it.
|
||
pub fn id(&self) -> SessionId {
|
||
self.id
|
||
}
|
||
|
||
/// TRACES: FR-DEV-3
|
||
/// Everything a segmentation needs, so it can be run somewhere else.
|
||
///
|
||
/// Taking the job is cheap — two `Arc` bumps and a texture handle — and
|
||
/// nothing about the session is borrowed past the call, which is what
|
||
/// lets the window go on drawing while the answer is being found.
|
||
pub fn segmentation_job(&self) -> SegmentationJob {
|
||
SegmentationJob {
|
||
ctx: self.ctx.clone(),
|
||
source: self.demosaiced.clone(),
|
||
session: self.id,
|
||
abandon: Abandon::default(),
|
||
}
|
||
}
|
||
|
||
/// TRACES: FR-DEV-3
|
||
/// Take on a segmentation found elsewhere.
|
||
///
|
||
/// The caller is responsible for checking that this result was computed
|
||
/// for *this* session — see [`SegmentationJob::session`]. Nothing here can
|
||
/// tell one photograph's subjects from another's, and a mismatch is
|
||
/// silent: the masks would rasterise, the overlay would draw, and the
|
||
/// outlines would simply follow a subject that is not in the picture.
|
||
pub fn adopt_segmentation(&mut self, found: Segmented) {
|
||
// Kept rather than replaced where there is one already: a second
|
||
// segmentation of the same photograph would otherwise throw away a
|
||
// working rasteriser for an identical one.
|
||
self.masks = self.masks.take().or(found.masks);
|
||
|
||
// The fields themselves are built per *layer*, on demand — there are
|
||
// none yet, and building one per detected object would transform
|
||
// several megapixels for masks the user may never make.
|
||
self.segmentation = Some(found.seg);
|
||
self.subjects = None;
|
||
self.subject_key = 0;
|
||
}
|
||
|
||
/// Find the subjects and take them on, blocking until both are done.
|
||
///
|
||
/// Test-only, and deliberately: a session-shaped blocking call is exactly
|
||
/// the shape that put two thirds of a second on the UI thread in the first
|
||
/// place, and leaving it public would invite the next caller to reach for
|
||
/// it. A test has nothing else to be doing.
|
||
#[cfg(test)]
|
||
fn segment(&mut self, options: &segmentation::Options) -> Result<(), String> {
|
||
if let Some(found) = self.segmentation_job().run(options)? {
|
||
self.adopt_segmentation(found);
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
pub fn has_segmentation(&self) -> bool {
|
||
self.segmentation.is_some()
|
||
}
|
||
|
||
/// The subjects the model recognised, as `(label, confidence)`.
|
||
///
|
||
/// Confidence is shown rather than hidden because the detector is offered
|
||
/// as a shortcut, not as an authority: a 0.42 "dog" is worth listing and
|
||
/// worth flagging, and a list that presented it identically to a 0.95 one
|
||
/// would make the tool look wrong when the guess was merely weak.
|
||
pub fn detected_subjects(&self) -> Vec<(String, f32)> {
|
||
self.segmentation
|
||
.as_ref()
|
||
.map(|s| {
|
||
s.instances()
|
||
.iter()
|
||
.map(|i| (i.class_name.to_string(), i.score))
|
||
.collect()
|
||
})
|
||
.unwrap_or_default()
|
||
}
|
||
|
||
// ----------------------------------------------------------------------
|
||
// The region overlay
|
||
// ----------------------------------------------------------------------
|
||
|
||
pub fn overlay_enabled(&self) -> bool {
|
||
self.show_overlay
|
||
}
|
||
|
||
pub fn set_overlay(&mut self, on: bool) {
|
||
self.show_overlay = on;
|
||
}
|
||
|
||
/// The part of the overlay the view is currently showing, in overlay
|
||
/// pixels: `(x, y, width, height)`.
|
||
///
|
||
/// The overlay is a **source-space** picture, and the canvas beside it
|
||
/// shows whatever the crop, the zoom and the pan selected out of that same
|
||
/// space. Drawn whole, it stays the size of the frame while the photograph
|
||
/// moves underneath — which is exactly the fault this exists to fix.
|
||
///
|
||
/// Reported as a clip rectangle rather than resampled here: the compositor
|
||
/// crops and scales a texture for nothing, where doing it on the CPU would
|
||
/// mean rebuilding a megapixel image on every frame of a drag.
|
||
///
|
||
/// **Known gap.** A quarter turn or a flip permutes the axes, and a clip
|
||
/// rectangle cannot express that — the straightening angle is handled
|
||
/// alongside this, but a quarter-turned frame shows the overlay unturned.
|
||
/// Fixing it properly means running the overlay through the same shader
|
||
/// prologue the image goes through, which is the right answer and a larger
|
||
/// one than this.
|
||
pub fn overlay_clip(&self) -> (i32, i32, i32, i32) {
|
||
let Some(seg) = self.segmentation.as_ref() else {
|
||
return (0, 0, 0, 0);
|
||
};
|
||
let (w, h) = seg.proxy_size();
|
||
let rect = self.graph.framing().visible_rect();
|
||
|
||
// Rounded outward, so half a pixel of rounding never shows as a strip
|
||
// of missing overlay along an edge.
|
||
let x = (rect.x * w as f32).floor().max(0.0) as i32;
|
||
let y = (rect.y * h as f32).floor().max(0.0) as i32;
|
||
let right = ((rect.x + rect.width) * w as f32).ceil().min(w as f32) as i32;
|
||
let bottom = ((rect.y + rect.height) * h as f32).ceil().min(h as f32) as i32;
|
||
|
||
(x, y, (right - x).max(1), (bottom - y).max(1))
|
||
}
|
||
|
||
/// TRACES: FR-DEV-3
|
||
/// A false-coloured picture of what a click can select, for the canvas.
|
||
///
|
||
/// Returned as a CPU image rather than a texture, and deliberately: it is
|
||
/// regenerated only when the segmentation changes, it is proxy-sized
|
||
/// rather than viewport-sized, and the compositor scales and clips it for
|
||
/// free. Putting it on the GPU would buy nothing and add a second texture
|
||
/// to keep in step with the view.
|
||
///
|
||
/// `None` when the overlay is off or nothing has been segmented, so the
|
||
/// caller can bind this straight to an image source.
|
||
pub fn overlay_image(&self) -> Option<slint::Image> {
|
||
if !self.show_overlay {
|
||
return None;
|
||
}
|
||
let (rgba, w, h) = self.segmentation.as_ref()?.overlay_rgba();
|
||
let buffer = slint::SharedPixelBuffer::<slint::Rgba8Pixel>::clone_from_slice(&rgba, w, h);
|
||
Some(slint::Image::from_rgba8(buffer))
|
||
}
|
||
|
||
// ----------------------------------------------------------------------
|
||
// Mask layers
|
||
// ----------------------------------------------------------------------
|
||
|
||
/// The layers, as `(id, name, enabled, is_active_selection)`.
|
||
pub fn mask_layers(&self) -> Vec<(String, String, bool, bool)> {
|
||
self.graph
|
||
.masks()
|
||
.layers()
|
||
.iter()
|
||
.map(|l| {
|
||
(
|
||
l.id.clone(),
|
||
l.display_name().to_string(),
|
||
l.enabled,
|
||
Some(&l.id) == self.active_mask.as_ref(),
|
||
)
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// What kind of mask a layer is — "regions", "linear", "radial".
|
||
pub fn mask_kind(&self, id: &str) -> &'static str {
|
||
self.graph.masks().get(id).map_or("", |l| l.source.kind())
|
||
}
|
||
|
||
pub fn mask_inverted(&self, id: &str) -> bool {
|
||
self.graph.masks().get(id).is_some_and(|l| l.invert)
|
||
}
|
||
|
||
pub fn mask_opacity(&self, id: &str) -> f32 {
|
||
self.graph.masks().get(id).map_or(1.0, |l| l.opacity)
|
||
}
|
||
|
||
/// Whether a layer has any adjustment on it yet.
|
||
///
|
||
/// Distinct from `is_active`, which also asks whether the layer is enabled
|
||
/// and visible. The panel wants specifically "you have made a selection
|
||
/// and not yet done anything with it", because that state looks identical
|
||
/// to a broken mask and is the most likely thing a first-time user hits.
|
||
pub fn mask_is_adjusted(&self, id: &str) -> bool {
|
||
self.graph
|
||
.masks()
|
||
.get(id)
|
||
.is_some_and(|l| l.active_ops().next().is_some())
|
||
}
|
||
|
||
pub fn active_mask(&self) -> Option<&str> {
|
||
self.active_mask.as_deref()
|
||
}
|
||
|
||
/// TRACES: FR-DEV-3 | FR-UI-3
|
||
/// The selected gradient's handles, in fractions of the shown image.
|
||
///
|
||
/// Empty unless a gradient layer is selected, which is what makes this the
|
||
/// panel's whole test for "is there anything to draw on the canvas".
|
||
///
|
||
/// Recomputed on every redraw rather than cached, because the answer
|
||
/// changes with the *view* and not only with the mask: a pan moves every
|
||
/// handle and touches no geometry. Four handles through an affine map is
|
||
/// not work worth caching, and a cache keyed on the wrong thing is how a
|
||
/// handle comes to sit where the mask used to be.
|
||
pub fn gradient_handles(&self) -> Vec<crate::GradientHandle> {
|
||
let Some(layer) = self.active_layer() else {
|
||
return Vec::new();
|
||
};
|
||
let (sw, sh) = self.demosaiced.size();
|
||
crate::gradient::handles(&layer.source, self.graph.framing(), (sw, sh))
|
||
}
|
||
|
||
/// Drag one handle of the selected gradient, from `press` to `now`, both
|
||
/// in fractions of the shown image.
|
||
///
|
||
/// `origin` is the geometry the gesture started from — see
|
||
/// [`crate::gradient::drag`] for why a drag is applied to that rather than
|
||
/// accumulated. Returns it, so the caller can hold it for the rest of the
|
||
/// gesture; `None` when there is no gradient selected to drag.
|
||
pub fn drag_gradient_handle(
|
||
&mut self,
|
||
role: crate::HandleRole,
|
||
origin: Option<&MaskSource>,
|
||
press: (f32, f32),
|
||
now: (f32, f32),
|
||
) -> Option<MaskSource> {
|
||
let (sw, sh) = self.demosaiced.size();
|
||
let framing = *self.graph.framing();
|
||
let id = self.active_mask.clone()?;
|
||
let start = match origin {
|
||
Some(s) => s.clone(),
|
||
None => self.graph.masks().get(&id)?.source.clone(),
|
||
};
|
||
|
||
let moved = crate::gradient::drag(&start, role, press, now, &framing, (sw, sh));
|
||
self.graph.masks_mut().get_mut(&id)?.source = moved;
|
||
// **Nothing recorded here.** A drag delivers a pointer event a frame,
|
||
// and a history step per frame would make undo walk a gesture back
|
||
// pixel by pixel. `Edit` coalesces by operation id and a mask's shape
|
||
// is not an operation, so there is no key to coalesce under — the
|
||
// honest answer is to record once, on release.
|
||
Some(start)
|
||
}
|
||
|
||
/// A handle drag finished: one history step for the whole gesture.
|
||
///
|
||
/// Called on the pointer's release rather than on each move, which is what
|
||
/// makes a drag one decision in the undo stack however many frames it took.
|
||
pub fn commit_gradient_drag(&mut self) {
|
||
self.history.record(&self.graph, Edit::Discrete);
|
||
}
|
||
|
||
/// The selected layer's mask rule, for a caller that has to remember what
|
||
/// a gesture started from.
|
||
pub fn active_mask_source(&self) -> Option<MaskSource> {
|
||
self.active_layer().map(|l| l.source.clone())
|
||
}
|
||
|
||
/// Select a layer for editing, or `None` to return the panel to the
|
||
/// global chain.
|
||
pub fn set_active_mask(&mut self, id: Option<&str>) {
|
||
self.active_mask = id
|
||
.filter(|id| self.graph.masks().get(id).is_some())
|
||
.map(|id| id.to_string());
|
||
}
|
||
|
||
/// Mask the object under a normalised image point.
|
||
///
|
||
/// Clicking the photograph is how a local adjustment begins, so this
|
||
/// creates the layer — requiring "add layer" first would be a step with no
|
||
/// decision in it.
|
||
///
|
||
/// Returns the layer that now holds the selection.
|
||
pub fn select_region_at(&mut self, x: f32, y: f32) -> Option<String> {
|
||
let index = self.segmentation.as_ref()?.instance_at(x, y)?;
|
||
self.add_subject_mask(index)
|
||
}
|
||
|
||
/// Add a layer selecting one detected subject.
|
||
///
|
||
/// The instance's own coverage is the mask, rather than the watershed
|
||
/// regions it overlaps. Snapping to regions was the original design and
|
||
/// it is not currently worth doing: the hierarchy those ids index into
|
||
/// collapses on a photograph (docs/segmentation.md §15), so snapping
|
||
/// would trade the model's approximately-right outline for a
|
||
/// confidently-wrong one.
|
||
pub fn add_subject_mask(&mut self, index: usize) -> Option<String> {
|
||
let seg = self.segmentation.as_ref()?;
|
||
let instance = seg.instances().get(index)?;
|
||
let signature = seg.signature();
|
||
let name = instance.class_name.to_string();
|
||
let score = instance.score;
|
||
|
||
let id = self.graph.masks().next_id();
|
||
let mut layer = MaskLayer::new(
|
||
id.clone(),
|
||
MaskSource::Subject {
|
||
signature,
|
||
index: index as u32,
|
||
class: name.clone(),
|
||
score,
|
||
},
|
||
);
|
||
layer.name = name;
|
||
if !self.graph.masks_mut().push(layer) {
|
||
return None;
|
||
}
|
||
self.active_mask = Some(id.clone());
|
||
self.history.record(&self.graph, Edit::Discrete);
|
||
Some(id)
|
||
}
|
||
|
||
/// Add a gradient layer, which needs no segmentation.
|
||
pub fn add_gradient_mask(&mut self, radial: bool) -> Option<String> {
|
||
let id = self.graph.masks().next_id();
|
||
let source = if radial {
|
||
MaskSource::Radial {
|
||
centre: (0.5, 0.5),
|
||
radii: (0.35, 0.35),
|
||
angle: 0.0,
|
||
feather: 0.5,
|
||
}
|
||
} else {
|
||
MaskSource::Linear {
|
||
centre: (0.5, 0.5),
|
||
angle: std::f32::consts::FRAC_PI_2,
|
||
width: 0.3,
|
||
}
|
||
};
|
||
if !self
|
||
.graph
|
||
.masks_mut()
|
||
.push(MaskLayer::new(id.clone(), source))
|
||
{
|
||
return None;
|
||
}
|
||
self.active_mask = Some(id.clone());
|
||
self.history.record(&self.graph, Edit::Discrete);
|
||
Some(id)
|
||
}
|
||
|
||
pub fn remove_mask(&mut self, id: &str) {
|
||
if self.graph.masks_mut().remove(id).is_some() {
|
||
if self.active_mask.as_deref() == Some(id) {
|
||
self.active_mask = None;
|
||
}
|
||
self.history.record(&self.graph, Edit::Discrete);
|
||
}
|
||
}
|
||
|
||
pub fn set_mask_enabled(&mut self, id: &str, enabled: bool) {
|
||
if let Some(layer) = self.graph.masks_mut().get_mut(id) {
|
||
layer.enabled = enabled;
|
||
self.history.record(&self.graph, Edit::Discrete);
|
||
}
|
||
}
|
||
|
||
pub fn set_mask_invert(&mut self, id: &str, invert: bool) {
|
||
if let Some(layer) = self.graph.masks_mut().get_mut(id) {
|
||
layer.invert = invert;
|
||
self.history.record(&self.graph, Edit::Discrete);
|
||
}
|
||
}
|
||
|
||
/// Edge transition half-width, in fractions of the shorter edge.
|
||
pub fn set_mask_feather(&mut self, id: &str, feather: f32) {
|
||
if let Some(layer) = self.graph.masks_mut().get_mut(id) {
|
||
layer.feather = feather.clamp(0.0, 1.0);
|
||
self.history
|
||
.record(&self.graph, Edit::Op(OpId("mask-feather")));
|
||
}
|
||
}
|
||
|
||
pub fn set_mask_falloff(&mut self, id: &str, index: usize) {
|
||
use dr_pipeline::mask::Falloff;
|
||
let Some(&falloff) = Falloff::ALL.get(index) else {
|
||
return;
|
||
};
|
||
if let Some(layer) = self.graph.masks_mut().get_mut(id) {
|
||
layer.falloff = falloff;
|
||
self.history.record(&self.graph, Edit::Discrete);
|
||
}
|
||
}
|
||
|
||
pub fn set_mask_morphology(&mut self, id: &str, index: usize) {
|
||
use dr_pipeline::mask::Morphology;
|
||
let Some(&morphology) = Morphology::ALL.get(index) else {
|
||
return;
|
||
};
|
||
if let Some(layer) = self.graph.masks_mut().get_mut(id) {
|
||
layer.morphology = morphology;
|
||
// Picking an operation with no amount set would appear to do
|
||
// nothing, and the user would reasonably conclude it is broken.
|
||
if morphology != Morphology::None && layer.morph_radius <= 0.0 {
|
||
layer.morph_radius = 0.006;
|
||
}
|
||
self.history.record(&self.graph, Edit::Discrete);
|
||
}
|
||
}
|
||
|
||
pub fn set_mask_morph_radius(&mut self, id: &str, radius: f32) {
|
||
if let Some(layer) = self.graph.masks_mut().get_mut(id) {
|
||
layer.morph_radius = radius.clamp(0.0, 1.0);
|
||
self.history
|
||
.record(&self.graph, Edit::Op(OpId("mask-morph")));
|
||
}
|
||
}
|
||
|
||
/// Which falloff a layer uses, as an index into `Falloff::ALL`.
|
||
pub fn mask_falloff(&self, id: &str) -> usize {
|
||
use dr_pipeline::mask::Falloff;
|
||
self.graph.masks().get(id).map_or(0, |l| {
|
||
Falloff::ALL
|
||
.iter()
|
||
.position(|&f| f == l.falloff)
|
||
.unwrap_or(0)
|
||
})
|
||
}
|
||
|
||
pub fn mask_morphology(&self, id: &str) -> usize {
|
||
use dr_pipeline::mask::Morphology;
|
||
self.graph.masks().get(id).map_or(0, |l| {
|
||
Morphology::ALL
|
||
.iter()
|
||
.position(|&m| m == l.morphology)
|
||
.unwrap_or(0)
|
||
})
|
||
}
|
||
|
||
pub fn mask_feather(&self, id: &str) -> f32 {
|
||
self.graph.masks().get(id).map_or(0.0, |l| l.feather)
|
||
}
|
||
|
||
pub fn mask_morph_radius(&self, id: &str) -> f32 {
|
||
self.graph.masks().get(id).map_or(0.0, |l| l.morph_radius)
|
||
}
|
||
|
||
/// Whether the edge controls apply to this layer.
|
||
///
|
||
/// Only sources that go through the distance field. A gradient carries its
|
||
/// own falloff in its geometry, so offering a second one would be two
|
||
/// controls fighting over the same edge.
|
||
pub fn mask_is_shapeable(&self, id: &str) -> bool {
|
||
use dr_pipeline::mask::MaskSource;
|
||
self.graph.masks().get(id).is_some_and(|l| {
|
||
matches!(
|
||
l.source,
|
||
MaskSource::Subject { .. } | MaskSource::Regions { .. }
|
||
)
|
||
})
|
||
}
|
||
|
||
pub fn set_mask_opacity(&mut self, id: &str, opacity: f32) {
|
||
if let Some(layer) = self.graph.masks_mut().get_mut(id) {
|
||
layer.opacity = opacity.clamp(0.0, 1.0);
|
||
// `Op` rather than `Discrete`: opacity is dragged, and a drag is
|
||
// one decision however many values it passes through. `Discrete`
|
||
// would put every intermediate position on the undo stack.
|
||
self.history
|
||
.record(&self.graph, Edit::Op(OpId("mask-opacity")));
|
||
}
|
||
}
|
||
|
||
/// Whether a layer's region ids belong to a segmentation other than the
|
||
/// one currently loaded — a mask restored from a sidecar written under
|
||
/// different tuning.
|
||
pub fn mask_is_stale(&self, id: &str) -> bool {
|
||
let Some(layer) = self.graph.masks().get(id) else {
|
||
return false;
|
||
};
|
||
match self.segmentation.as_ref() {
|
||
Some(seg) => layer.is_stale(seg.signature()),
|
||
// Nothing loaded to compare against. Not stale, just unrenderable
|
||
// — the distinction matters because "stale" invites the user to
|
||
// recompute the selection and this only needs the segmentation
|
||
// running.
|
||
None => false,
|
||
}
|
||
}
|
||
|
||
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. Taken from whichever scope `rows`
|
||
// last described — the indices the interface is holding are positions
|
||
// in *that* list, and reading the global chain while a layer is
|
||
// selected would map a slider onto a different operation.
|
||
// The *unfiltered* scoped list, because `op_index` counts over every
|
||
// capability — see `rows_filtered`. Indexing a filtered list here is
|
||
// how a slider would drive the wrong operation once a tab is chosen.
|
||
let caps = self.scoped_capabilities();
|
||
let op = caps.get(usize::try_from(op_index).ok()?)?;
|
||
let param = op.params.get(usize::try_from(param_index).ok()?)?;
|
||
Some((op.id, param.id))
|
||
}
|
||
|
||
/// TRACES: FR-DSP-1 | AC-8
|
||
/// Render at the requested display size and hand back a Slint image.
|
||
///
|
||
/// Renders at *viewport* resolution rather than sensor resolution, which
|
||
/// is what keeps slider interaction inside the frame budget on a 24 MP
|
||
/// file (FR-DSP-1).
|
||
///
|
||
/// **The image is the texture, not a copy of it.** This used to end in a
|
||
/// `read_output` into a `SharedPixelBuffer` — the GPU→CPU→GPU round-trip
|
||
/// ARCH §6.1 forbids and AC-8 asserts against, measured at ~7 ms at 4K
|
||
/// against a 0.28 ms compute pass. Spike S1 replaced it with
|
||
/// `slint::Image::try_from`, which wraps the texture where it already is.
|
||
/// The `clone` below is a refcount on the wgpu handle, not on the pixels.
|
||
///
|
||
/// This only works because the compositor is drawing with the same device
|
||
/// the pass wrote with; see `shared_gpu` in the crate root for how that is
|
||
/// arranged, and note that nothing here can detect it having gone wrong —
|
||
/// a texture from a foreign device is a runtime fault on a real screen,
|
||
/// which is why the arrangement is made once at startup and never again.
|
||
pub fn render(&mut self, width: u32, height: u32) -> Result<slint::Image, String> {
|
||
// Fit the render to the viewport while preserving aspect, so the
|
||
// pass does no work on pixels the view will letterbox away.
|
||
//
|
||
// Fitted against the *framed* size, not the sensor's: a crop changes
|
||
// the aspect ratio, and fitting the uncropped shape would letterbox
|
||
// to the wrong box and render the crop squashed.
|
||
let (sw, sh) = self.demosaiced.size();
|
||
let (fw, fh) = self.graph.output_size(sw, sh);
|
||
let (w, h) = fit(fw, fh, width.max(1), height.max(1));
|
||
|
||
let shader = self.graph.compose();
|
||
|
||
// Rasterise the masks first: the shader addresses array slices by
|
||
// index, so the array has to describe *this* stack before it is bound.
|
||
self.render_with_masks(&shader, w, h, dr_types::ColourSpace::Srgb)?;
|
||
let texture = self.adjust.output().ok_or("nothing was rendered")?;
|
||
|
||
// The import is fallible on format and usage only, and both are fixed
|
||
// in `AdjustPass`'s texture descriptor — so a failure here is a
|
||
// descriptor that drifted, not anything the caller did. Say that,
|
||
// rather than surfacing "InvalidUsage" to a photographer.
|
||
slint::Image::try_from(texture.clone())
|
||
.map_err(|e| format!("the render target is not importable by the compositor: {e}"))
|
||
}
|
||
|
||
/// TRACES: FR-DSP-7
|
||
/// Count the frame that is currently on the canvas.
|
||
///
|
||
/// **Reads the frame [`Self::render`] last produced rather than rendering
|
||
/// its own.** The histogram has to describe what the photographer is
|
||
/// looking at, and rendering a second time to count it would both cost a
|
||
/// second pass and open the possibility of the two disagreeing.
|
||
///
|
||
/// That the frame is the *displayed* one has two consequences worth being
|
||
/// explicit about. It is in the output colour space, which is what
|
||
/// FR-DSP-7 asks for — the levels counted are the levels the display will
|
||
/// show, so a clipped bin means a highlight that is actually gone rather
|
||
/// than one the transform might still recover. And when the view is zoomed
|
||
/// or cropped it describes the visible region, not the whole file: a
|
||
/// photographer inspecting a highlight at 4× is asking about *that*
|
||
/// highlight, and a histogram of the parts of the frame off screen would
|
||
/// be answering a question nobody asked.
|
||
///
|
||
/// `None` where nothing has been rendered yet, or where the device could
|
||
/// not build the reduction.
|
||
pub fn histogram(&self) -> Option<Histogram> {
|
||
let pass = self.histogram.as_ref()?;
|
||
let frame = self.adjust.output()?;
|
||
pass.compute(frame)
|
||
.inspect_err(|e| log::warn!("histogram failed: {e}"))
|
||
.ok()
|
||
}
|
||
|
||
/// Render the *whole* frame for the crop overlay to be drawn over.
|
||
///
|
||
/// Crop mode cannot use [`Self::render`]: that applies the crop, so the
|
||
/// area being cropped away would not be on screen and there would be
|
||
/// nothing to drag the handles across. This renders as though the crop
|
||
/// were full, and the interface draws the rect and greys the surround.
|
||
///
|
||
/// Zoom is suspended too. Panning a zoomed view while also dragging crop
|
||
/// handles is two conflicting meanings for one drag, and the handles are
|
||
/// placed against the whole frame in any case.
|
||
///
|
||
/// Returns the image together with the size it was rendered at, since the
|
||
/// overlay has to place its rect against exactly those pixels.
|
||
pub fn render_uncropped(
|
||
&mut self,
|
||
width: u32,
|
||
height: u32,
|
||
) -> Result<(slint::Image, u32, u32), String> {
|
||
let saved_crop = self.graph.crop();
|
||
let saved_view = self.graph.framing().view();
|
||
|
||
self.graph.set_crop(CropRect::default());
|
||
self.graph.framing_mut().set_view(CropRect::default());
|
||
|
||
let result = self.render(width, height);
|
||
|
||
// Restored whatever happened: leaving the graph cropped-to-full on a
|
||
// render error would silently discard the user's crop.
|
||
self.graph.set_crop(saved_crop);
|
||
self.graph.framing_mut().set_view(saved_view);
|
||
|
||
let image = result?;
|
||
let (sw, sh) = self.demosaiced.size();
|
||
// The uncropped frame still turns with the quarter turns, so the
|
||
// overlay's box comes from the framing rather than the sensor.
|
||
let (fw, fh) = self.graph.framing().output_size_uncropped(sw, sh);
|
||
let (rw, rh) = fit(fw, fh, width.max(1), height.max(1));
|
||
Ok((image, rw, rh))
|
||
}
|
||
|
||
/// The displayed size, for sizing the viewport.
|
||
///
|
||
/// The *framed* size, not the sensor's: cropping and quarter turns change
|
||
/// the aspect ratio, and a viewport sized to the sensor would letterbox a
|
||
/// cropped image against the wrong shape.
|
||
pub fn source_size(&self) -> (u32, u32) {
|
||
let (w, h) = self.demosaiced.size();
|
||
self.graph.output_size(w, h)
|
||
}
|
||
|
||
/// TRACES: FR-EXP-9
|
||
/// Render at full resolution and hand back the pixels, for an export.
|
||
///
|
||
/// **Not the frame on screen.** [`Self::render`] deliberately renders at
|
||
/// viewport size, which is what keeps a slider inside the frame budget on
|
||
/// a 24 MP file (FR-DSP-1) — and what would make an export of it a soft,
|
||
/// screen-sized file. This renders the framed output size instead, so the
|
||
/// export is the full-quality path FR-EXP-9 requires.
|
||
///
|
||
/// This reads pixels back and [`Self::render`] does not, and that is the
|
||
/// whole distinction AC-8 draws: a file is made of bytes on the CPU and
|
||
/// there is no path to one that avoids the transfer, whereas a frame on
|
||
/// screen had no business making the trip. See `AdjustPass::export_pixels`
|
||
/// for the longer version.
|
||
///
|
||
/// Leaves one of the pass's two targets at full resolution; it is dropped
|
||
/// and reallocated on the second display render after this, since the
|
||
/// other target still holds a viewport-sized texture and comes up first.
|
||
/// Cheaper than keeping a second pass alive for the exports a session
|
||
/// rarely performs.
|
||
///
|
||
/// `space` is the output colour space the file will claim. It is chosen
|
||
/// here rather than at encode time because the conversion happens in the
|
||
/// shader, before the clip to 0..1 — by the time pixels reach the encoder
|
||
/// they are in exactly one space, and the only honest thing left to do is
|
||
/// label them. Asking for the wrong one is a typed error rather than a
|
||
/// mislabelled file (FR-EXP-2).
|
||
pub fn render_for_export(
|
||
&mut self,
|
||
space: dr_types::ColourSpace,
|
||
) -> Result<dr_export::Frame, String> {
|
||
let (sw, sh) = self.demosaiced.size();
|
||
let (w, h) = self.graph.output_size(sw, sh);
|
||
|
||
let shader = self.graph.compose_for(space);
|
||
self.render_with_masks(&shader, w, h, space)?;
|
||
|
||
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())
|
||
}
|
||
|
||
/// TRACES: FR-CAT-9
|
||
/// Render this edit small, for the grid's thumbnail.
|
||
///
|
||
/// **The framed output, not the sensor.** `output_size` is what a crop, a
|
||
/// quarter turn, a flip and a straighten all act on, so a thumbnail taken
|
||
/// from the raw frame would show the grid a photograph the user no longer
|
||
/// has — the right pixels in the wrong shape, still the wrong way up. This
|
||
/// is the same path [`Self::render_for_export`] takes, at a size the store
|
||
/// wants instead of at full resolution.
|
||
///
|
||
/// Always sRGB: this is going into a JPEG in a thumbnail shard that syncs
|
||
/// between devices and is drawn as a cell, not a file the user is
|
||
/// finishing. The wider spaces exist for export and mean nothing here.
|
||
///
|
||
/// Returns width, height and RGBA8.
|
||
pub fn render_thumbnail(&mut self, edge: u32) -> Result<(u32, u32, Vec<u8>), String> {
|
||
let (sw, sh) = self.demosaiced.size();
|
||
let (fw, fh) = self.graph.output_size(sw, sh);
|
||
let (w, h) = fit(fw, fh, edge.max(1), edge.max(1));
|
||
|
||
let shader = self.graph.compose_for(dr_types::ColourSpace::Srgb);
|
||
self.render_with_masks(&shader, w, h, dr_types::ColourSpace::Srgb)?;
|
||
|
||
let (pixels, rw, rh) = self.adjust.export_pixels().map_err(|e| e.to_string())?;
|
||
Ok((rw, rh, pixels))
|
||
}
|
||
|
||
/// The sensor's own dimensions, before framing.
|
||
///
|
||
/// What a crop overlay needs: its handles are placed against the full
|
||
/// frame, since that is what the user is selecting *from*.
|
||
pub fn sensor_size(&self) -> (u32, u32) {
|
||
self.demosaiced.size()
|
||
}
|
||
|
||
/// Whether one source pixel now covers more than one screen pixel.
|
||
///
|
||
/// The question the interface asks to decide how the canvas is *filtered*,
|
||
/// not how it is rendered. Below 1:1 there are more source pixels than
|
||
/// screen pixels and smoothing is what stops the image aliasing; past it
|
||
/// there is no more detail to show, and smoothing only invents values
|
||
/// between real ones — at which point a photographer inspecting focus or
|
||
/// noise wants to see the pixels, not a blur of them.
|
||
///
|
||
/// Measured against the visible region rather than the zoom factor alone,
|
||
/// because the two differ: a 24 MP file in a 1200px viewport is still
|
||
/// showing five sensor pixels per screen pixel at 4×, while a small JPEG is
|
||
/// already magnified at 1×.
|
||
pub fn magnifies_source(&self, viewport_w: u32, viewport_h: u32) -> bool {
|
||
let (sw, sh) = self.demosaiced.size();
|
||
let (fw, fh) = self.graph.output_size(sw, sh);
|
||
let (rw, rh) = fit(fw, fh, viewport_w.max(1), viewport_h.max(1));
|
||
|
||
// How many source pixels lie behind the render target: the framed
|
||
// image narrowed to the region the view selects. The target keeps its
|
||
// size while that region shrinks, which is what raises the ratio.
|
||
let view = self.graph.framing().view();
|
||
let behind_w = f64::from(fw) * f64::from(view.width.max(f32::EPSILON));
|
||
let behind_h = f64::from(fh) * f64::from(view.height.max(f32::EPSILON));
|
||
|
||
// Strictly greater, with a margin: at exactly 1:1 either filter gives
|
||
// the same answer, and flipping mode on a rounding error would make the
|
||
// canvas visibly change character mid-scroll.
|
||
f64::from(rw) > behind_w * 1.001 && f64::from(rh) > behind_h * 1.001
|
||
}
|
||
|
||
/// Set the crop rectangle, in fractions of the source.
|
||
pub fn set_crop(&mut self, rect: CropRect) {
|
||
self.graph.set_crop(rect);
|
||
// Keyed on the operation, not on a parameter: one drag of one handle
|
||
// moves the origin and the extent together.
|
||
self.history
|
||
.record(&self.graph, Edit::Op(dr_pipeline::framing::ID));
|
||
}
|
||
|
||
pub fn crop(&self) -> CropRect {
|
||
self.graph.crop()
|
||
}
|
||
|
||
/// Rotate by quarter turns, wrapping. The rotate-left/right buttons.
|
||
///
|
||
/// The crop travels with the frame rather than staying where it was on
|
||
/// screen. A crop is a decision about *this part of the photograph*, and
|
||
/// leaving the rect in place while the image turns under it would move the
|
||
/// selection onto a different part of the picture — so the rect is turned
|
||
/// by the same quarter and the composition survives the rotation.
|
||
pub fn rotate_quarters(&mut self, turns: i32) {
|
||
let crop = self.graph.crop();
|
||
if !crop.is_full() {
|
||
self.graph.set_crop(rotate_crop(crop, turns));
|
||
}
|
||
self.graph.rotate_quarters(turns);
|
||
self.history.record(&self.graph, Edit::Discrete);
|
||
}
|
||
|
||
/// Straightening, in degrees. Positive turns the image clockwise.
|
||
pub fn angle(&self) -> f32 {
|
||
self.graph.framing().angle()
|
||
}
|
||
|
||
/// Quarter turns clockwise, 0..=3 — for the panel's readout.
|
||
pub fn quarter_turns(&self) -> u8 {
|
||
self.graph.framing().quarter_turns()
|
||
}
|
||
|
||
pub fn flips(&self) -> (bool, bool) {
|
||
self.graph.framing().flips()
|
||
}
|
||
|
||
/// Mirror horizontally, about the frame's vertical centre line.
|
||
pub fn toggle_flip_h(&mut self) {
|
||
let (h, _) = self.graph.framing().flips();
|
||
self.graph.set_param(
|
||
dr_pipeline::framing::ID,
|
||
dr_pipeline::framing::FLIP_H,
|
||
f32::from(u8::from(!h)),
|
||
);
|
||
self.history.record(&self.graph, Edit::Discrete);
|
||
}
|
||
|
||
pub fn toggle_flip_v(&mut self) {
|
||
let (_, v) = self.graph.framing().flips();
|
||
self.graph.set_param(
|
||
dr_pipeline::framing::ID,
|
||
dr_pipeline::framing::FLIP_V,
|
||
f32::from(u8::from(!v)),
|
||
);
|
||
self.history.record(&self.graph, Edit::Discrete);
|
||
}
|
||
|
||
/// Set the straightening angle, in degrees.
|
||
pub fn set_angle(&mut self, degrees: f32) {
|
||
self.graph.set_param(
|
||
dr_pipeline::framing::ID,
|
||
dr_pipeline::framing::ANGLE,
|
||
degrees,
|
||
);
|
||
self.history.record(
|
||
&self.graph,
|
||
Edit::Param(dr_pipeline::framing::ID, dr_pipeline::framing::ANGLE),
|
||
);
|
||
}
|
||
|
||
/// Whether the framing currently changes the image — what lights the
|
||
/// section's modified dot and enables its reset.
|
||
///
|
||
/// Asks whether it *edits*, not whether it is active: a zoomed view makes
|
||
/// the framing active without changing the photograph, and a section that
|
||
/// claimed an edit because the user scrolled would be lying.
|
||
pub fn framing_edits_image(&self) -> bool {
|
||
self.graph.framing().edits_image()
|
||
}
|
||
|
||
/// Return crop, straightening, rotation and flips to neutral, leaving
|
||
/// every colour adjustment alone.
|
||
///
|
||
/// The zoom is deliberately preserved: it is a viewing state, and resetting
|
||
/// the framing is an edit, so throwing away where the user was looking
|
||
/// would be an unrelated second effect.
|
||
pub fn reset_framing(&mut self) {
|
||
let view = self.graph.framing().view();
|
||
self.graph.framing_mut().reset();
|
||
self.graph.framing_mut().set_view(view);
|
||
self.history.record(&self.graph, Edit::Discrete);
|
||
}
|
||
|
||
/// How far the viewport is zoomed in: 1.0 fits the frame, 4.0 is 4×.
|
||
pub fn zoom(&self) -> f32 {
|
||
let v = self.graph.framing().view();
|
||
if v.width <= 0.0 {
|
||
1.0
|
||
} else {
|
||
1.0 / v.width
|
||
}
|
||
}
|
||
|
||
pub fn is_zoomed(&self) -> bool {
|
||
self.graph.framing().is_zoomed()
|
||
}
|
||
|
||
/// Zoom about a point, given in fractions of the *visible* area.
|
||
///
|
||
/// Anchoring matters: zooming about the pointer keeps whatever is under
|
||
/// it stationary, which is what makes a scroll-wheel zoom feel like it is
|
||
/// magnifying the photograph rather than sliding it around.
|
||
///
|
||
/// `factor` multiplies the current zoom — above 1 moves in.
|
||
pub fn zoom_about(&mut self, factor: f32, at_x: f32, at_y: f32) {
|
||
const MAX_ZOOM: f32 = 16.0;
|
||
|
||
let view = self.graph.framing().view();
|
||
let current = if view.width > 0.0 {
|
||
1.0 / view.width
|
||
} else {
|
||
1.0
|
||
};
|
||
let target = (current * factor).clamp(1.0, MAX_ZOOM);
|
||
// Snapped so scrolling back out reliably reaches "fit" rather than
|
||
// stopping a fraction short and leaving the image imperceptibly
|
||
// panned.
|
||
let target = if (target - 1.0).abs() < 0.01 {
|
||
1.0
|
||
} else {
|
||
target
|
||
};
|
||
|
||
let extent = (1.0 / target).clamp(CropRect::MIN_EXTENT, 1.0);
|
||
|
||
// The point under the cursor, in framed coordinates, must land back
|
||
// under the cursor afterwards.
|
||
let anchor_x = view.x + at_x.clamp(0.0, 1.0) * view.width;
|
||
let anchor_y = view.y + at_y.clamp(0.0, 1.0) * view.height;
|
||
|
||
self.set_view_clamped(
|
||
anchor_x - at_x.clamp(0.0, 1.0) * extent,
|
||
anchor_y - at_y.clamp(0.0, 1.0) * extent,
|
||
extent,
|
||
);
|
||
}
|
||
|
||
/// Pan by a fraction of the *visible* area — what a drag reports.
|
||
pub fn pan_by(&mut self, dx: f32, dy: f32) {
|
||
let view = self.graph.framing().view();
|
||
self.set_view_clamped(
|
||
view.x + dx * view.width,
|
||
view.y + dy * view.height,
|
||
view.width,
|
||
);
|
||
}
|
||
|
||
/// Back to fitting the whole frame.
|
||
pub fn reset_zoom(&mut self) {
|
||
self.graph.framing_mut().set_view(CropRect::default());
|
||
}
|
||
|
||
/// Place a square view of `extent`, keeping it inside the frame.
|
||
///
|
||
/// Clamped rather than allowed to run off the edge: panning past the
|
||
/// boundary would show undefined area beside the photograph, which reads
|
||
/// as a rendering fault rather than as the end of the image.
|
||
fn set_view_clamped(&mut self, x: f32, y: f32, extent: f32) {
|
||
let extent = extent.clamp(CropRect::MIN_EXTENT, 1.0);
|
||
let max = 1.0 - extent;
|
||
self.graph.framing_mut().set_view(CropRect {
|
||
x: x.clamp(0.0, max.max(0.0)),
|
||
y: y.clamp(0.0, max.max(0.0)),
|
||
width: extent,
|
||
height: extent,
|
||
});
|
||
}
|
||
|
||
/// The largest centred crop that, at the current straightening angle,
|
||
/// contains no undefined area. What a "straighten and fill" action
|
||
/// applies.
|
||
pub fn max_inscribed_crop(&self) -> CropRect {
|
||
let (w, h) = self.demosaiced.size();
|
||
self.graph.framing().max_inscribed_crop(w, h)
|
||
}
|
||
|
||
/// How many shader pipelines have been compiled. Surfaced so the status
|
||
/// strip can show that slider movement is not recompiling.
|
||
pub fn compiled_pipelines(&self) -> usize {
|
||
self.adjust.cached_pipelines()
|
||
}
|
||
|
||
pub fn is_neutral(&self) -> bool {
|
||
self.graph.is_neutral()
|
||
}
|
||
|
||
/// TRACES: FR-DEV-6
|
||
/// Lift this session's edit onto the clipboard.
|
||
///
|
||
/// Captured at full scope — framing included — because the decision about
|
||
/// what travels is made when the preset is *applied*. Copying, then
|
||
/// changing one's mind about the crop, must not mean copying again.
|
||
pub fn copy_settings(&self) -> Preset {
|
||
Preset::capture(&self.graph)
|
||
}
|
||
|
||
/// TRACES: FR-DEV-6
|
||
/// Replace this session's edit within `scope`.
|
||
///
|
||
/// The panel must be rebuilt from [`Self::rows`] afterwards: a paste moves
|
||
/// values the sliders are showing, and nothing here pushes them.
|
||
pub fn apply_settings(&mut self, preset: &Preset, scope: Scope) {
|
||
preset.apply(&mut self.graph, scope);
|
||
// A paste is undoable, and is the action most in need of it: it
|
||
// replaces everything in scope at once, so getting it wrong costs more
|
||
// than any single control can.
|
||
self.history.record(&self.graph, Edit::Discrete);
|
||
}
|
||
|
||
/// TRACES: FR-CAT-8
|
||
/// Load a stored edit, as read from this image's sidecar.
|
||
///
|
||
/// A replacement rather than an overlay — [`Version::apply`] resets first —
|
||
/// so a version that stores nothing opens the photograph at its defaults
|
||
/// rather than leaving the previous image's exposure standing. The file's
|
||
/// orientation survives it, since that was never an edit.
|
||
pub fn apply_version(&mut self, version: &dr_pipeline::Version) {
|
||
version.apply(&mut self.graph);
|
||
// The stored edit becomes the floor rather than a step. It is not
|
||
// something the user did in this sitting, and an undo that reached
|
||
// behind it would discard a previous session's work in one press —
|
||
// then persist that on the way out, since saving is automatic.
|
||
self.history.reset(&self.graph);
|
||
}
|
||
|
||
/// TRACES: FR-DEV-5
|
||
/// Step the edit back one, returning whether anything moved.
|
||
///
|
||
/// The panel must be rebuilt from [`Self::rows`] afterwards, for the same
|
||
/// reason a paste must: this moves values the controls are showing and
|
||
/// nothing here pushes them.
|
||
pub fn undo(&mut self) -> bool {
|
||
self.history.undo(&mut self.graph)
|
||
}
|
||
|
||
/// TRACES: FR-DEV-5
|
||
/// Step the edit forward one, returning whether anything moved.
|
||
pub fn redo(&mut self) -> bool {
|
||
self.history.redo(&mut self.graph)
|
||
}
|
||
|
||
pub fn can_undo(&self) -> bool {
|
||
self.history.can_undo()
|
||
}
|
||
|
||
pub fn can_redo(&self) -> bool {
|
||
self.history.can_redo()
|
||
}
|
||
}
|
||
|
||
/// Re-express a crop rect after the frame it is measured against turns.
|
||
///
|
||
/// The crop lives in fractions of the *framed* image — the one the quarter
|
||
/// turns have already produced — so turning the frame another quarter leaves
|
||
/// the rect describing the wrong region unless it turns with it. Without this,
|
||
/// rotating a portrait crop on a landscape photograph slides the selection
|
||
/// onto a different part of the picture, which reads as the rotation having
|
||
/// moved the image rather than the frame.
|
||
///
|
||
/// One clockwise quarter takes `(x, y)` to `(1 - y - h, x)` and exchanges the
|
||
/// extents; anticlockwise is the same map run the other way. Applied
|
||
/// `turns.rem_euclid(4)` times so the caller's wrapping and this agree.
|
||
fn rotate_crop(rect: CropRect, turns: i32) -> CropRect {
|
||
let mut r = rect;
|
||
for _ in 0..turns.rem_euclid(4) {
|
||
r = CropRect {
|
||
x: 1.0 - r.y - r.height,
|
||
y: r.x,
|
||
width: r.height,
|
||
height: r.width,
|
||
};
|
||
}
|
||
r.normalised()
|
||
}
|
||
|
||
/// Sort ascending and force a minimum separation.
|
||
///
|
||
/// Mirrors what the curve operation does before handing points to the
|
||
/// shader. Duplicated rather than shared because the operation keeps it
|
||
/// private, and the consequence of drift is only a drawn line that lags the
|
||
/// rendered one by a pixel — not a wrong image.
|
||
fn sort_with_gap(xs: &mut [f32]) {
|
||
const MIN_GAP: f32 = 0.001;
|
||
for i in 1..xs.len() {
|
||
let mut j = i;
|
||
while j > 0 && xs[j - 1] > xs[j] {
|
||
xs.swap(j - 1, j);
|
||
j -= 1;
|
||
}
|
||
}
|
||
for i in 1..xs.len() {
|
||
if xs[i] - xs[i - 1] < MIN_GAP {
|
||
xs[i] = xs[i - 1] + MIN_GAP;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Largest size fitting `(sw, sh)` inside `(max_w, max_h)`, preserving aspect.
|
||
///
|
||
/// Rendering to the letterboxed size rather than the full viewport avoids
|
||
/// shading pixels the view will not show, which at a 3:2 image in a 16:9
|
||
/// window is a fifth of them.
|
||
fn fit(sw: u32, sh: u32, max_w: u32, max_h: u32) -> (u32, u32) {
|
||
if sw == 0 || sh == 0 {
|
||
return (max_w, max_h);
|
||
}
|
||
let scale = (max_w as f32 / sw as f32).min(max_h as f32 / sh as f32);
|
||
// Never upscale past the source: there is no detail to recover, and a
|
||
// 1:1 render is cheaper.
|
||
let scale = scale.min(1.0);
|
||
(
|
||
((sw as f32 * scale).round() as u32).max(1),
|
||
((sh as f32 * scale).round() as u32).max(1),
|
||
)
|
||
}
|
||
|
||
/// The order an operation's parameters are shown in.
|
||
///
|
||
/// Declaration order, unless the operation facets them — in which case
|
||
/// parameters sharing an aspect are brought together, so the panel names
|
||
/// each run once instead of repeating "Hue / Saturation / Luminance"
|
||
/// twelve times over. The colour mixer declares band by band, which is the
|
||
/// order the shader wants; a photographer works channel by channel.
|
||
///
|
||
/// **This is presentation, and so it lives here** (ARCH §4.3a). The core
|
||
/// says which aspect a parameter belongs to; deciding that an aspect is
|
||
/// worth stacking rows by is the panel's composition to make, exactly as
|
||
/// grouping by operation is. Routing is unaffected — `param_index` stays
|
||
/// the position in the capability list however the rows are stacked.
|
||
///
|
||
/// A stable sort by the aspect's first appearance, so an operation with no
|
||
/// facets comes back untouched, and one that mixes plain parameters with
|
||
/// faceted ones keeps the plain ones first and in order.
|
||
fn presentation_order(params: &[dr_pipeline::ParamCapability]) -> Vec<usize> {
|
||
let mut aspects: Vec<&str> = Vec::new();
|
||
let rank: Vec<usize> = params
|
||
.iter()
|
||
.map(|p| match &p.facet {
|
||
None => 0,
|
||
Some(f) => {
|
||
let at = aspects.iter().position(|a| *a == f.aspect.0);
|
||
// First appearance defines the run's place, so the panel's
|
||
// sections come out in the order the operation introduced
|
||
// them rather than alphabetically.
|
||
1 + at.unwrap_or_else(|| {
|
||
aspects.push(f.aspect.0);
|
||
aspects.len() - 1
|
||
})
|
||
}
|
||
})
|
||
.collect();
|
||
|
||
let mut order: Vec<usize> = (0..params.len()).collect();
|
||
order.sort_by_key(|i| rank[*i]);
|
||
order
|
||
}
|
||
|
||
/// Suffix shown after a value. Comes from the descriptor's declared unit, so
|
||
/// this function needs no knowledge of which parameter it is formatting.
|
||
fn unit_suffix(unit: Unit) -> &'static str {
|
||
match unit {
|
||
Unit::None => "",
|
||
Unit::Stops => " EV",
|
||
Unit::Kelvin => " K",
|
||
Unit::Percent => "%",
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use dr_pipeline::EditGraph;
|
||
|
||
/// TRACES: FR-DSP-1 | AC-8
|
||
/// Copy a displayed frame back to the CPU, for assertions and nothing else.
|
||
///
|
||
/// The library has no such function on purpose: S1 removed the display
|
||
/// readback, and AC-8 is the assertion that it stayed removed. A test that
|
||
/// wants to look at the pixels therefore has to do the copy itself, which
|
||
/// is exactly the right shape — the round-trip lives in the test binary
|
||
/// and cannot be reached from a shipping one.
|
||
///
|
||
/// Doubles as the proof: this only compiles because the image *is* a wgpu
|
||
/// texture. Hand it a `SharedPixelBuffer`-backed image and it panics.
|
||
fn read_back(ctx: &GpuContext, image: &slint::Image) -> Vec<u8> {
|
||
let texture = image
|
||
.to_wgpu_29_texture()
|
||
.expect("the develop canvas must be a GPU texture, not a pixel buffer");
|
||
let (w, h) = (texture.width(), texture.height());
|
||
|
||
// Buffer rows must be aligned to COPY_BYTES_PER_ROW_ALIGNMENT.
|
||
let unpadded = w * 4;
|
||
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
|
||
let padded = unpadded.div_ceil(align) * align;
|
||
|
||
let buf = ctx.device.create_buffer(&wgpu::BufferDescriptor {
|
||
label: Some("test-readback"),
|
||
size: u64::from(padded * h),
|
||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||
mapped_at_creation: false,
|
||
});
|
||
|
||
let mut enc = ctx.device.create_command_encoder(&Default::default());
|
||
enc.copy_texture_to_buffer(
|
||
wgpu::TexelCopyTextureInfo {
|
||
texture: &texture,
|
||
mip_level: 0,
|
||
origin: wgpu::Origin3d::ZERO,
|
||
aspect: wgpu::TextureAspect::All,
|
||
},
|
||
wgpu::TexelCopyBufferInfo {
|
||
buffer: &buf,
|
||
layout: wgpu::TexelCopyBufferLayout {
|
||
offset: 0,
|
||
bytes_per_row: Some(padded),
|
||
rows_per_image: Some(h),
|
||
},
|
||
},
|
||
wgpu::Extent3d {
|
||
width: w,
|
||
height: h,
|
||
depth_or_array_layers: 1,
|
||
},
|
||
);
|
||
ctx.queue.submit(Some(enc.finish()));
|
||
|
||
let slice = buf.slice(..);
|
||
let (tx, rx) = std::sync::mpsc::channel();
|
||
slice.map_async(wgpu::MapMode::Read, move |r| {
|
||
let _ = tx.send(r);
|
||
});
|
||
ctx.device
|
||
.poll(wgpu::PollType::wait_indefinitely())
|
||
.expect("poll");
|
||
rx.recv().expect("map").expect("map");
|
||
|
||
let data = slice.get_mapped_range();
|
||
let mut out = Vec::with_capacity((unpadded * h) as usize);
|
||
for row in 0..h {
|
||
let start = (row * padded) as usize;
|
||
out.extend_from_slice(&data[start..start + unpadded as usize]);
|
||
}
|
||
drop(data);
|
||
buf.unmap();
|
||
out
|
||
}
|
||
|
||
// ----------------------------------------------------------------------
|
||
// Segmentation off the UI thread
|
||
// ----------------------------------------------------------------------
|
||
|
||
/// The property the whole arrangement rests on.
|
||
///
|
||
/// If someone puts an `Rc`, a `Cell` or a raw pipeline handle into
|
||
/// `SegmentationJob`, this stops compiling — which is the only warning
|
||
/// there would be, since the call site in `masks_ui` would then fail with
|
||
/// a lifetime error a long way from the cause.
|
||
#[test]
|
||
fn a_job_and_its_answer_can_cross_a_thread() {
|
||
fn is_send<T: Send>() {}
|
||
is_send::<SegmentationJob>();
|
||
is_send::<Segmented>();
|
||
is_send::<Abandon>();
|
||
}
|
||
|
||
/// Two sessions over the same file are still two photographs as far as a
|
||
/// late result is concerned, because opening one twice is opening it
|
||
/// twice.
|
||
#[test]
|
||
fn every_session_has_its_own_identity() {
|
||
let Some(ctx) = headless() else { return };
|
||
let rgba: Vec<u8> = (0..16 * 16).flat_map(|_| [128, 128, 128, 255]).collect();
|
||
let open = || {
|
||
DevelopSession::open_rgb(&ctx, &rgba, 16, 16, dr_types::Orientation::NORMAL)
|
||
.expect("session")
|
||
};
|
||
let (a, b) = (open(), open());
|
||
assert_ne!(a.id(), b.id());
|
||
assert_eq!(a.id(), a.id(), "and stable within one session");
|
||
}
|
||
|
||
#[test]
|
||
fn a_job_carries_the_session_it_was_taken_from() {
|
||
let Some(ctx) = headless() else { return };
|
||
let rgba: Vec<u8> = (0..16 * 16).flat_map(|_| [128, 128, 128, 255]).collect();
|
||
let session = DevelopSession::open_rgb(&ctx, &rgba, 16, 16, dr_types::Orientation::NORMAL)
|
||
.expect("session");
|
||
assert_eq!(session.segmentation_job().session(), session.id());
|
||
}
|
||
|
||
/// Abandoning before the run reaches the proxy must cost nothing at all —
|
||
/// this is the case that fires when the user pages on while a job is still
|
||
/// waiting for a thread.
|
||
#[test]
|
||
fn an_abandoned_job_does_no_work() {
|
||
let Some(ctx) = headless() else { return };
|
||
let rgba: Vec<u8> = (0..16 * 16).flat_map(|_| [128, 128, 128, 255]).collect();
|
||
let session = DevelopSession::open_rgb(&ctx, &rgba, 16, 16, dr_types::Orientation::NORMAL)
|
||
.expect("session");
|
||
|
||
let job = session.segmentation_job();
|
||
job.abandon().now();
|
||
|
||
let started = std::time::Instant::now();
|
||
let out = job.run(&crate::segmentation::Options::default());
|
||
assert!(
|
||
matches!(out, Ok(None)),
|
||
"abandoned is not an error and not an empty answer: {:?}",
|
||
out.map(|o| o.is_some())
|
||
);
|
||
assert!(
|
||
started.elapsed() < std::time::Duration::from_millis(50),
|
||
"it returned without loading the model"
|
||
);
|
||
}
|
||
|
||
/// A finished segmentation is adopted whole, and the session says so.
|
||
#[test]
|
||
fn adopting_a_result_gives_the_session_its_subjects() {
|
||
let Some(ctx) = headless() else { return };
|
||
let rgba: Vec<u8> = (0..100 * 100).flat_map(|_| [128, 128, 128, 255]).collect();
|
||
let mut session =
|
||
DevelopSession::open_rgb(&ctx, &rgba, 100, 100, dr_types::Orientation::NORMAL)
|
||
.expect("session");
|
||
assert!(!session.has_segmentation());
|
||
|
||
let job = session.segmentation_job();
|
||
let Ok(Some(found)) = job.run(&crate::segmentation::Options::default()) else {
|
||
eprintln!("no model; skipping");
|
||
return;
|
||
};
|
||
session.adopt_segmentation(found);
|
||
assert!(session.has_segmentation());
|
||
}
|
||
|
||
// ----------------------------------------------------------------------
|
||
// The overlay's clip rectangle
|
||
// ----------------------------------------------------------------------
|
||
//
|
||
// The overlay is a source-space picture and the canvas shows whatever the
|
||
// crop, the zoom and the pan selected out of that space. Drawn whole it
|
||
// stays frame-sized while the photograph moves underneath, which is what
|
||
// these pin down.
|
||
|
||
/// A session with a segmentation, so the clip has a proxy to measure
|
||
/// against.
|
||
///
|
||
/// The model finds nothing in flat grey, and that is fine: the clip is
|
||
/// computed from the framing and the proxy size, neither of which depends
|
||
/// on what was detected.
|
||
fn segmented_session(ctx: &GpuContext) -> Option<DevelopSession> {
|
||
let rgba: Vec<u8> = (0..100 * 100).flat_map(|_| [128, 128, 128, 255]).collect();
|
||
let mut session =
|
||
DevelopSession::open_rgb(ctx, &rgba, 100, 100, dr_types::Orientation::NORMAL)
|
||
.expect("session");
|
||
session
|
||
.segment(&crate::segmentation::Options::default())
|
||
.ok()?;
|
||
Some(session)
|
||
}
|
||
|
||
fn headless() -> Option<GpuContext> {
|
||
pollster::block_on(dr_gpu::GpuContext::new_headless()).ok()
|
||
}
|
||
|
||
/// TRACES: FR-DEV-3
|
||
/// A gradient needs no segmentation, and until now it silently got no mask.
|
||
///
|
||
/// The rasteriser was built on the way out of `segment`, so a gradient
|
||
/// added to a photograph nobody had segmented had nothing to draw it — and
|
||
/// the failure was invisible from every side. The generated shader still
|
||
/// emits the layer's block, the empty placeholder multiplies it by zero,
|
||
/// and the result is a well-formed frame with the local adjustment simply
|
||
/// absent. No error, no warning, and nothing on screen to tell it apart
|
||
/// from a mask the user had placed badly.
|
||
///
|
||
/// A graduated filter over a sky never had to know what a sky is, so the
|
||
/// dependency was wrong as well as silent.
|
||
#[test]
|
||
fn a_gradient_renders_on_a_photograph_nobody_has_segmented() {
|
||
let Some(ctx) = headless() else { return };
|
||
|
||
// Mid grey, so a brightening layer is unambiguous either way.
|
||
let rgba: Vec<u8> = (0..64 * 64).flat_map(|_| [128u8, 128, 128, 255]).collect();
|
||
let mut session =
|
||
DevelopSession::open_rgb(&ctx, &rgba, 64, 64, dr_types::Orientation::NORMAL)
|
||
.expect("session");
|
||
assert!(
|
||
!session.has_segmentation(),
|
||
"the point of the test is that there is none"
|
||
);
|
||
|
||
let before = read_back(&ctx, &session.render(64, 64).expect("render"));
|
||
|
||
// A radial over the middle, brightened hard. Addressed by index, so
|
||
// this names no operation (FR-DEV-3a).
|
||
session.add_gradient_mask(true).expect("a radial");
|
||
let row = session.rows()[0].clone();
|
||
session.set_param(row.op_index, row.param_index, row.maximum);
|
||
|
||
let after = read_back(&ctx, &session.render(64, 64).expect("render"));
|
||
|
||
let centre = |px: &[u8]| px[((32 * 64 + 32) * 4) as usize];
|
||
assert!(
|
||
centre(&after) > centre(&before) + 20,
|
||
"the middle of the frame must brighten: {} against {}",
|
||
centre(&after),
|
||
centre(&before)
|
||
);
|
||
// And only the middle: a mask that failed to rasterise the other way —
|
||
// covering everything — would pass the assertion above.
|
||
let corner = |px: &[u8]| px[0];
|
||
assert_eq!(
|
||
corner(&after),
|
||
corner(&before),
|
||
"the corner is outside the radial and must not move"
|
||
);
|
||
}
|
||
|
||
/// TRACES: FR-DEV-3
|
||
/// The mask array and the segmentation proxy are the same size on purpose.
|
||
///
|
||
/// They have to be: a subject layer's distance field is built at the
|
||
/// segmentation's proxy resolution and sampled against the array, so if the
|
||
/// two ever diverged a subject mask would be drawn at the wrong scale —
|
||
/// a mask that is confidently in the wrong place, which is worse than none.
|
||
///
|
||
/// It used to hold because the array's size was *read off* the
|
||
/// segmentation, which also made a gradient wait for a model it does not
|
||
/// use. Deriving both from the photograph keeps the agreement and drops the
|
||
/// dependency, and this is what stops the agreement being an accident.
|
||
#[test]
|
||
fn the_mask_array_is_the_size_the_segmentation_will_use() {
|
||
let Some(ctx) = headless() else { return };
|
||
|
||
let rgba: Vec<u8> = (0..100 * 100)
|
||
.flat_map(|_| [128u8, 128, 128, 255])
|
||
.collect();
|
||
let mut session =
|
||
DevelopSession::open_rgb(&ctx, &rgba, 100, 100, dr_types::Orientation::NORMAL)
|
||
.expect("session");
|
||
|
||
let before = session.mask_raster_size();
|
||
if session
|
||
.segment(&crate::segmentation::Options::default())
|
||
.is_err()
|
||
{
|
||
eprintln!("no model; skipping");
|
||
return;
|
||
}
|
||
assert_eq!(
|
||
before,
|
||
session.mask_raster_size(),
|
||
"a mask rasterised before the model ran must not move when it does"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn an_unzoomed_overlay_shows_the_whole_frame() {
|
||
let Some(ctx) = headless() else { return };
|
||
let Some(session) = segmented_session(&ctx) else {
|
||
eprintln!("no model; skipping");
|
||
return;
|
||
};
|
||
|
||
let (x, y, w, h) = session.overlay_clip();
|
||
assert_eq!((x, y), (0, 0));
|
||
assert!(w > 1 && h > 1, "the whole proxy: {w}x{h}");
|
||
}
|
||
|
||
/// The bug this exists for: zooming must narrow the clip, or the overlay
|
||
/// keeps showing the whole picture at frame size while the canvas shows a
|
||
/// detail of it.
|
||
#[test]
|
||
fn zooming_narrows_the_overlay_to_what_is_visible() {
|
||
let Some(ctx) = headless() else { return };
|
||
let Some(mut session) = segmented_session(&ctx) else {
|
||
eprintln!("no model; skipping");
|
||
return;
|
||
};
|
||
|
||
let (_, _, full_w, full_h) = session.overlay_clip();
|
||
session.zoom_about(4.0, 0.5, 0.5);
|
||
let (_, _, zoomed_w, zoomed_h) = session.overlay_clip();
|
||
|
||
assert!(
|
||
zoomed_w < full_w && zoomed_h < full_h,
|
||
"zoomed in, the overlay should show less: {zoomed_w}x{zoomed_h} \
|
||
against {full_w}x{full_h}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn panning_moves_the_overlay_with_the_photograph() {
|
||
let Some(ctx) = headless() else { return };
|
||
let Some(mut session) = segmented_session(&ctx) else {
|
||
eprintln!("no model; skipping");
|
||
return;
|
||
};
|
||
|
||
session.zoom_about(4.0, 0.5, 0.5);
|
||
let (before_x, _, _, _) = session.overlay_clip();
|
||
session.pan_by(0.3, 0.0);
|
||
let (after_x, _, _, _) = session.overlay_clip();
|
||
|
||
assert!(
|
||
after_x > before_x,
|
||
"panning right moves the visible window right: {before_x} then {after_x}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn cropping_narrows_the_overlay_too() {
|
||
let Some(ctx) = headless() else { return };
|
||
let Some(mut session) = segmented_session(&ctx) else {
|
||
eprintln!("no model; skipping");
|
||
return;
|
||
};
|
||
|
||
let (_, _, full_w, _) = session.overlay_clip();
|
||
session.set_crop(dr_pipeline::CropRect {
|
||
x: 0.25,
|
||
y: 0.25,
|
||
width: 0.5,
|
||
height: 0.5,
|
||
});
|
||
let (x, y, w, _) = session.overlay_clip();
|
||
|
||
assert!(w < full_w, "a half-width crop shows half the overlay");
|
||
assert!(x > 0 && y > 0, "and it starts inside the frame");
|
||
}
|
||
|
||
/// Nothing segmented means no overlay, and no rectangle a caller might
|
||
/// divide by.
|
||
#[test]
|
||
fn no_segmentation_means_no_clip() {
|
||
let Some(ctx) = headless() else { return };
|
||
let rgba: Vec<u8> = (0..16 * 16).flat_map(|_| [128, 128, 128, 255]).collect();
|
||
let session = DevelopSession::open_rgb(&ctx, &rgba, 16, 16, dr_types::Orientation::NORMAL)
|
||
.expect("session");
|
||
assert_eq!(session.overlay_clip(), (0, 0, 0, 0));
|
||
}
|
||
|
||
/// 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")],
|
||
attributes: &[dr_pipeline::Attribute::Tone],
|
||
};
|
||
|
||
assert!(rows_from(&[on_canvas]).is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn a_group_spans_exactly_its_operations_rows() {
|
||
// `group_len` is how many rows the section reaches forward over. Too
|
||
// few silently drops controls off the bottom of a section; too many
|
||
// reads past the model and renders a neighbouring operation's
|
||
// parameters under the wrong heading.
|
||
let graph = EditGraph::default_chain();
|
||
let caps = graph.capabilities();
|
||
let rows = rows_of(&caps);
|
||
|
||
for (i, row) in rows.iter().enumerate() {
|
||
let (head, len) = *row;
|
||
assert!(head <= i, "row {i} claims a head after itself");
|
||
assert!(
|
||
head + len <= rows.len(),
|
||
"group at {head} reaches past the model"
|
||
);
|
||
// Every row the group spans must agree it belongs to that group.
|
||
for (offset, spanned) in rows[head..head + len].iter().enumerate() {
|
||
let span = head + offset;
|
||
assert_eq!(spanned.0, head, "row {span} disagrees about its group");
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_group_is_modified_when_any_of_its_parameters_is() {
|
||
// The dot on a collapsed section is the only thing saying an edit is
|
||
// hidden inside it, and it is derived here rather than asked of the
|
||
// core (ARCH §4.3a).
|
||
let mut graph = EditGraph::default_chain();
|
||
let caps = graph.capabilities();
|
||
// A fresh chain is at its defaults, so nothing is modified.
|
||
assert!(
|
||
caps.iter()
|
||
.all(|c| c.params.iter().all(|p| p.value == p.default)),
|
||
"a fresh chain must start neutral"
|
||
);
|
||
|
||
// Move one parameter of one operation off its default; only that
|
||
// operation's group may light up.
|
||
let (op_id, param_id, default) = caps
|
||
.iter()
|
||
.find_map(|c| {
|
||
c.params
|
||
.iter()
|
||
.find(|p| matches!(p.kind, ParamKind::Scalar { .. }))
|
||
.map(|p| (c.id, p.id, p.default))
|
||
})
|
||
.expect("the chain has a scalar parameter");
|
||
graph.set_param(op_id, param_id, default + 1.0);
|
||
|
||
let caps = graph.capabilities();
|
||
let modified: Vec<bool> = caps
|
||
.iter()
|
||
.map(|c| c.params.iter().any(|p| p.value != p.default))
|
||
.collect();
|
||
assert_eq!(
|
||
modified.iter().filter(|m| **m).count(),
|
||
1,
|
||
"one edit must mark exactly one group"
|
||
);
|
||
|
||
// And it goes out again when the value returns.
|
||
graph.set_param(op_id, param_id, default);
|
||
assert!(
|
||
graph
|
||
.capabilities()
|
||
.iter()
|
||
.all(|c| c.params.iter().all(|p| p.value == p.default)),
|
||
"returning a value to its default must clear the group"
|
||
);
|
||
}
|
||
|
||
/// `(group_head, group_len)` per row, flattened as
|
||
/// [`DevelopSession::rows`] flattens — without needing a GPU to build a
|
||
/// session.
|
||
///
|
||
/// A widget hint only collapses an operation to one row when it is
|
||
/// *honoured*; `rows` falls back to sliders otherwise, and mirroring that
|
||
/// here is what keeps the test honest when a hint stops applying.
|
||
/// TRACES: FR-DEV-3c
|
||
/// An operation this file has never heard of, appearing in the panel.
|
||
///
|
||
/// The acceptance test requirements.md names for FR-DEV-3c: "a test
|
||
/// operation added to the registry appears in a generated panel with no
|
||
/// frontend change". Built as a capability rather than a real node so it
|
||
/// costs the pipeline nothing — what is being asserted is the mapping from
|
||
/// descriptor to control, and that mapping does not care whether a shader
|
||
/// exists behind it.
|
||
#[test]
|
||
fn an_operation_the_frontend_has_never_heard_of_gets_controls() {
|
||
use dr_pipeline::{LocalizedKey, ParamCapability};
|
||
|
||
let invented = OpCapability {
|
||
id: OpId("invented"),
|
||
label: LocalizedKey("op.invented"),
|
||
active: false,
|
||
presentation: None,
|
||
params: vec![
|
||
ParamCapability {
|
||
id: ParamId("strength"),
|
||
label: LocalizedKey("param.invented.strength"),
|
||
kind: ParamKind::Scalar {
|
||
min: -100.0,
|
||
max: 100.0,
|
||
scale: dr_pipeline::Scale::Linear,
|
||
unit: Unit::Percent,
|
||
precision: 0,
|
||
},
|
||
default: 0.0,
|
||
value: 25.0,
|
||
facet: None,
|
||
},
|
||
ParamCapability {
|
||
id: ParamId("method"),
|
||
label: LocalizedKey("param.invented.method"),
|
||
kind: ParamKind::Enum {
|
||
variants: &[
|
||
LocalizedKey("param.invented.method.fast"),
|
||
LocalizedKey("param.invented.method.exact"),
|
||
],
|
||
},
|
||
default: 0.0,
|
||
value: 1.0,
|
||
facet: None,
|
||
},
|
||
],
|
||
attributes: &[dr_pipeline::Attribute::Tone],
|
||
};
|
||
|
||
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,
|
||
},
|
||
],
|
||
attributes: &[dr_pipeline::Attribute::Tone],
|
||
};
|
||
|
||
assert!(!supported(WidgetKind::ColourWheel), "precondition");
|
||
let rows = rows_from(&[wheel]);
|
||
assert_eq!(rows.len(), 2, "both parameters must remain reachable");
|
||
assert!(rows.iter().all(|r| r.kind == "scalar"));
|
||
}
|
||
|
||
/// Each generated row's `(group_head, group_len)`.
|
||
///
|
||
/// Taken from the real generator rather than re-derived. This used to be a
|
||
/// hand-written simulation of `rows_from` — it walked the capabilities and
|
||
/// reproduced the grouping rules, including a copy of the framing skip —
|
||
/// which meant the tests below asserted against a second implementation
|
||
/// that had to be kept in step with the first by hand. It was not: giving
|
||
/// framing a presentation changed the real panel and the simulation
|
||
/// disagreed, which is how a passing test suite would have hidden the
|
||
/// change entirely.
|
||
fn rows_of(caps: &[OpCapability]) -> Vec<(usize, usize)> {
|
||
rows_from(caps)
|
||
.iter()
|
||
.map(|r| (r.group_head as usize, r.group_len as usize))
|
||
.collect()
|
||
}
|
||
|
||
#[test]
|
||
fn four_quarter_turns_return_a_crop_where_it_started() {
|
||
// The property that makes rotation safe to repeat: a user who turns
|
||
// past the orientation they wanted and keeps going must arrive back at
|
||
// the crop they had, not at a slowly drifting one.
|
||
let start = CropRect {
|
||
x: 0.1,
|
||
y: 0.2,
|
||
width: 0.3,
|
||
height: 0.4,
|
||
};
|
||
let mut r = start;
|
||
for _ in 0..4 {
|
||
r = rotate_crop(r, 1);
|
||
}
|
||
assert!((r.x - start.x).abs() < 1e-5, "x drifted to {}", r.x);
|
||
assert!((r.y - start.y).abs() < 1e-5, "y drifted to {}", r.y);
|
||
assert!((r.width - start.width).abs() < 1e-5);
|
||
assert!((r.height - start.height).abs() < 1e-5);
|
||
}
|
||
|
||
#[test]
|
||
fn a_quarter_turn_exchanges_a_crops_extents() {
|
||
// A portrait selection on a landscape frame must come out landscape.
|
||
// Were the extents left alone, the rect would keep its old shape while
|
||
// the frame changed to the other one, and the crop would spill off the
|
||
// photograph.
|
||
let r = rotate_crop(
|
||
CropRect {
|
||
x: 0.0,
|
||
y: 0.0,
|
||
width: 0.25,
|
||
height: 1.0,
|
||
},
|
||
1,
|
||
);
|
||
assert!((r.width - 1.0).abs() < 1e-5, "width was {}", r.width);
|
||
assert!((r.height - 0.25).abs() < 1e-5, "height was {}", r.height);
|
||
}
|
||
|
||
#[test]
|
||
fn rotating_a_crop_keeps_it_inside_the_frame() {
|
||
// Whatever the angle and wherever the rect, the result must still be a
|
||
// rect the pipeline can render: outside the unit square it would
|
||
// sample undefined area, and degenerate it is a zero-sized texture.
|
||
for turns in -5..=5 {
|
||
for rect in [
|
||
CropRect {
|
||
x: 0.0,
|
||
y: 0.0,
|
||
width: 1.0,
|
||
height: 1.0,
|
||
},
|
||
CropRect {
|
||
x: 0.7,
|
||
y: 0.8,
|
||
width: 0.3,
|
||
height: 0.2,
|
||
},
|
||
CropRect {
|
||
x: 0.0,
|
||
y: 0.45,
|
||
width: 0.02,
|
||
height: 0.02,
|
||
},
|
||
] {
|
||
let r = rotate_crop(rect, turns);
|
||
assert!(
|
||
r.x >= 0.0 && r.y >= 0.0,
|
||
"{turns} turns of {rect:?} gave {r:?}"
|
||
);
|
||
assert!(
|
||
r.x + r.width <= 1.0 + 1e-5 && r.y + r.height <= 1.0 + 1e-5,
|
||
"{turns} turns of {rect:?} left the frame: {r:?}"
|
||
);
|
||
assert!(
|
||
r.width >= CropRect::MIN_EXTENT && r.height >= CropRect::MIN_EXTENT,
|
||
"{turns} turns of {rect:?} went degenerate: {r:?}"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn opposite_quarter_turns_cancel() {
|
||
// The rotate-left and rotate-right buttons must undo one another, or
|
||
// correcting an over-rotation would land somewhere new each time.
|
||
let start = CropRect {
|
||
x: 0.15,
|
||
y: 0.05,
|
||
width: 0.5,
|
||
height: 0.25,
|
||
};
|
||
let there_and_back = rotate_crop(rotate_crop(start, 1), -1);
|
||
assert!((there_and_back.x - start.x).abs() < 1e-5);
|
||
assert!((there_and_back.y - start.y).abs() < 1e-5);
|
||
assert!((there_and_back.width - start.width).abs() < 1e-5);
|
||
assert!((there_and_back.height - start.height).abs() < 1e-5);
|
||
}
|
||
|
||
#[test]
|
||
fn a_full_crop_survives_rotation_as_a_full_crop() {
|
||
// The common case: rotating an uncropped photograph must not quietly
|
||
// introduce a crop, which would shrink the exported image.
|
||
assert!(rotate_crop(CropRect::default(), 1).is_full());
|
||
assert!(rotate_crop(CropRect::default(), -3).is_full());
|
||
}
|
||
|
||
#[test]
|
||
fn an_operation_without_facets_keeps_its_declared_order() {
|
||
// Every operation but the mixer. Reordering one of these would move
|
||
// Highlights below Shadows for no reason anybody could see in the
|
||
// code, so the stable sort has to be a no-op when nothing is faceted.
|
||
let graph = EditGraph::default_chain();
|
||
for cap in graph.capabilities() {
|
||
if cap.params.iter().any(|p| p.facet.is_some()) {
|
||
continue;
|
||
}
|
||
let order = presentation_order(&cap.params);
|
||
assert_eq!(
|
||
order,
|
||
(0..cap.params.len()).collect::<Vec<_>>(),
|
||
"{} was reordered",
|
||
cap.id
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn faceted_parameters_are_stacked_one_run_per_aspect() {
|
||
// The panel names a run once and then draws its rows. That only works
|
||
// if a run is *contiguous*: the mixer declares band by band — red hue,
|
||
// red sat, red lum, orange hue — so shown in declaration order every
|
||
// single row would begin a new run, and the panel would draw
|
||
// thirty-six headings over thirty-six sliders.
|
||
let graph = EditGraph::default_chain();
|
||
let cap = graph
|
||
.capabilities()
|
||
.into_iter()
|
||
.find(|c| c.params.iter().any(|p| p.facet.is_some()))
|
||
.expect("the chain has a faceted operation");
|
||
|
||
let mut seen: Vec<&str> = Vec::new();
|
||
let mut previous: Option<&str> = None;
|
||
for i in presentation_order(&cap.params) {
|
||
let aspect = cap.params[i]
|
||
.facet
|
||
.as_ref()
|
||
.expect("this operation facets every parameter")
|
||
.aspect
|
||
.0;
|
||
if previous != Some(aspect) {
|
||
assert!(
|
||
!seen.contains(&aspect),
|
||
"{aspect} is split into two runs — a heading would be \
|
||
drawn over each half"
|
||
);
|
||
seen.push(aspect);
|
||
previous = Some(aspect);
|
||
}
|
||
}
|
||
assert!(seen.len() > 1, "the fixture must have several aspects");
|
||
}
|
||
|
||
#[test]
|
||
fn reordering_rows_does_not_move_where_a_change_is_routed() {
|
||
// The rows are stacked for reading; `param_index` still addresses the
|
||
// capability list. Were the two confused, dragging a band's Hue would
|
||
// silently write to whichever parameter happened to sit at that
|
||
// position — an edit landing on the wrong control, which reads as the
|
||
// renderer being broken rather than the panel.
|
||
let graph = EditGraph::default_chain();
|
||
let cap = graph
|
||
.capabilities()
|
||
.into_iter()
|
||
.find(|c| c.params.iter().any(|p| p.facet.is_some()))
|
||
.expect("the chain has a faceted operation");
|
||
|
||
let mut order = presentation_order(&cap.params);
|
||
order.sort_unstable();
|
||
assert_eq!(
|
||
order,
|
||
(0..cap.params.len()).collect::<Vec<_>>(),
|
||
"the order must be a permutation: every parameter reachable from \
|
||
exactly one row, and every row addressing a parameter that exists"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn every_faceted_parameter_resolves_to_a_band_name() {
|
||
// The bug this closes: `labels.rs` had no `param.mixer.*` entries, so
|
||
// all thirty-six keys fell through to a derived label that yields the
|
||
// bare channel name — twelve rows reading "Hue" with nothing saying
|
||
// which band. A row identified only by a swatch depends on this
|
||
// resolving, since the name is what a screen reader speaks and what
|
||
// anyone who cannot separate two squares by eye has to go on.
|
||
let graph = EditGraph::default_chain();
|
||
for cap in graph.capabilities() {
|
||
for p in &cap.params {
|
||
let Some(facet) = &p.facet else { continue };
|
||
let subject = labels::resolve(facet.subject.0);
|
||
let aspect = labels::resolve(facet.aspect.0);
|
||
assert!(!subject.is_empty(), "{} has no subject name", p.id);
|
||
assert!(!aspect.is_empty(), "{} has no aspect name", p.id);
|
||
// Not the channel name repeated: that is exactly the failure
|
||
// the catalogue entries were added to fix.
|
||
assert_ne!(subject, aspect, "{} is named after its channel", p.id);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn unit_suffixes_come_from_the_descriptor() {
|
||
assert_eq!(unit_suffix(Unit::Stops), " EV");
|
||
assert_eq!(unit_suffix(Unit::None), "");
|
||
}
|
||
|
||
#[test]
|
||
fn fitting_preserves_aspect_ratio() {
|
||
// A 3:2 image in a 16:9 window must letterbox, not stretch.
|
||
let (w, h) = fit(6000, 4000, 1600, 900);
|
||
assert_eq!(h, 900);
|
||
assert!(
|
||
((w as f32 / h as f32) - 1.5).abs() < 0.01,
|
||
"got {w}x{h}, aspect {}",
|
||
w as f32 / h as f32
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn fitting_never_upscales_past_the_source() {
|
||
// Rendering a 400px image into a 4K window at 4K shades 25x the
|
||
// pixels for no additional detail.
|
||
let (w, h) = fit(400, 300, 3840, 2160);
|
||
assert_eq!((w, h), (400, 300));
|
||
}
|
||
|
||
#[test]
|
||
fn fitting_handles_a_degenerate_source() {
|
||
let (w, h) = fit(0, 0, 800, 600);
|
||
assert_eq!((w, h), (800, 600));
|
||
}
|
||
|
||
#[test]
|
||
fn fitting_is_bounded_by_the_narrow_axis() {
|
||
// A tall window on a wide image must be limited by width.
|
||
let (w, h) = fit(4000, 1000, 800, 4000);
|
||
assert_eq!(w, 800);
|
||
assert_eq!(h, 200);
|
||
}
|
||
|
||
#[test]
|
||
fn the_curve_collapses_to_a_single_row() {
|
||
// Every point parameter — all four curves' worth — must appear as one
|
||
// curve control, not as forty sliders. Otherwise the widget and the
|
||
// sliders both render and the panel shows the same values twice.
|
||
let graph = EditGraph::default_chain();
|
||
let curve_cap = graph
|
||
.capabilities()
|
||
.into_iter()
|
||
.find(|c| c.id == curve::ID)
|
||
.expect("the chain includes a tone curve");
|
||
|
||
assert_eq!(
|
||
curve_cap.params.len(),
|
||
curve::CHANNELS * curve::POINTS * 2,
|
||
"a master curve and one per colour channel"
|
||
);
|
||
let presentation = curve_cap
|
||
.presentation
|
||
.as_ref()
|
||
.expect("the curve declares a widget");
|
||
// Asked the way the panel asks it: the first preference this frontend
|
||
// implements, not a fixed single kind.
|
||
assert_eq!(presentation.choose(supported), Some(WidgetKind::ToneCurve));
|
||
// Every parameter is owned by the widget, so none is left over to be
|
||
// rendered as a stray slider.
|
||
assert_eq!(presentation.params.len(), curve_cap.params.len());
|
||
}
|
||
|
||
#[test]
|
||
fn curve_point_parameters_are_contiguous() {
|
||
// The widget addresses points by offset from the first. Were they
|
||
// interleaved with anything else, dragging a point would write to
|
||
// the wrong parameter.
|
||
let graph = EditGraph::default_chain();
|
||
let cap = graph
|
||
.capabilities()
|
||
.into_iter()
|
||
.find(|c| c.id == curve::ID)
|
||
.expect("tone curve present");
|
||
let presentation = cap.presentation.as_ref().expect("declares a widget");
|
||
|
||
let base = cap
|
||
.params
|
||
.iter()
|
||
.position(|p| p.id == presentation.params[0])
|
||
.expect("first point is a parameter");
|
||
for (i, id) in presentation.params.iter().enumerate() {
|
||
assert_eq!(
|
||
cap.params[base + i].id,
|
||
*id,
|
||
"point parameter {i} is out of order"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// The panel's whole knowledge of colour channels, asserted to be none.
|
||
///
|
||
/// It groups the widget's parameters by the subject the *operation* put on
|
||
/// them and finds four curves; nothing below says "red", and an operation
|
||
/// that grew a fifth curve would arrive here on its own.
|
||
#[test]
|
||
fn a_curve_widget_offers_one_run_per_subject() {
|
||
let graph = EditGraph::default_chain();
|
||
let cap = graph
|
||
.capabilities()
|
||
.into_iter()
|
||
.find(|c| c.id == curve::ID)
|
||
.expect("tone curve present");
|
||
let presentation = cap.presentation.as_ref().expect("declares a widget");
|
||
|
||
let runs = curve_runs(&cap, presentation).expect("a curve-shaped operation");
|
||
assert_eq!(runs.len(), curve::CHANNELS);
|
||
for (i, run) in runs.iter().enumerate() {
|
||
assert_eq!(run.len, curve::POINTS * 2, "run {i} is not five points");
|
||
assert_eq!(run.base, i * curve::POINTS * 2);
|
||
assert!(run.subject.is_some(), "run {i} is unnamed");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn switching_curve_repoints_the_row() {
|
||
use slint::Model as _;
|
||
|
||
// What a drag routes through. The row's `param_index` is the base of
|
||
// the curve *on show*, so picking a different one must move it — if it
|
||
// did not, dragging a point on the red curve would write to the
|
||
// master's.
|
||
let graph = EditGraph::default_chain();
|
||
let caps = graph.capabilities();
|
||
let curve_at = caps
|
||
.iter()
|
||
.position(|c| c.id == curve::ID)
|
||
.expect("tone curve present");
|
||
|
||
let mut bases = Vec::new();
|
||
for channel in 0..curve::CHANNELS {
|
||
let rows = rows_filtered(&caps, |_| true, channel);
|
||
let row = rows
|
||
.iter()
|
||
.find(|r| r.op_index as usize == curve_at)
|
||
.expect("the curve has a row");
|
||
assert_eq!(row.kind, "curve");
|
||
assert_eq!(
|
||
row.points.row_count(),
|
||
curve::POINTS * 2,
|
||
"one curve's points, not all four curves'"
|
||
);
|
||
bases.push(row.param_index);
|
||
}
|
||
|
||
assert_eq!(
|
||
bases,
|
||
(0..curve::CHANNELS)
|
||
.map(|i| (i * curve::POINTS * 2) as i32)
|
||
.collect::<Vec<_>>()
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn a_selection_the_operation_cannot_honour_falls_back_to_its_last_curve() {
|
||
// The selection outlives the photograph it was made on, and the next
|
||
// image's operation may offer fewer curves. Clamping keeps a plot on
|
||
// the grid; the alternative is a curve row that vanishes, which reads
|
||
// as the tone curve having disappeared from the panel.
|
||
let graph = EditGraph::default_chain();
|
||
let caps = graph.capabilities();
|
||
let rows = rows_filtered(&caps, |_| true, 99);
|
||
let row = rows
|
||
.iter()
|
||
.find(|r| r.kind == "curve")
|
||
.expect("the curve still has a row");
|
||
assert_eq!(
|
||
row.param_index,
|
||
((curve::CHANNELS - 1) * curve::POINTS * 2) as i32
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn an_operation_whose_points_are_unfaceted_is_one_curve() {
|
||
// A curve widget that spans a single unnamed curve — which is what
|
||
// this operation was before the channels arrived, and what any other
|
||
// node declaring a `tone_curve` widget over ten scalars would be.
|
||
// It must draw, and it must offer no choice.
|
||
use dr_pipeline::{LocalizedKey, ParamCapability, WidgetDemand};
|
||
use slint::Model as _;
|
||
|
||
static IDS: [ParamId; 4] = [
|
||
ParamId("p0_x"),
|
||
ParamId("p0_y"),
|
||
ParamId("p1_x"),
|
||
ParamId("p1_y"),
|
||
];
|
||
let param = |id: ParamId| ParamCapability {
|
||
id,
|
||
label: LocalizedKey("param.point"),
|
||
kind: ParamKind::Scalar {
|
||
min: 0.0,
|
||
max: 1.0,
|
||
scale: dr_pipeline::Scale::Linear,
|
||
unit: Unit::None,
|
||
precision: 4,
|
||
},
|
||
default: 0.0,
|
||
value: 0.0,
|
||
facet: None,
|
||
};
|
||
let plain = OpCapability {
|
||
id: OpId("invented_curve"),
|
||
label: LocalizedKey("op.invented_curve"),
|
||
active: false,
|
||
presentation: Some(Presentation {
|
||
widgets: &[WidgetKind::ToneCurve],
|
||
demand: WidgetDemand {
|
||
two_dimensional: true,
|
||
precise_pointing: true,
|
||
},
|
||
params: &IDS,
|
||
}),
|
||
params: IDS.iter().map(|id| param(*id)).collect(),
|
||
attributes: &[dr_pipeline::Attribute::Tone],
|
||
};
|
||
|
||
let presentation = plain.presentation.as_ref().expect("declares a widget");
|
||
let runs = curve_runs(&plain, presentation).expect("curve-shaped");
|
||
assert_eq!(runs.len(), 1, "one unnamed curve");
|
||
assert_eq!(runs[0].subject, None);
|
||
|
||
let rows = rows_from(&[plain]);
|
||
assert_eq!(rows.len(), 1);
|
||
assert_eq!(rows[0].kind, "curve");
|
||
assert_eq!(rows[0].points.row_count(), IDS.len());
|
||
}
|
||
|
||
#[test]
|
||
fn curve_samples_start_on_the_diagonal() {
|
||
// A fresh curve is the identity, so the drawn line must be the 45°
|
||
// diagonal — anything else means the widget opens showing a shape
|
||
// the image does not have.
|
||
let mut xs = [0.0f32; curve::POINTS];
|
||
let mut ys = [0.0f32; curve::POINTS];
|
||
for i in 0..curve::POINTS {
|
||
let t = i as f32 / (curve::POINTS - 1) as f32;
|
||
xs[i] = t;
|
||
ys[i] = t;
|
||
}
|
||
for i in 0..=20 {
|
||
let x = i as f32 / 20.0;
|
||
let y = curve::evaluate(&xs, &ys, x);
|
||
assert!((y - x).abs() < 1e-4, "at {x} the identity gave {y}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn sorting_enforces_a_minimum_gap() {
|
||
// Two points dragged onto each other would divide by zero in the
|
||
// spline; the drawn curve must survive it exactly as the shader does.
|
||
let mut xs = [0.5, 0.5, 0.5, 0.5, 0.5];
|
||
sort_with_gap(&mut xs);
|
||
for i in 1..xs.len() {
|
||
assert!(xs[i] > xs[i - 1], "not separated: {xs:?}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn sorting_orders_reversed_points() {
|
||
let mut xs = [0.9, 0.7, 0.5, 0.3, 0.1];
|
||
sort_with_gap(&mut xs);
|
||
for i in 1..xs.len() {
|
||
assert!(xs[i] > xs[i - 1], "not sorted: {xs:?}");
|
||
}
|
||
}
|
||
|
||
/// A frame black on the left half and white on the right, at `size`
|
||
/// square. Both ends of the histogram are occupied and both clipping
|
||
/// counters are non-zero, and cropping to one half leaves exactly one of
|
||
/// them so.
|
||
fn split_frame(size: u32) -> Vec<u8> {
|
||
let mut rgba = Vec::with_capacity((size * size * 4) as usize);
|
||
for _ in 0..size {
|
||
for x in 0..size {
|
||
let v = if x < size / 2 { 0u8 } else { 255 };
|
||
rgba.extend_from_slice(&[v, v, v, 255]);
|
||
}
|
||
}
|
||
rgba
|
||
}
|
||
|
||
/// TRACES: FR-DSP-7
|
||
#[test]
|
||
fn the_histogram_counts_the_frame_that_is_actually_on_the_canvas() {
|
||
// The wiring, end to end and against exact numbers: a 64x64 frame that
|
||
// is half black and half white must come back as 2048 pixels at level
|
||
// 0, 2048 at 255, and both clipping counters at 2048.
|
||
//
|
||
// Asserted at the session rather than at the pass because the mistake
|
||
// this catches is not arithmetic — `dr_gpu` has its own tests for that
|
||
// — it is counting the *wrong texture*. Reading a stale target, or the
|
||
// demosaiced source instead of the adjusted output, produces a
|
||
// perfectly well-formed histogram of an image the photographer is not
|
||
// looking at, which is the one failure mode that cannot be seen.
|
||
let Ok(ctx) = pollster::block_on(dr_gpu::GpuContext::new_headless()) else {
|
||
log::warn!("no GPU adapter; skipping");
|
||
return;
|
||
};
|
||
|
||
let rgba = split_frame(64);
|
||
let mut session =
|
||
DevelopSession::open_rgb(&ctx, &rgba, 64, 64, dr_types::Orientation::NORMAL)
|
||
.expect("session");
|
||
session.render(64, 64).expect("render");
|
||
|
||
let hist = session.histogram().expect("a rendered session must count");
|
||
assert_eq!(hist.pixels(), 64 * 64);
|
||
assert_eq!(hist.red()[0], 2048, "the black half");
|
||
assert_eq!(hist.red()[255], 2048, "the white half");
|
||
assert_eq!(hist.clipped_shadows(), 2048);
|
||
assert_eq!(hist.clipped_highlights(), 2048);
|
||
}
|
||
|
||
/// TRACES: FR-DSP-7
|
||
#[test]
|
||
fn the_histogram_follows_the_edit_rather_than_the_file() {
|
||
// The property that makes it *live*. A histogram computed once from the
|
||
// source would pass the test above and be useless — the whole reason
|
||
// FR-DSP-7 exists is to show what an adjustment is doing, so cropping
|
||
// away the white half must leave a histogram with no white in it and
|
||
// no highlight clipping to report.
|
||
let Ok(ctx) = pollster::block_on(dr_gpu::GpuContext::new_headless()) else {
|
||
log::warn!("no GPU adapter; skipping");
|
||
return;
|
||
};
|
||
|
||
let rgba = split_frame(64);
|
||
let mut session =
|
||
DevelopSession::open_rgb(&ctx, &rgba, 64, 64, dr_types::Orientation::NORMAL)
|
||
.expect("session");
|
||
|
||
session.set_crop(CropRect {
|
||
x: 0.0,
|
||
y: 0.0,
|
||
width: 0.5,
|
||
height: 1.0,
|
||
});
|
||
session.render(64, 64).expect("render");
|
||
|
||
let hist = session.histogram().expect("histogram");
|
||
assert_eq!(hist.pixels(), 32 * 64, "the crop halved the frame");
|
||
assert_eq!(hist.red()[0], 32 * 64);
|
||
assert_eq!(hist.red()[255], 0, "the white half was cropped away");
|
||
assert_eq!(hist.clipped_highlights(), 0);
|
||
assert_eq!(hist.clipped_shadows(), 32 * 64);
|
||
}
|
||
|
||
#[test]
|
||
fn routing_indices_map_back_to_the_right_parameter() {
|
||
// A wrong index would silently move the wrong slider's value, which
|
||
// is exactly the kind of bug that looks like a rendering fault.
|
||
let graph = EditGraph::default_chain();
|
||
let caps = graph.capabilities();
|
||
for (oi, op) in caps.iter().enumerate() {
|
||
for (pi, p) in op.params.iter().enumerate() {
|
||
assert_eq!(caps[oi].params[pi].id, p.id);
|
||
assert_eq!(caps[oi].id, op.id);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ----------------------------------------------------------------------
|
||
// Group tabs (ARCH §4.3a, FR-DEV-3a)
|
||
// ----------------------------------------------------------------------
|
||
|
||
fn tabbed_session(ctx: &GpuContext) -> DevelopSession {
|
||
let rgba: Vec<u8> = (0..16 * 16).flat_map(|_| [128, 128, 128, 255]).collect();
|
||
DevelopSession::open_rgb(ctx, &rgba, 16, 16, dr_types::Orientation::NORMAL)
|
||
.expect("session")
|
||
}
|
||
|
||
#[test]
|
||
fn the_tabs_come_from_the_chain_not_from_a_list() {
|
||
let Some(ctx) = headless() else { return };
|
||
let session = tabbed_session(&ctx);
|
||
|
||
let names: Vec<String> = session.tabs().into_iter().map(|(_, n)| n).collect();
|
||
assert!(names.contains(&"Light".to_string()), "got {names:?}");
|
||
assert!(names.contains(&"Colour".to_string()), "got {names:?}");
|
||
// Nothing offers a group with no rows behind it.
|
||
for (attribute, name) in session.tabs() {
|
||
let mut probe = tabbed_session(&ctx);
|
||
let index = probe
|
||
.tabs()
|
||
.iter()
|
||
.position(|(a, _)| *a == attribute)
|
||
.expect("just listed");
|
||
probe.set_active_tab(index as i32);
|
||
assert!(!probe.rows().is_empty(), "tab {name} opens onto nothing");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn choosing_a_tab_narrows_the_panel() {
|
||
let Some(ctx) = headless() else { return };
|
||
let mut session = tabbed_session(&ctx);
|
||
|
||
let all = session.rows().len();
|
||
session.set_active_tab(0);
|
||
let narrowed = session.rows().len();
|
||
|
||
assert!(narrowed > 0, "a tab must show something");
|
||
assert!(
|
||
narrowed < all,
|
||
"and less than everything: {narrowed} of {all}"
|
||
);
|
||
}
|
||
|
||
/// The trap: `op_index` counts over *every* capability, so a row that
|
||
/// survived a filter must still route to the operation it came from. If it
|
||
/// renumbered, a slider would drive a different operation once a tab was
|
||
/// chosen.
|
||
#[test]
|
||
fn a_filtered_row_still_drives_its_own_operation() {
|
||
let Some(ctx) = headless() else { return };
|
||
let mut session = tabbed_session(&ctx);
|
||
|
||
// Find a colour row while unfiltered, and remember where it points.
|
||
let colour = session
|
||
.tabs()
|
||
.iter()
|
||
.position(|(_, n)| n == "Colour")
|
||
.expect("the chain has colour operations");
|
||
session.set_active_tab(colour as i32);
|
||
|
||
let row = session.rows().into_iter().next().expect("a row");
|
||
let (op, param) = (row.op_index, row.param_index);
|
||
|
||
session.set_param(op, param, 0.5);
|
||
let after = session
|
||
.rows()
|
||
.into_iter()
|
||
.find(|r| r.op_index == op && r.param_index == param)
|
||
.expect("the row survived");
|
||
|
||
assert!(
|
||
(after.value - 0.5).abs() < 1e-5,
|
||
"the value landed on the row that asked for it, not another"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn all_is_reachable_again() {
|
||
let Some(ctx) = headless() else { return };
|
||
let mut session = tabbed_session(&ctx);
|
||
let all = session.rows().len();
|
||
|
||
session.set_active_tab(0);
|
||
assert!(session.rows().len() < all);
|
||
|
||
session.set_active_tab(-1);
|
||
assert_eq!(session.rows().len(), all, "-1 means everything");
|
||
assert_eq!(session.active_tab(), -1);
|
||
}
|
||
|
||
/// An out-of-range index is navigation nonsense, not an edit; it must not
|
||
/// leave the panel showing nothing.
|
||
#[test]
|
||
fn a_nonsense_tab_falls_back_to_everything() {
|
||
let Some(ctx) = headless() else { return };
|
||
let mut session = tabbed_session(&ctx);
|
||
let all = session.rows().len();
|
||
|
||
session.set_active_tab(99);
|
||
assert_eq!(session.rows().len(), all);
|
||
}
|
||
}
|
||
|
||
/// `dr_pipeline`'s morphology, as `dr_segment` names it.
|
||
///
|
||
/// Two enums for one idea, and deliberately: `dr-pipeline` describes the
|
||
/// *edit* and `dr-segment` implements the *transform*, and neither depends on
|
||
/// the other. The crossing is this function, which the compiler makes
|
||
/// exhaustive on both sides.
|
||
fn morphology_for(m: dr_pipeline::mask::Morphology) -> dr_segment::Morphology {
|
||
use dr_pipeline::mask::Morphology as Edit;
|
||
use dr_segment::Morphology as Transform;
|
||
match m {
|
||
Edit::None => Transform::None,
|
||
Edit::Dilate => Transform::Dilate,
|
||
Edit::Erode => Transform::Erode,
|
||
Edit::Close => Transform::Close,
|
||
Edit::Open => Transform::Open,
|
||
}
|
||
}
|