Reported as a thumbnail bug; the export had it too, which is the serious half. You would have exported a photograph missing every local adjustment. Both called the unmasked `render`, and the failure is silent by construction: the generated shader always declares the mask binding and always emits a block per active layer, so binding the empty placeholder multiplies each of them by zero. No error, no warning, no missing texture — the adjustments are simply not there. From inside either path there is nothing to see. Every path that produces pixels now goes through one helper that binds the array, and that is the point of it being one helper rather than three correct call sites. The array is rasterised in source space at proxy size and sampled through the framing map, so one array serves every output size: a 256px thumbnail and a 24 MP export bind the same texture. Three tests, and the first is the fault stated directly — render the same edit with and without the array and assert they *differ*. If binding it ever stops mattering, the masks have stopped reaching the shader. The third checks the masked share of the frame is the same at 32px and 128px, because "both non-empty" would pass while a mask that scaled wrongly still ruined every thumbnail.
3286 lines
133 KiB
Rust
3286 lines
133 KiB
Rust
//! The develop session — capabilities in, rendered image out.
|
||
//!
|
||
//! This is the only place the UI touches the pipeline, and it does so through
|
||
//! two calls: [`dr_pipeline::EditGraph::capabilities`] to learn what controls
|
||
//! to build, and `set_param` to change one. It never names an operation, and
|
||
//! it knows nothing about shaders.
|
||
//!
|
||
//! Whether a control is a slider or a switch follows from the parameter's
|
||
//! declared [`ParamKind`], not from which parameter it is (ARCH §4.3), so a
|
||
//! new operation appears in the panel with no change here (FR-DEV-3c).
|
||
|
||
use dr_decode::RawImage;
|
||
use dr_gpu::{
|
||
AdjustPass, DemosaicedImage, Demosaicer, GpuContext, Histogram, HistogramPass, 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;
|
||
|
||
pub struct DevelopSession {
|
||
/// 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: 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>,
|
||
}
|
||
|
||
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 {
|
||
ctx: ctx.clone(),
|
||
graph,
|
||
history,
|
||
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,
|
||
}
|
||
}
|
||
|
||
/// 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)),
|
||
None => rows_from(&caps),
|
||
}
|
||
}
|
||
|
||
/// 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)).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.
|
||
pub(crate) fn rows_from(caps: &[OpCapability]) -> Vec<ParamRow> {
|
||
rows_filtered(caps, |_| true)
|
||
}
|
||
|
||
/// 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.
|
||
pub(crate) fn rows_filtered(
|
||
caps: &[OpCapability],
|
||
keep: impl Fn(&OpCapability) -> bool,
|
||
) -> 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),
|
||
// Canvas-hosted kinds returned above; the rest are not
|
||
// implemented and reached sliders via `choose`.
|
||
WidgetKind::ColourWheel
|
||
| WidgetKind::CropOverlay
|
||
| WidgetKind::GradientHandle
|
||
| WidgetKind::BrushMask
|
||
| WidgetKind::WhitePoint => None,
|
||
};
|
||
if let Some(row) = row {
|
||
rows.push(row);
|
||
continue;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Whether anything in this operation has been touched, aggregated
|
||
// before the rows are built so every row of the group can carry
|
||
// the same answer — the panel's heading is one of them and cannot
|
||
// see the others.
|
||
//
|
||
// Derived here rather than asked of the core: a group is a
|
||
// composition this side invented, so whether one is modified is
|
||
// this side's question to answer (ARCH §4.3a).
|
||
let group_modified = op.params.iter().any(|p| p.value != p.default);
|
||
let group_len = op.params.len() as i32;
|
||
|
||
// The aspect the previous row belonged to, so a run can be told
|
||
// from its continuation. Reset per operation: two operations that
|
||
// happened to facet on the same key are still two groups.
|
||
let mut previous_aspect: Option<&str> = None;
|
||
|
||
for param_index in presentation_order(&op.params) {
|
||
let p = &op.params[param_index];
|
||
// Empty for every kind but `Enum`, which is what the panel
|
||
// keys on to build a segmented control rather than a slider.
|
||
let mut choices: Vec<slint::SharedString> = Vec::new();
|
||
let (kind, min, max, precision, unit) = match &p.kind {
|
||
ParamKind::Scalar {
|
||
min,
|
||
max,
|
||
unit,
|
||
precision,
|
||
..
|
||
} => (
|
||
"scalar",
|
||
*min,
|
||
*max,
|
||
i32::from(*precision),
|
||
unit_suffix(*unit),
|
||
),
|
||
ParamKind::Bool => ("bool", 0.0, 1.0, 0, ""),
|
||
// The value is a variant index, so the range is the list's
|
||
// own bounds and the precision is whole numbers. Labels are
|
||
// resolved here, against this crate's catalogue, because
|
||
// the core deals in localisation keys only (NFR-A11Y-1).
|
||
ParamKind::Enum { variants } => {
|
||
choices = variants
|
||
.iter()
|
||
.map(|v| labels::resolve(v.0).into())
|
||
.collect();
|
||
("enum", 0.0, variants.len().saturating_sub(1) as f32, 0, "")
|
||
}
|
||
};
|
||
|
||
// A faceted parameter is named by its *subject* — the band —
|
||
// because its aspect is already written above the run it sits
|
||
// in. Unfaceted parameters keep their own label, which is
|
||
// every operation but the mixer.
|
||
let param_label = match &p.facet {
|
||
Some(f) => labels::resolve(f.subject.0),
|
||
None => labels::resolve(p.label.0),
|
||
};
|
||
let aspect = p.facet.as_ref().map(|f| f.aspect.0);
|
||
let starts_facet = aspect.is_some() && aspect != previous_aspect;
|
||
previous_aspect = aspect;
|
||
|
||
rows.push(ParamRow {
|
||
op_index: op_index as i32,
|
||
param_index: param_index as i32,
|
||
op_label: labels::resolve(op.label.0).into(),
|
||
param_label: param_label.into(),
|
||
facet_label: aspect.map(labels::resolve).unwrap_or_default().into(),
|
||
starts_facet,
|
||
// -1 rather than an `Option`, which a Slint struct cannot
|
||
// carry: 0° is red, so no value in range can stand for
|
||
// "no swatch".
|
||
swatch_hue: p.facet.as_ref().and_then(|f| f.subject_hue).unwrap_or(-1.0),
|
||
group_head: group_head as i32,
|
||
group_len,
|
||
group_modified,
|
||
kind: kind.into(),
|
||
value: p.value,
|
||
default_value: p.default,
|
||
minimum: min,
|
||
maximum: max,
|
||
precision,
|
||
unit: unit.into(),
|
||
// Only curve rows carry points.
|
||
points: no_points(),
|
||
// The shared empty model unless this row really has choices —
|
||
// see `no_choices` for why the identity matters.
|
||
choices: if choices.is_empty() {
|
||
no_choices()
|
||
} else {
|
||
slint::ModelRc::new(slint::VecModel::from(choices))
|
||
},
|
||
});
|
||
}
|
||
}
|
||
rows
|
||
}
|
||
|
||
/// One row standing for a whole curve.
|
||
///
|
||
/// Returns `None` if the operation's parameters do not look like point
|
||
/// coordinates, in which case the caller falls back to sliders rather than
|
||
/// rendering a broken widget.
|
||
fn curve_row(
|
||
op_index: usize,
|
||
group_head: usize,
|
||
op: &OpCapability,
|
||
presentation: &Presentation,
|
||
) -> Option<ParamRow> {
|
||
// Points are x/y pairs, so an odd count means the operation and this
|
||
// code disagree about the layout.
|
||
if presentation.params.len() < 2 || !presentation.params.len().is_multiple_of(2) {
|
||
log::warn!("{}: curve widget needs an even parameter count", op.id);
|
||
return None;
|
||
}
|
||
|
||
// The widget addresses points by offset from the first, so they must
|
||
// be contiguous in the capability list.
|
||
let base = op
|
||
.params
|
||
.iter()
|
||
.position(|p| p.id == presentation.params[0])?;
|
||
for (i, id) in presentation.params.iter().enumerate() {
|
||
if op.params.get(base + i).map(|p| p.id) != Some(*id) {
|
||
log::warn!("{}: curve parameters are not contiguous", op.id);
|
||
return None;
|
||
}
|
||
}
|
||
|
||
let points: Vec<f32> = presentation
|
||
.params
|
||
.iter()
|
||
.filter_map(|id| op.params.iter().find(|p| p.id == *id))
|
||
.map(|p| p.value)
|
||
.collect();
|
||
|
||
Some(ParamRow {
|
||
op_index: op_index as i32,
|
||
// The first point parameter; the widget offsets from here.
|
||
param_index: base as i32,
|
||
op_label: labels::resolve(op.label.0).into(),
|
||
param_label: String::new().into(),
|
||
// A widget spanning a whole operation is not a row in anyone's
|
||
// grid, so it heads no run and carries no swatch.
|
||
facet_label: String::new().into(),
|
||
starts_facet: false,
|
||
swatch_hue: -1.0,
|
||
group_head: group_head as i32,
|
||
// One widget standing for every parameter of the operation, so
|
||
// the group it heads is itself and nothing else.
|
||
group_len: 1,
|
||
group_modified: op.params.iter().any(|p| p.value != p.default),
|
||
kind: "curve".into(),
|
||
value: 0.0,
|
||
default_value: 0.0,
|
||
minimum: 0.0,
|
||
maximum: 1.0,
|
||
precision: 4,
|
||
unit: String::new().into(),
|
||
points: slint::ModelRc::new(slint::VecModel::from(points)),
|
||
// A curve is not a choice between named alternatives.
|
||
choices: no_choices(),
|
||
})
|
||
}
|
||
|
||
impl DevelopSession {
|
||
/// The curve's shape, sampled for drawing.
|
||
///
|
||
/// Evaluated with `dr_pipeline`'s own spline, so the line the user drags
|
||
/// is the line the shader applies. The alternative — reading the curve
|
||
/// back off the GPU — is the round-trip ARCH §6.1 forbids, to draw a
|
||
/// polyline.
|
||
pub fn curve_samples(&self) -> Vec<f32> {
|
||
const SAMPLES: usize = 96;
|
||
|
||
let mut xs = [0.0f32; curve::POINTS];
|
||
let mut ys = [0.0f32; curve::POINTS];
|
||
let mut found = false;
|
||
|
||
for cap in self.graph.capabilities() {
|
||
if cap.id != curve::ID {
|
||
continue;
|
||
}
|
||
found = true;
|
||
for (i, p) in cap.params.iter().enumerate() {
|
||
let point = i / 2;
|
||
if point >= curve::POINTS {
|
||
break;
|
||
}
|
||
if i % 2 == 0 {
|
||
xs[point] = p.value;
|
||
} else {
|
||
ys[point] = p.value;
|
||
}
|
||
}
|
||
}
|
||
if !found {
|
||
return Vec::new();
|
||
}
|
||
|
||
// Sorted the same way the operation sorts before handing points to
|
||
// the shader, or a dragged-past point would draw differently from
|
||
// how it renders.
|
||
sort_with_gap(&mut xs);
|
||
|
||
(0..SAMPLES)
|
||
.map(|i| {
|
||
let x = i as f32 / (SAMPLES - 1) as f32;
|
||
curve::evaluate(&xs, &ys, x).clamp(0.0, 1.0)
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// Return every parameter of one operation to its default.
|
||
///
|
||
/// What both a section's reset and a curve's reset do — a curve is one
|
||
/// widget spanning all of its operation's parameters, so "reset this
|
||
/// curve" and "reset this operation" were always the same action. Nothing
|
||
/// here is curve-shaped; it walks whatever parameters the operation
|
||
/// declares.
|
||
pub fn reset_op(&mut self, op_index: i32) {
|
||
let caps = self.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.
|
||
fn render_with_masks(
|
||
&mut self,
|
||
shader: &dr_pipeline::operation::ComposedShader,
|
||
w: u32,
|
||
h: u32,
|
||
) -> 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();
|
||
|
||
self.adjust
|
||
.render_masked(&self.demosaiced, shader, w, h, masks)
|
||
.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 Some(seg) = self.segmentation.as_ref() else {
|
||
return false;
|
||
};
|
||
let (pw, ph) = seg.proxy_size();
|
||
let subjects = self.subjects.as_ref();
|
||
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 as u32,
|
||
ph as u32,
|
||
)
|
||
.inspect_err(|e| log::warn!("mask rasterisation failed: {e}"))
|
||
.is_ok()
|
||
}
|
||
|
||
// ----------------------------------------------------------------------
|
||
// Segmentation (S15, docs/segmentation.md)
|
||
// ----------------------------------------------------------------------
|
||
|
||
/// TRACES: FR-DEV-3
|
||
/// Compute the region map this image's local masks select from.
|
||
///
|
||
/// **Blocking, and roughly half a second.** The caller is responsible for
|
||
/// running it off the UI thread — see the worker in `lib.rs`. It is
|
||
/// exposed as a plain blocking call rather than something async because
|
||
/// what it needs is a GPU context and a CPU core, not a runtime.
|
||
pub fn segment(&mut self, ctx: &GpuContext, options: &segmentation::Options) -> Result<(), String> {
|
||
// 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).
|
||
// Timed and logged, because this blocks the interface and the size of
|
||
// that stall is the feature's largest open risk. 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(ctx, SEGMENT_PROXY_EDGE)?;
|
||
let proxied = started.elapsed();
|
||
|
||
let seg = segmentation::compute(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,
|
||
);
|
||
|
||
if self.masks.is_none() {
|
||
self.masks = MaskPass::new(ctx)
|
||
.inspect_err(|e| log::warn!("no mask rasteriser on this device: {e}"))
|
||
.ok();
|
||
}
|
||
|
||
// 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(seg);
|
||
self.subjects = None;
|
||
self.subject_key = 0;
|
||
Ok(())
|
||
}
|
||
|
||
/// Render the *unedited* image to a CPU buffer at proxy size.
|
||
///
|
||
/// Goes through a throwaway [`AdjustPass`] with a neutral graph rather
|
||
/// than the session's own. Reusing `self.adjust` would overwrite the frame
|
||
/// the histogram reads and leave the view showing an unedited image until
|
||
/// the next redraw — a visible flicker for the sake of not allocating.
|
||
///
|
||
/// 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,
|
||
ctx: &GpuContext,
|
||
max_edge: u32,
|
||
) -> Result<(Vec<f32>, usize, usize), String> {
|
||
let (sw, sh) = self.demosaiced.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(ctx);
|
||
pass.render(&self.demosaiced, &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 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()
|
||
}
|
||
|
||
/// 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)?;
|
||
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)?;
|
||
|
||
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)?;
|
||
|
||
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
|
||
}
|
||
|
||
// ----------------------------------------------------------------------
|
||
// 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(ctx, &crate::segmentation::Options::default())
|
||
.ok()?;
|
||
Some(session)
|
||
}
|
||
|
||
fn headless() -> Option<GpuContext> {
|
||
pollster::block_on(dr_gpu::GpuContext::new_headless()).ok()
|
||
}
|
||
|
||
#[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() {
|
||
// Ten point parameters must appear as one curve control, not ten
|
||
// sliders — otherwise the widget and the sliders both render and the
|
||
// panel shows the same values twice.
|
||
let graph = EditGraph::default_chain();
|
||
let curve_cap = graph
|
||
.capabilities()
|
||
.into_iter()
|
||
.find(|c| c.id == curve::ID)
|
||
.expect("the chain includes a tone curve");
|
||
|
||
assert_eq!(curve_cap.params.len(), curve::POINTS * 2);
|
||
let presentation = curve_cap
|
||
.presentation
|
||
.as_ref()
|
||
.expect("the curve declares a widget");
|
||
// Asked the way the panel asks it: the first preference this frontend
|
||
// implements, not a fixed single kind.
|
||
assert_eq!(presentation.choose(supported), Some(WidgetKind::ToneCurve));
|
||
// Every parameter is owned by the widget, so none is left over to be
|
||
// rendered as a stray slider.
|
||
assert_eq!(presentation.params.len(), curve_cap.params.len());
|
||
}
|
||
|
||
#[test]
|
||
fn curve_point_parameters_are_contiguous() {
|
||
// The widget addresses points by offset from the first. Were they
|
||
// interleaved with anything else, dragging a point would write to
|
||
// the wrong parameter.
|
||
let graph = EditGraph::default_chain();
|
||
let cap = graph
|
||
.capabilities()
|
||
.into_iter()
|
||
.find(|c| c.id == curve::ID)
|
||
.expect("tone curve present");
|
||
let presentation = cap.presentation.as_ref().expect("declares a widget");
|
||
|
||
let base = cap
|
||
.params
|
||
.iter()
|
||
.position(|p| p.id == presentation.params[0])
|
||
.expect("first point is a parameter");
|
||
for (i, id) in presentation.params.iter().enumerate() {
|
||
assert_eq!(
|
||
cap.params[base + i].id,
|
||
*id,
|
||
"point parameter {i} is out of order"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn curve_samples_start_on_the_diagonal() {
|
||
// A fresh curve is the identity, so the drawn line must be the 45°
|
||
// diagonal — anything else means the widget opens showing a shape
|
||
// the image does not have.
|
||
let mut xs = [0.0f32; curve::POINTS];
|
||
let mut ys = [0.0f32; curve::POINTS];
|
||
for i in 0..curve::POINTS {
|
||
let t = i as f32 / (curve::POINTS - 1) as f32;
|
||
xs[i] = t;
|
||
ys[i] = t;
|
||
}
|
||
for i in 0..=20 {
|
||
let x = i as f32 / 20.0;
|
||
let y = curve::evaluate(&xs, &ys, x);
|
||
assert!((y - x).abs() < 1e-4, "at {x} the identity gave {y}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn sorting_enforces_a_minimum_gap() {
|
||
// Two points dragged onto each other would divide by zero in the
|
||
// spline; the drawn curve must survive it exactly as the shader does.
|
||
let mut xs = [0.5, 0.5, 0.5, 0.5, 0.5];
|
||
sort_with_gap(&mut xs);
|
||
for i in 1..xs.len() {
|
||
assert!(xs[i] > xs[i - 1], "not separated: {xs:?}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn sorting_orders_reversed_points() {
|
||
let mut xs = [0.9, 0.7, 0.5, 0.3, 0.1];
|
||
sort_with_gap(&mut xs);
|
||
for i in 1..xs.len() {
|
||
assert!(xs[i] > xs[i - 1], "not sorted: {xs:?}");
|
||
}
|
||
}
|
||
|
||
/// A frame black on the left half and white on the right, at `size`
|
||
/// square. Both ends of the histogram are occupied and both clipping
|
||
/// counters are non-zero, and cropping to one half leaves exactly one of
|
||
/// them so.
|
||
fn split_frame(size: u32) -> Vec<u8> {
|
||
let mut rgba = Vec::with_capacity((size * size * 4) as usize);
|
||
for _ in 0..size {
|
||
for x in 0..size {
|
||
let v = if x < size / 2 { 0u8 } else { 255 };
|
||
rgba.extend_from_slice(&[v, v, v, 255]);
|
||
}
|
||
}
|
||
rgba
|
||
}
|
||
|
||
/// TRACES: FR-DSP-7
|
||
#[test]
|
||
fn the_histogram_counts_the_frame_that_is_actually_on_the_canvas() {
|
||
// The wiring, end to end and against exact numbers: a 64x64 frame that
|
||
// is half black and half white must come back as 2048 pixels at level
|
||
// 0, 2048 at 255, and both clipping counters at 2048.
|
||
//
|
||
// Asserted at the session rather than at the pass because the mistake
|
||
// this catches is not arithmetic — `dr_gpu` has its own tests for that
|
||
// — it is counting the *wrong texture*. Reading a stale target, or the
|
||
// demosaiced source instead of the adjusted output, produces a
|
||
// perfectly well-formed histogram of an image the photographer is not
|
||
// looking at, which is the one failure mode that cannot be seen.
|
||
let Ok(ctx) = pollster::block_on(dr_gpu::GpuContext::new_headless()) else {
|
||
log::warn!("no GPU adapter; skipping");
|
||
return;
|
||
};
|
||
|
||
let rgba = split_frame(64);
|
||
let mut session =
|
||
DevelopSession::open_rgb(&ctx, &rgba, 64, 64, dr_types::Orientation::NORMAL)
|
||
.expect("session");
|
||
session.render(64, 64).expect("render");
|
||
|
||
let hist = session.histogram().expect("a rendered session must count");
|
||
assert_eq!(hist.pixels(), 64 * 64);
|
||
assert_eq!(hist.red()[0], 2048, "the black half");
|
||
assert_eq!(hist.red()[255], 2048, "the white half");
|
||
assert_eq!(hist.clipped_shadows(), 2048);
|
||
assert_eq!(hist.clipped_highlights(), 2048);
|
||
}
|
||
|
||
/// TRACES: FR-DSP-7
|
||
#[test]
|
||
fn the_histogram_follows_the_edit_rather_than_the_file() {
|
||
// The property that makes it *live*. A histogram computed once from the
|
||
// source would pass the test above and be useless — the whole reason
|
||
// FR-DSP-7 exists is to show what an adjustment is doing, so cropping
|
||
// away the white half must leave a histogram with no white in it and
|
||
// no highlight clipping to report.
|
||
let Ok(ctx) = pollster::block_on(dr_gpu::GpuContext::new_headless()) else {
|
||
log::warn!("no GPU adapter; skipping");
|
||
return;
|
||
};
|
||
|
||
let rgba = split_frame(64);
|
||
let mut session =
|
||
DevelopSession::open_rgb(&ctx, &rgba, 64, 64, dr_types::Orientation::NORMAL)
|
||
.expect("session");
|
||
|
||
session.set_crop(CropRect {
|
||
x: 0.0,
|
||
y: 0.0,
|
||
width: 0.5,
|
||
height: 1.0,
|
||
});
|
||
session.render(64, 64).expect("render");
|
||
|
||
let hist = session.histogram().expect("histogram");
|
||
assert_eq!(hist.pixels(), 32 * 64, "the crop halved the frame");
|
||
assert_eq!(hist.red()[0], 32 * 64);
|
||
assert_eq!(hist.red()[255], 0, "the white half was cropped away");
|
||
assert_eq!(hist.clipped_highlights(), 0);
|
||
assert_eq!(hist.clipped_shadows(), 32 * 64);
|
||
}
|
||
|
||
#[test]
|
||
fn routing_indices_map_back_to_the_right_parameter() {
|
||
// A wrong index would silently move the wrong slider's value, which
|
||
// is exactly the kind of bug that looks like a rendering fault.
|
||
let graph = EditGraph::default_chain();
|
||
let caps = graph.capabilities();
|
||
for (oi, op) in caps.iter().enumerate() {
|
||
for (pi, p) in op.params.iter().enumerate() {
|
||
assert_eq!(caps[oi].params[pi].id, p.id);
|
||
assert_eq!(caps[oi].id, op.id);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ----------------------------------------------------------------------
|
||
// 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,
|
||
}
|
||
}
|