Thirteen requirements were surveyed as built but untagged. Eight of them were: R3, R6, FR-DEV-1, FR-UI-6, FR-NC-6d, NFR-OPS-3, NFR-PORT-2 and NFR-SEC-3. Each was read against its full text in requirements.md and against the code before the tag was added, because a tag that is wrong is worse than an absent one — it turns a visible gap into an invisible one. The five that were refused, and why, because the reasoning is the part worth keeping: R2 carries "(figure TBD)" in its own acceptance criterion and asks for a stated prefetch margin and cache-hit rate; neither figure exists anywhere in the tree and neither quantity is measured, while TD-2 and TD-3 both describe the thumbnail path falling short of it. R5 asks for three things and the code does one. The display pipeline does run at viewport resolution, but "only visible tiles are computed" and "panning recomputes only newly exposed tiles" need a tile scheduler that does not exist — and frame_budget.rs currently argues for striking tiled computation from the interactive path rather than building it. FR-RAW-2 asks for a trait taking a SourceRef, so that a second decoder can be added without changing callers. What exists is free functions over &[u8]. That meets the requirement's stated *purpose* — the same decoder serves a local file, a SAF document and a byte range, which is exactly why it takes bytes — but there is no trait and no second implementation seam, so the requirement should probably be amended rather than tagged. NFR-ARCH-1 asks for named executors with stated thread counts. architecture.md §7.1 states the table; nothing implements it. Workers are twenty-odd ad-hoc std::thread::spawn sites, each building its own one-worker tokio runtime, with no decode pool, no GPU-submit executor and no I/O pool. The requirement's own text says R4 and NFR-P9 "assert an outcome with no stated means", and that is still true. NFR-SEC-4 is satisfied by absence — there is no telemetry — and absence has no module to tag. A tag would point at nothing. NFR-OPS-3 was the closest call of the eight taken. The store is single, separate from the catalog, survives a catalog rebuild and does not sync between devices; it has no version *field*, deliberately, and settings.rs argues why and names the condition that would need one. The substance is met and the reasoning is recorded where it belongs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1216 lines
49 KiB
Rust
1216 lines
49 KiB
Rust
//! TRACES: FR-DEV-1
|
|
//! The edit graph — an ordered set of operations (ARCH §3.4).
|
|
//!
|
|
//! CPU-side state, deliberately. The GPU device can be lost and rebuilt at any
|
|
//! moment on Android (ARCH §6.10), and recovery is only tractable because
|
|
//! everything needed to re-render lives here rather than in GPU memory.
|
|
//!
|
|
//! Order is data, not code: operations run in the sequence this holds them,
|
|
//! so reordering the pipeline needs no code change.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use crate::descriptor::{
|
|
Attribute, Facet, LocalizedKey, OpDescriptor, OpId, ParamId, ParamKind, Presentation,
|
|
};
|
|
use crate::framing::{CropRect, Framing};
|
|
use crate::mask::MaskStack;
|
|
use crate::operation::{compose_full, ComposedShader, Operation};
|
|
use crate::ops;
|
|
use crate::preset::{Preset, Scope};
|
|
use crate::spot::SpotSet;
|
|
use crate::state::{EditState, FilmRebake, FilmRef};
|
|
|
|
/// TRACES: FR-DEV-3a
|
|
/// What one operation offers, as plain data.
|
|
///
|
|
/// Deliberately owned rather than borrowed, and free of any trait objects:
|
|
/// the UI receives a snapshot it can hold across a frame without borrowing
|
|
/// the graph, and nothing in it hints at how the operation is implemented.
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub struct OpCapability {
|
|
pub id: OpId,
|
|
/// A key for the UI's own catalogue. Never a display string — resolving
|
|
/// it needs a localiser, which `core/` must not depend on.
|
|
pub label: LocalizedKey,
|
|
/// Whether this operation currently alters the image. A UI may use it to
|
|
/// mark a section as modified, or to offer a per-operation reset.
|
|
pub active: bool,
|
|
pub params: Vec<ParamCapability>,
|
|
/// A hint that several of `params` form one conceptual control.
|
|
///
|
|
/// `None` means one control per parameter. A UI that does not implement
|
|
/// the named widget may ignore this and render sliders — the parameters
|
|
/// are ordinary scalars either way, so nothing becomes unreachable.
|
|
pub presentation: Option<Presentation>,
|
|
/// TRACES: FR-DEV-3a | FR-DEV-3c
|
|
/// What this operation is about.
|
|
///
|
|
/// **The whole point is that a panel can group by these without knowing
|
|
/// what any operation is.** A tab strip built from the attributes present
|
|
/// in this list names no operation and needs no table mapping one to the
|
|
/// other, so a new operation joins the right group by declaring what it
|
|
/// is — which is the only thing its author is well placed to say.
|
|
///
|
|
/// Never empty; both readers refuse an operation that declares none.
|
|
pub attributes: Vec<Attribute>,
|
|
}
|
|
|
|
/// TRACES: FR-DEV-3a | FR-DEV-3b
|
|
/// What one parameter offers.
|
|
///
|
|
/// [`Self::kind`] is what selects the control: the UI maps each `ParamKind`
|
|
/// to a widget appropriate to the current input modality (ARCH §4.3), and
|
|
/// never switches on the parameter's identity.
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub struct ParamCapability {
|
|
pub id: ParamId,
|
|
pub label: LocalizedKey,
|
|
pub kind: ParamKind,
|
|
pub default: f32,
|
|
/// The current setting, so the control opens where the edit actually is.
|
|
pub value: f32,
|
|
/// Where this parameter sits among its siblings, when the operation's
|
|
/// parameters form a grid rather than a list. `None` for the usual case.
|
|
pub facet: Option<Facet>,
|
|
}
|
|
|
|
impl ParamCapability {
|
|
/// Whether this parameter is away from its default.
|
|
pub fn is_modified(&self) -> bool {
|
|
self.value != self.default
|
|
}
|
|
}
|
|
|
|
/// An ordered pipeline of operations, plus how the result is framed.
|
|
pub struct EditGraph {
|
|
ops: Vec<Box<dyn Operation>>,
|
|
/// Crop, straighten, rotation and flips.
|
|
///
|
|
/// Held apart from `ops` rather than in the list because it is not one:
|
|
/// an operation transforms a colour, and framing decides which source
|
|
/// pixel that colour is read from — and changes the output's dimensions,
|
|
/// which no colour operation can do. See [`crate::framing`].
|
|
framing: Framing,
|
|
/// The local adjustments (FR-DEV-3).
|
|
///
|
|
/// Also apart from `ops`, and for a sharper reason than framing's: each
|
|
/// layer *contains* a chain of its own. Folding the stack into the global
|
|
/// list would make the list recursive and every consumer that walks it
|
|
/// have to know that some entries are really sub-graphs.
|
|
masks: Arc<MaskStack>,
|
|
/// TRACES: FR-DEV-3f
|
|
/// The film stock this edit renders through, if any.
|
|
///
|
|
/// Apart from `ops` for the same reason `masks` is, and the reason
|
|
/// [`crate::sidecar::Version::rating`] is a top-level key: a stock is not
|
|
/// a scalar and not a slider. It is a *choice of material*, named by an
|
|
/// id, from which the tables in `ops::film_sim` are derived.
|
|
///
|
|
/// Both halves live here together — the id that persists and the tables
|
|
/// that render — because they are one fact, and holding them apart is how
|
|
/// a sidecar comes to name one stock while the shader draws another.
|
|
film: Option<Film>,
|
|
/// TRACES: FR-DEV-8
|
|
/// The repairs (`docs/spot-removal.md`).
|
|
///
|
|
/// Apart from `ops` for the third time and the same reason: a spot is not
|
|
/// a scalar, and a list of them is not a slider. It sits beside the masks
|
|
/// rather than among them because it is not a mask either — a mask says
|
|
/// *where* an adjustment applies, and a spot says where a piece of the
|
|
/// photograph comes from.
|
|
spots: SpotSet,
|
|
}
|
|
|
|
/// TRACES: FR-DEV-3f
|
|
/// A chosen stock, and what it bakes to.
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub struct Film {
|
|
/// The stock's id, e.g. `kodak_portra_400`. **This is what persists.**
|
|
///
|
|
/// A name rather than an index into the stock list, because the list is
|
|
/// data-driven: stocks are files, users add them, and an index would mean
|
|
/// installing a profile silently changed which film every existing
|
|
/// photograph was developed on.
|
|
pub stock: String,
|
|
/// The paper it is printed on, if it is printed. `None` views the film
|
|
/// directly — right for a reversal stock, and for a negative it is the
|
|
/// scan, orange mask and all.
|
|
pub print: Option<String>,
|
|
/// The baked tables. **Not persisted**: they are derived from the two ids
|
|
/// above plus the node's own exposure parameters, and re-baking is
|
|
/// milliseconds.
|
|
pub tables: crate::ops::FilmTables,
|
|
}
|
|
|
|
impl EditGraph {
|
|
/// The default develop chain, in pipeline order (ARCH §5.2).
|
|
///
|
|
/// The order is not written here. Each node declares its own place with
|
|
/// an `order:` in `ops/<id>.yaml`, and [`ops::chain`] is generated from
|
|
/// those — so adding an operation, or moving one, is an edit to a
|
|
/// declaration rather than to this file.
|
|
///
|
|
/// The order itself is still not arbitrary. White balance and exposure
|
|
/// come first because they are corrections to how the scene was captured,
|
|
/// and the tonal operations that follow should act on a correctly exposed
|
|
/// image. Colour comes last, so vibrance responds to the tones the user
|
|
/// has actually settled on rather than the ones they started with. Each
|
|
/// node records that reasoning for itself, under `placement:`.
|
|
pub fn default_chain() -> Self {
|
|
Self {
|
|
ops: ops::chain(),
|
|
framing: Framing::new(),
|
|
masks: Arc::new(MaskStack::new()),
|
|
film: None,
|
|
spots: SpotSet::new(),
|
|
}
|
|
}
|
|
|
|
/// TRACES: FR-DEV-3
|
|
/// The default chain with the detail stage's test consumer appended.
|
|
///
|
|
/// **Not a shipping path.** `detail_probe` is a separable box blur that
|
|
/// exists so the neighbourhood stage has something to run (see
|
|
/// [`crate::detail::probe`]); it is not declared in `ops/`, has no place
|
|
/// in the pipeline order, and is compiled only for tests and behind the
|
|
/// `detail-probe` feature.
|
|
///
|
|
/// It is a constructor rather than a fixture inside one test module
|
|
/// because `dr-gpu` needs the same graph: proving the stage works means
|
|
/// dispatching it, and dispatching it means composing both halves of the
|
|
/// shader from one graph exactly as the interface will.
|
|
#[cfg(any(test, feature = "detail-probe"))]
|
|
pub fn with_detail_probe() -> Self {
|
|
let mut graph = Self::default_chain();
|
|
graph
|
|
.ops
|
|
.push(Box::new(crate::detail::probe::BoxBlur::new()));
|
|
graph
|
|
}
|
|
|
|
/// TRACES: FR-DEV-8
|
|
/// The repairs.
|
|
pub fn spots(&self) -> &SpotSet {
|
|
&self.spots
|
|
}
|
|
|
|
pub fn spots_mut(&mut self) -> &mut SpotSet {
|
|
&mut self.spots
|
|
}
|
|
|
|
/// The local adjustment stack.
|
|
pub fn masks(&self) -> &MaskStack {
|
|
&self.masks
|
|
}
|
|
|
|
/// The stack, to modify.
|
|
///
|
|
/// Clones on write. The stack is shared with every [`EditState`] snapshot
|
|
/// taken since it last changed — the undo stack holds a run of them — so
|
|
/// this is where a shared stack becomes this graph's own again. Callers
|
|
/// see no difference; what it buys is that recording a slider drag does
|
|
/// not deep-copy a painted mask once a frame.
|
|
pub fn masks_mut(&mut self) -> &mut MaskStack {
|
|
Arc::make_mut(&mut self.masks)
|
|
}
|
|
|
|
/// The framing — crop, straighten, rotation and flips.
|
|
///
|
|
/// Reached directly rather than through `set_param` because the crop is a
|
|
/// rectangle, and driving one through four independent scalars makes an
|
|
/// interactive drag four clamps that can disagree. The parameter route
|
|
/// still exists for the sidecar, which has only scalars to work with.
|
|
pub fn framing(&self) -> &Framing {
|
|
&self.framing
|
|
}
|
|
|
|
pub fn framing_mut(&mut self) -> &mut Framing {
|
|
&mut self.framing
|
|
}
|
|
|
|
/// The size this graph renders to, given a source of `(w, h)`.
|
|
///
|
|
/// Cropping and quarter turns change it, so the caller allocating the
|
|
/// output texture must ask rather than assume the source size.
|
|
pub fn output_size(&self, width: u32, height: u32) -> (u32, u32) {
|
|
self.framing.output_size(width, height)
|
|
}
|
|
|
|
/// Descriptors for every operation, in order.
|
|
///
|
|
/// Operations only — framing is not one, and is reached through
|
|
/// [`Self::framing`] or the capability list. The distinction matters here
|
|
/// because this is what the codegen tests count `---- ` shader blocks
|
|
/// against, and framing generates a prologue rather than a colour block.
|
|
/// A UI wanting everything should read [`Self::capabilities`] (FR-DEV-3a).
|
|
pub fn descriptors(&self) -> Vec<Arc<OpDescriptor>> {
|
|
self.ops.iter().map(|o| o.descriptor()).collect()
|
|
}
|
|
|
|
/// TRACES: FR-DEV-3a | FR-DEV-3c
|
|
/// Everything a UI needs to build its controls.
|
|
///
|
|
/// **This is the only thing the UI should read.** It must not know that
|
|
/// exposure exists, that saturation is implemented with a mix, or that
|
|
/// any of this becomes a shader — it walks this list and instantiates a
|
|
/// control per entry according to the [`ParamKind`]. A new operation
|
|
/// therefore appears in the interface with no UI change at all
|
|
/// (FR-DEV-3c), and an operation removed from the chain disappears from
|
|
/// it just as automatically.
|
|
///
|
|
/// Current values are included so the UI has no separate initialisation
|
|
/// step, and so reopening an edited image shows where the sliders
|
|
/// actually are.
|
|
pub fn capabilities(&self) -> Vec<OpCapability> {
|
|
let ops = self.ops.iter().map(|op| {
|
|
let desc = op.descriptor();
|
|
OpCapability {
|
|
id: desc.id,
|
|
label: desc.label,
|
|
active: op.is_active(),
|
|
params: desc
|
|
.params
|
|
.iter()
|
|
.map(|p| ParamCapability {
|
|
id: p.id,
|
|
label: p.label,
|
|
kind: p.kind.clone(),
|
|
default: p.default,
|
|
value: op.param(p.id),
|
|
facet: p.facet,
|
|
})
|
|
.collect(),
|
|
presentation: op.presentation(),
|
|
attributes: desc.attributes.clone(),
|
|
}
|
|
});
|
|
|
|
// Framing last, matching where it sits in the pipeline: the crop is
|
|
// decided after the image looks right, not before.
|
|
let desc = self.framing.descriptor();
|
|
let framing = OpCapability {
|
|
id: desc.id,
|
|
label: desc.label,
|
|
active: self.framing.is_active(),
|
|
params: desc
|
|
.params
|
|
.iter()
|
|
.map(|p| ParamCapability {
|
|
id: p.id,
|
|
label: p.label,
|
|
kind: p.kind.clone(),
|
|
default: p.default,
|
|
value: self.framing.param(p.id),
|
|
facet: p.facet,
|
|
})
|
|
.collect(),
|
|
// Framing is not an `Operation`, but it has the same thing to say
|
|
// about how it wants drawing: a crop is dragged on the photograph.
|
|
// Declaring it here is what lets the frontend skip generating
|
|
// sliders for framing *without naming framing* — see
|
|
// `Framing::presentation`.
|
|
presentation: self.framing.presentation(),
|
|
attributes: desc.attributes.clone(),
|
|
};
|
|
|
|
ops.chain(std::iter::once(framing)).collect()
|
|
}
|
|
|
|
/// Set a parameter, clamping to the descriptor's declared range.
|
|
///
|
|
/// Clamping here rather than in each operation means an operation never
|
|
/// has to defend against an out-of-range value, and a corrupt sidecar
|
|
/// cannot reach a shader.
|
|
/// TRACES: FR-DEV-3f
|
|
/// Develop on a film stock, or stop doing so.
|
|
///
|
|
/// One call for the id and the tables together, because they are one fact.
|
|
/// Offered to every operation rather than to the one that wants it,
|
|
/// because the graph holds `Box<dyn Operation>` and knowing which concrete
|
|
/// type is which is exactly what it is organised not to know (ARCH §3.4).
|
|
/// The default implementation ignores it, so this costs a virtual call per
|
|
/// node on an action a user takes by hand.
|
|
pub fn set_film(&mut self, film: Option<Film>) {
|
|
for op in &mut self.ops {
|
|
op.set_film_tables(film.as_ref().map(|f| &f.tables));
|
|
}
|
|
self.film = film;
|
|
}
|
|
|
|
/// The stock this edit is being developed on.
|
|
pub fn film(&self) -> Option<&Film> {
|
|
self.film.as_ref()
|
|
}
|
|
|
|
/// TRACES: FR-DEV-5 | FR-CAT-8
|
|
/// The whole edit, as data — what an undo step and a sidecar are both
|
|
/// made of.
|
|
///
|
|
/// **The pattern below is exhaustive on purpose.** It is the only thing
|
|
/// standing between a new kind of graph state and an undo that quietly
|
|
/// ignores it, which is exactly how the mask stack came to be missing
|
|
/// from the history for as long as it was. Never add `..` to it: a field
|
|
/// added to this struct should fail to compile here until somebody has
|
|
/// decided whether stepping backwards has to put it back. See
|
|
/// [`crate::state`].
|
|
pub fn state(&self) -> EditState {
|
|
let Self {
|
|
// Both reached through `capabilities`, which is the one walk the
|
|
// develop panel, the clipboard and the sidecar already make — so
|
|
// an operation is undoable by virtue of being in the chain, with
|
|
// nothing to register (FR-DEV-3c).
|
|
ops: _,
|
|
framing: _,
|
|
masks,
|
|
film,
|
|
spots,
|
|
} = self;
|
|
|
|
EditState {
|
|
params: Preset::capture(self),
|
|
// A refcount bump. See `EditState::masks` for why that matters on
|
|
// a path called once a frame.
|
|
masks: Arc::clone(masks),
|
|
// The names travel; the tables do not. They are derived, and this
|
|
// crate cannot rebuild them — hence `FilmRebake`.
|
|
film: film.as_ref().map(|f| FilmRef {
|
|
stock: f.stock.clone(),
|
|
print: f.print.clone(),
|
|
}),
|
|
spots: spots.clone(),
|
|
}
|
|
}
|
|
|
|
/// TRACES: FR-DEV-5 | FR-CAT-8
|
|
/// Put `state` back, replacing whatever this graph held.
|
|
///
|
|
/// A *replacement*, not an overlay: a parameter absent from the state
|
|
/// means default, and a state with no mask blocks means an edit with no
|
|
/// local adjustments rather than an edit that keeps whatever was on
|
|
/// screen. That is the same rule [`Preset::apply`] and
|
|
/// [`crate::Version::apply`] keep, and for the same reason — "the
|
|
/// photograph is now as it was" is the whole claim the call makes.
|
|
///
|
|
/// The **viewport survives**, because [`Preset::apply`] preserves it. Zoom
|
|
/// says where the user is looking rather than what the picture is, and an
|
|
/// undo that refitted the frame would read as having navigated somewhere.
|
|
///
|
|
/// The pattern below is exhaustive for the reason [`Self::state`]'s is.
|
|
pub fn set_state(&mut self, state: &EditState) -> FilmRebake {
|
|
let EditState {
|
|
params,
|
|
masks,
|
|
film,
|
|
spots,
|
|
} = state;
|
|
|
|
// At full scope. `Scope` is a question about what a paste carries
|
|
// *between* photographs; this is one photograph's own edit being put
|
|
// back, so there is nothing to leave behind.
|
|
params.apply(self, Scope::Everything);
|
|
|
|
self.masks = Arc::clone(masks);
|
|
self.spots = spots.clone();
|
|
|
|
// Cleared either way, and when a stock is named the caller bakes it.
|
|
// Left standing, the tables now in the graph would be the ones baked
|
|
// from the film node's *previous* exposure sliders — and those sliders
|
|
// were just replaced, so the restored state would render through the
|
|
// film of the state it replaced. Clearing is the conservative half of
|
|
// that; `Wanted` is the half that gets it back.
|
|
self.set_film(None);
|
|
match film {
|
|
None => FilmRebake::NotNeeded,
|
|
Some(want) => FilmRebake::Wanted(want.clone()),
|
|
}
|
|
}
|
|
|
|
pub fn set_param(&mut self, op: OpId, param: ParamId, value: f32) {
|
|
if op == crate::framing::ID {
|
|
// Bound rather than chained: `descriptor()` hands back an owned
|
|
// `Arc` now, so a `param()` borrowed straight out of the call
|
|
// would outlive the temporary it came from.
|
|
let descriptor = self.framing.descriptor();
|
|
let Some(desc) = descriptor.param(param) else {
|
|
log::warn!("unknown parameter {param} on {op}; ignoring");
|
|
return;
|
|
};
|
|
self.framing.set_param(param, desc.clamp(value));
|
|
return;
|
|
}
|
|
|
|
let Some(operation) = self.ops.iter_mut().find(|o| o.descriptor().id == op) else {
|
|
// A sidecar naming an operation this build does not have. The
|
|
// rest of the edit must still apply.
|
|
log::warn!("unknown operation {op}; ignoring");
|
|
return;
|
|
};
|
|
let clamped = match operation.descriptor().param(param) {
|
|
Some(d) => d.clamp(value),
|
|
None => {
|
|
log::warn!("unknown parameter {param} on {op}; ignoring");
|
|
return;
|
|
}
|
|
};
|
|
operation.set_param(param, clamped);
|
|
}
|
|
|
|
/// Read a parameter back.
|
|
pub fn param(&self, op: OpId, param: ParamId) -> Option<f32> {
|
|
if op == crate::framing::ID {
|
|
return self
|
|
.framing
|
|
.descriptor()
|
|
.param(param)
|
|
.map(|_| self.framing.param(param));
|
|
}
|
|
self.ops
|
|
.iter()
|
|
.find(|o| o.descriptor().id == op)
|
|
.map(|o| o.param(param))
|
|
}
|
|
|
|
/// Reset every parameter of every operation, and the framing, to default.
|
|
pub fn reset(&mut self) {
|
|
for op in &mut self.ops {
|
|
for p in &op.descriptor().params {
|
|
op.set_param(p.id, p.default);
|
|
}
|
|
}
|
|
self.framing.reset();
|
|
// Masks go too, and this is why `apply` can be a replacement rather
|
|
// than an overlay: a sidecar with no mask blocks means an edit with no
|
|
// local adjustments, not an edit that keeps whatever was on screen.
|
|
self.masks = Arc::new(MaskStack::new());
|
|
// The film goes too, for the reason the masks do. Restoring it is the
|
|
// *caller's* job rather than `Version::apply`'s: a sidecar names a
|
|
// stock, and turning a name into tables needs the profile database,
|
|
// which this crate deliberately does not link (ARCH §6.5a).
|
|
self.set_film(None);
|
|
}
|
|
|
|
/// Set the crop rectangle. Clamped to keep it inside the frame.
|
|
pub fn set_crop(&mut self, rect: CropRect) {
|
|
self.framing.set_crop(rect);
|
|
}
|
|
|
|
pub fn crop(&self) -> CropRect {
|
|
self.framing.crop()
|
|
}
|
|
|
|
/// Rotate by quarter turns, wrapping. The rotate-left/right buttons.
|
|
pub fn rotate_quarters(&mut self, turns: i32) {
|
|
self.framing.rotate_quarters(turns);
|
|
}
|
|
|
|
/// Record how the file stored its pixels, from its EXIF orientation.
|
|
///
|
|
/// Set when the image is opened and never by an edit — see
|
|
/// [`crate::framing::Framing::set_baseline`]. Survives [`Self::reset`],
|
|
/// so it is safe to call before restoring a sidecar.
|
|
pub fn set_orientation(&mut self, orientation: dr_types::Orientation) {
|
|
self.framing.set_baseline(orientation);
|
|
}
|
|
|
|
/// Whether any operation, or the framing, currently changes the image.
|
|
///
|
|
/// Asks the framing whether it *edits*, not whether it is active: zoom
|
|
/// makes the framing active without changing the image, and reporting a
|
|
/// merely-zoomed image as edited would mark a clean file dirty.
|
|
pub fn is_neutral(&self) -> bool {
|
|
!self.ops.iter().any(|o| o.is_active())
|
|
&& !self.framing.edits_image()
|
|
&& self.masks.is_neutral()
|
|
&& self.spots.is_neutral()
|
|
}
|
|
|
|
/// Generate the fused shader for the current state, encoded to sRGB.
|
|
///
|
|
/// What the display path wants. An export that has been asked for a wider
|
|
/// space wants [`Self::compose_for`] instead, and must say so: the space
|
|
/// is baked into the shader, so a frame rendered by this one is sRGB and
|
|
/// nothing downstream can make it anything else.
|
|
pub fn compose(&self) -> ComposedShader {
|
|
self.compose_for(dr_types::ColourSpace::Srgb)
|
|
}
|
|
|
|
/// TRACES: FR-EXP-2
|
|
/// Generate the fused shader, encoded to a chosen output space.
|
|
///
|
|
/// Not stored on the graph, because it is not part of the edit: the same
|
|
/// graph renders to the screen and to a file in the same breath, and the
|
|
/// two want different answers.
|
|
pub fn compose_for(&self, output: dr_types::ColourSpace) -> ComposedShader {
|
|
compose_full(&self.ops, &self.framing, output, &self.masks, &self.spots)
|
|
}
|
|
|
|
/// TRACES: FR-DSP-1
|
|
/// How this render relates to the file it stands for.
|
|
///
|
|
/// `source` is the demosaiced image's size and `render` the size being
|
|
/// drawn now. The result describes *the region on screen*, with the crop
|
|
/// and the zoom already folded in: cropping to half the frame while the
|
|
/// viewport stays the same size genuinely does show twice the detail, and
|
|
/// zooming to 1:1 genuinely does make the preview exact. Both fall out of
|
|
/// the arithmetic rather than needing a special case.
|
|
///
|
|
/// Only the detail stage needs this. Every point operation is scale-free
|
|
/// — a multiply is a multiply at any resolution — which is why nothing in
|
|
/// the pipeline had to know its own size until a kernel arrived.
|
|
pub fn render_scale(
|
|
&self,
|
|
source: (u32, u32),
|
|
render: (u32, u32),
|
|
) -> crate::detail::RenderScale {
|
|
let (fw, fh) = self.framing.output_size(source.0, source.1);
|
|
let view = self.framing.view();
|
|
// The *viewed* part of the framed image, at source resolution. Zoom
|
|
// shrinks the view rect while the render target keeps its size, so
|
|
// this is what shrinks and the ratio is what climbs.
|
|
let full = (
|
|
((fw as f32 * view.width).round() as u32).max(1),
|
|
((fh as f32 * view.height).round() as u32).max(1),
|
|
);
|
|
crate::detail::RenderScale::new(render, full)
|
|
}
|
|
|
|
/// TRACES: FR-DEV-3 | FR-DSP-1
|
|
/// Generate the detail stage for this edit at one resolution, to sRGB.
|
|
///
|
|
/// Empty for every edit with no active neighbourhood operation, which is
|
|
/// almost all of them — and in that case [`Self::compose`] emits the
|
|
/// single encoded dispatch it always has.
|
|
pub fn compose_detail(
|
|
&self,
|
|
source: (u32, u32),
|
|
render: (u32, u32),
|
|
) -> crate::detail::ComposedDetail {
|
|
self.compose_detail_for(source, render, dr_types::ColourSpace::Srgb)
|
|
}
|
|
|
|
/// TRACES: FR-EXP-2
|
|
/// The detail stage, encoded into a chosen output space.
|
|
///
|
|
/// The space belongs here as well as on [`Self::compose_for`] because when
|
|
/// a detail stage exists it is the *last* pass that performs the output
|
|
/// transform — the fused pass stops at linear working values. Composing
|
|
/// the two halves for different spaces would encode the edit twice, or
|
|
/// not at all.
|
|
/// `source` is the demosaiced image's size and `render` the size being
|
|
/// drawn. The scale is worked out here rather than handed in, because the
|
|
/// repairs need the *source* size as well — a spot is stored in normalised
|
|
/// source coordinates and has to be put through the framing to find out
|
|
/// where it lands on this render, and a [`crate::detail::RenderScale`]
|
|
/// describes the region on screen rather than the photograph.
|
|
pub fn compose_detail_for(
|
|
&self,
|
|
source: (u32, u32),
|
|
render: (u32, u32),
|
|
output: dr_types::ColourSpace,
|
|
) -> crate::detail::ComposedDetail {
|
|
let scale = self.render_scale(source, render);
|
|
let spots = self.spots.passes(&self.framing, source, scale);
|
|
crate::detail::compose_detail_with(&self.ops, &spots, scale, output)
|
|
}
|
|
|
|
/// TRACES: FR-DEV-3d
|
|
/// The per-stage cache keys for the current edit.
|
|
///
|
|
/// See [`crate::Invalidation`] for what the keys mean and what may be
|
|
/// cached against them. In short: geometry covers the framing, colour
|
|
/// covers every fused operation and every mask layer, and detail covers
|
|
/// the neighbourhood operations — so moving one slider moves exactly one
|
|
/// key, and a consumer can tell which stages it has to redo.
|
|
pub fn invalidation(&self) -> crate::Invalidation {
|
|
use crate::operation::{hash_bytes, hash_op, mix, Affects, FNV_OFFSET};
|
|
|
|
// Geometry: the framing. Its own structure key covers the shape of the
|
|
// coordinate map; the parameters cover the magnitudes, which the
|
|
// structure key deliberately omits because they do not recompile a
|
|
// shader. Both matter to a cached *result*, so both are here.
|
|
let mut geometry = mix(FNV_OFFSET, self.framing.structure_key());
|
|
for p in &self.framing.descriptor().params {
|
|
geometry = hash_bytes(geometry, p.id.0.as_bytes());
|
|
geometry = mix(
|
|
geometry,
|
|
u64::from(crate::operation::canonical_bits(self.framing.param(p.id))),
|
|
);
|
|
}
|
|
// The view rect is not a parameter and not in the structure key — it
|
|
// is not an edit (see `Framing::view`). It is still an input to every
|
|
// rendered pixel, so a cache that ignored it would show the wrong part
|
|
// of the photograph after a scroll.
|
|
let view = self.framing.view();
|
|
for v in [view.x, view.y, view.width, view.height] {
|
|
geometry = mix(geometry, u64::from(crate::operation::canonical_bits(v)));
|
|
}
|
|
|
|
let mut colour = FNV_OFFSET;
|
|
let mut detail = FNV_OFFSET;
|
|
for op in &self.ops {
|
|
let target = if op.affects() == Affects::Detail {
|
|
&mut detail
|
|
} else {
|
|
&mut colour
|
|
};
|
|
*target = hash_op(*target, op.as_ref());
|
|
}
|
|
|
|
// The mask layers belong to the colour stage: their chains are fused
|
|
// into the same dispatch, and a layer's *shape* decides which pixels
|
|
// that dispatch treats differently. Both halves are folded in.
|
|
for layer in self.masks.layers() {
|
|
colour = hash_bytes(colour, layer.id.as_bytes());
|
|
// The source through its `Debug`, deliberately. A gradient's
|
|
// centre, a region's id list and a subject's signature are all
|
|
// part of where the layer applies, and matching on the variants
|
|
// here would be a second copy of `MaskSource`'s shape that falls
|
|
// out of step the first time a variant gains a field — silently,
|
|
// and showing as a mask that stops updating. `Debug` cannot fall
|
|
// out of step, because it is derived from the definition itself.
|
|
colour = hash_bytes(colour, format!("{:?}", layer.source).as_bytes());
|
|
colour = mix(colour, u64::from(layer.enabled));
|
|
colour = mix(colour, u64::from(layer.invert));
|
|
colour = hash_bytes(colour, layer.falloff.name().as_bytes());
|
|
for v in [layer.opacity, layer.feather, layer.morph_radius] {
|
|
colour = mix(colour, u64::from(crate::operation::canonical_bits(v)));
|
|
}
|
|
for (op_id, param_id, value) in layer.params() {
|
|
colour = hash_bytes(colour, op_id.as_bytes());
|
|
colour = hash_bytes(colour, param_id.as_bytes());
|
|
colour = mix(colour, u64::from(crate::operation::canonical_bits(value)));
|
|
}
|
|
}
|
|
|
|
// TRACES: FR-DEV-8
|
|
// The repairs belong to the detail stage, because that is where they
|
|
// run. Moving a spot therefore re-runs the neighbourhood passes and
|
|
// leaves the fused colour dispatch and the demosaic alone, which is
|
|
// the difference between a spot that follows the finger and one that
|
|
// stutters (FR-DEV-3d).
|
|
detail = self.spots.hash(detail);
|
|
|
|
crate::Invalidation::new(geometry, colour, detail)
|
|
}
|
|
}
|
|
|
|
impl Default for EditGraph {
|
|
fn default() -> Self {
|
|
Self::default_chain()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::ops::{exposure, white_balance};
|
|
|
|
#[test]
|
|
fn a_fresh_graph_is_neutral() {
|
|
// Opening an unedited image must produce the image, not an
|
|
// interpretation of it.
|
|
let g = EditGraph::default_chain();
|
|
assert!(g.is_neutral());
|
|
assert_eq!(
|
|
g.compose().source.matches("---- ").count(),
|
|
0,
|
|
"a neutral graph must generate no operation blocks"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn the_default_chain_exposes_every_operation() {
|
|
let g = EditGraph::default_chain();
|
|
let ids: Vec<&str> = g.descriptors().iter().map(|d| d.id.0).collect();
|
|
for expected in [
|
|
"white_balance",
|
|
"exposure",
|
|
"highlights_shadows",
|
|
"blacks_whites",
|
|
"brilliance",
|
|
"vibrance",
|
|
"saturation",
|
|
] {
|
|
assert!(ids.contains(&expected), "{expected} missing from the chain");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn white_balance_and_exposure_precede_the_tonal_operations() {
|
|
// Corrections to capture must come before interpretation of tone, or
|
|
// the tonal controls act on a wrongly exposed image.
|
|
let g = EditGraph::default_chain();
|
|
let ids: Vec<&str> = g.descriptors().iter().map(|d| d.id.0).collect();
|
|
let pos = |id: &str| ids.iter().position(|x| *x == id).expect(id);
|
|
assert!(pos("white_balance") < pos("highlights_shadows"));
|
|
assert!(pos("exposure") < pos("highlights_shadows"));
|
|
assert!(pos("highlights_shadows") < pos("vibrance"));
|
|
}
|
|
|
|
#[test]
|
|
fn setting_a_parameter_activates_its_operation() {
|
|
let mut g = EditGraph::default_chain();
|
|
g.set_param(exposure::ID, exposure::EXPOSURE, 1.5);
|
|
assert!(!g.is_neutral());
|
|
assert_eq!(g.param(exposure::ID, exposure::EXPOSURE), Some(1.5));
|
|
assert!(g.compose().source.contains("---- exposure ----"));
|
|
}
|
|
|
|
#[test]
|
|
fn values_are_clamped_to_the_descriptor() {
|
|
// The guarantee that lets each operation skip range checks.
|
|
let mut g = EditGraph::default_chain();
|
|
g.set_param(exposure::ID, exposure::EXPOSURE, 99.0);
|
|
assert_eq!(g.param(exposure::ID, exposure::EXPOSURE), Some(5.0));
|
|
}
|
|
|
|
#[test]
|
|
fn an_unknown_operation_is_ignored_rather_than_panicking() {
|
|
// A sidecar from a newer version names operations this build lacks.
|
|
// The rest of the edit must still load.
|
|
let mut g = EditGraph::default_chain();
|
|
g.set_param(OpId("time_machine"), ParamId("year"), 1994.0);
|
|
assert!(g.is_neutral());
|
|
}
|
|
|
|
#[test]
|
|
fn an_unknown_parameter_is_ignored() {
|
|
let mut g = EditGraph::default_chain();
|
|
g.set_param(exposure::ID, ParamId("nonexistent"), 3.0);
|
|
assert!(g.is_neutral());
|
|
}
|
|
|
|
#[test]
|
|
fn reset_returns_every_operation_to_neutral() {
|
|
let mut g = EditGraph::default_chain();
|
|
g.set_param(exposure::ID, exposure::EXPOSURE, 2.0);
|
|
g.set_param(white_balance::ID, white_balance::TEMPERATURE, 50.0);
|
|
assert!(!g.is_neutral());
|
|
|
|
g.reset();
|
|
assert!(g.is_neutral(), "reset must clear every operation");
|
|
}
|
|
|
|
#[test]
|
|
fn only_active_operations_reach_the_shader() {
|
|
// The composition property, end to end: two adjustments out of seven
|
|
// available must generate a shader doing exactly two things.
|
|
let mut g = EditGraph::default_chain();
|
|
g.set_param(exposure::ID, exposure::EXPOSURE, 1.0);
|
|
g.set_param(white_balance::ID, white_balance::TINT, 25.0);
|
|
|
|
let shader = g.compose();
|
|
assert_eq!(shader.source.matches("---- ").count(), 2);
|
|
assert!(shader.source.contains("---- exposure ----"));
|
|
assert!(shader.source.contains("---- white_balance ----"));
|
|
assert!(!shader.source.contains("---- saturation ----"));
|
|
}
|
|
|
|
#[test]
|
|
fn moving_a_slider_does_not_change_the_shader_structure() {
|
|
// What makes the pipeline cache worth having: dragging a slider must
|
|
// reuse the compiled pipeline and upload uniforms only.
|
|
let mut g = EditGraph::default_chain();
|
|
g.set_param(exposure::ID, exposure::EXPOSURE, 1.0);
|
|
let first = g.compose();
|
|
|
|
g.set_param(exposure::ID, exposure::EXPOSURE, 2.0);
|
|
let second = g.compose();
|
|
|
|
assert_eq!(first.structure_hash, second.structure_hash);
|
|
assert_eq!(first.source, second.source);
|
|
assert_ne!(first.uniforms, second.uniforms);
|
|
}
|
|
|
|
#[test]
|
|
fn capabilities_describe_every_operation_and_parameter() {
|
|
// The UI builds its whole panel from this. Anything missing here is
|
|
// something the UI would have to hardcode.
|
|
let g = EditGraph::default_chain();
|
|
let caps = g.capabilities();
|
|
// Every operation, plus framing — which is not an operation and so
|
|
// is absent from `descriptors`, but must still reach the panel.
|
|
assert_eq!(caps.len(), g.descriptors().len() + 1);
|
|
assert!(
|
|
caps.iter().any(|c| c.id == crate::framing::ID),
|
|
"framing must appear in the capability list, or the UI cannot \
|
|
build a crop control without naming it"
|
|
);
|
|
|
|
for cap in &caps {
|
|
assert!(!cap.params.is_empty(), "{} exposes no parameters", cap.id);
|
|
for p in &cap.params {
|
|
// A control cannot be built without a range.
|
|
match &p.kind {
|
|
ParamKind::Scalar { min, max, .. } => {
|
|
assert!(min < max, "{}.{} has an empty range", cap.id, p.id);
|
|
assert!(
|
|
(*min..=*max).contains(&p.default),
|
|
"{}.{} default is outside its range",
|
|
cap.id,
|
|
p.id
|
|
);
|
|
}
|
|
ParamKind::Bool => {}
|
|
ParamKind::Enum { variants } => {
|
|
// An empty list is a control with nothing to pick, and
|
|
// a one-entry list is a control that cannot be
|
|
// changed — both are declaration mistakes rather than
|
|
// states a UI should try to render.
|
|
assert!(
|
|
variants.len() > 1,
|
|
"{}.{} offers fewer than two choices",
|
|
cap.id,
|
|
p.id
|
|
);
|
|
assert!(
|
|
p.default >= 0.0 && p.default < variants.len() as f32,
|
|
"{}.{} defaults to a variant that does not exist",
|
|
cap.id,
|
|
p.id
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn capabilities_report_current_values_not_just_defaults() {
|
|
// So reopening an edited image shows the sliders where the edit left
|
|
// them, with no separate initialisation path in the UI.
|
|
let mut g = EditGraph::default_chain();
|
|
g.set_param(exposure::ID, exposure::EXPOSURE, 1.25);
|
|
|
|
let cap = g
|
|
.capabilities()
|
|
.into_iter()
|
|
.find(|c| c.id == exposure::ID)
|
|
.expect("exposure is in the chain");
|
|
let p = &cap.params[0];
|
|
assert_eq!(p.value, 1.25);
|
|
assert_eq!(p.default, 0.0);
|
|
assert!(p.is_modified());
|
|
assert!(cap.active);
|
|
}
|
|
|
|
#[test]
|
|
fn a_fresh_graph_reports_nothing_modified() {
|
|
for cap in EditGraph::default_chain().capabilities() {
|
|
assert!(!cap.active, "{} should start inactive", cap.id);
|
|
for p in &cap.params {
|
|
assert!(
|
|
!p.is_modified(),
|
|
"{}.{} should start at default",
|
|
cap.id,
|
|
p.id
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn capabilities_survive_a_round_trip_through_set_param() {
|
|
// The UI reads a capability, writes the value back, and must get the
|
|
// same thing out — no hidden scaling between the two.
|
|
//
|
|
// Written at the parameter's declared precision, because that is what
|
|
// the UI can actually produce: a control declaring 0 decimals emits
|
|
// whole numbers, and a stage free to quantise to them is behaving
|
|
// correctly rather than losing the value.
|
|
let mut g = EditGraph::default_chain();
|
|
for cap in g.capabilities() {
|
|
for p in &cap.params {
|
|
if let ParamKind::Scalar { max, precision, .. } = p.kind {
|
|
let step = 10f32.powi(i32::from(precision));
|
|
let target = (max * 0.5 * step).round() / step;
|
|
g.set_param(cap.id, p.id, target);
|
|
assert_eq!(
|
|
g.param(cap.id, p.id),
|
|
Some(target),
|
|
"{}.{} did not round-trip",
|
|
cap.id,
|
|
p.id
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn adding_an_operation_needs_no_ui_change() {
|
|
// FR-DEV-3c, asserted structurally: everything a control needs is
|
|
// reachable from the capability list, so a new operation appears
|
|
// without the UI naming it. If this test needs editing to add an
|
|
// operation, the abstraction has leaked.
|
|
let g = EditGraph::default_chain();
|
|
let rendered: Vec<String> = g
|
|
.capabilities()
|
|
.iter()
|
|
.flat_map(|c| {
|
|
c.params.iter().map(move |p| match &p.kind {
|
|
ParamKind::Scalar {
|
|
min,
|
|
max,
|
|
precision,
|
|
..
|
|
} => format!(
|
|
"{}/{}: slider {min}..{max} @{precision} = {}",
|
|
c.label.0, p.label.0, p.value
|
|
),
|
|
ParamKind::Bool => format!("{}/{}: switch", c.label.0, p.label.0),
|
|
ParamKind::Enum { variants } => format!(
|
|
"{}/{}: choice of {} = {}",
|
|
c.label.0,
|
|
p.label.0,
|
|
variants.len(),
|
|
p.value
|
|
),
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
// Counted from the chain, not a literal: this test must not need
|
|
// editing when an operation is added, or it would be asserting the
|
|
// opposite of what it claims.
|
|
let expected: usize = g.capabilities().iter().map(|c| c.params.len()).sum();
|
|
assert_eq!(rendered.len(), expected);
|
|
assert!(rendered.iter().all(|r| !r.is_empty()));
|
|
assert!(
|
|
expected > 40,
|
|
"the chain should now carry the mixer's 36 parameters too"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn enabling_another_operation_does_change_the_structure() {
|
|
let mut g = EditGraph::default_chain();
|
|
g.set_param(exposure::ID, exposure::EXPOSURE, 1.0);
|
|
let before = g.compose().structure_hash;
|
|
|
|
g.set_param(
|
|
crate::ops::saturation::ID,
|
|
crate::ops::saturation::SATURATION,
|
|
30.0,
|
|
);
|
|
assert_ne!(before, g.compose().structure_hash);
|
|
}
|
|
|
|
// ---- invalidation scoping (FR-DEV-3d) --------------------------------
|
|
|
|
use crate::descriptor::OpId;
|
|
use crate::operation::Affects;
|
|
|
|
const PROBE: OpId = OpId("detail_probe");
|
|
const PROBE_RADIUS: ParamId = ParamId("radius");
|
|
|
|
#[test]
|
|
fn moving_a_detail_parameter_leaves_every_earlier_stage_alone() {
|
|
// FR-DEV-3d's headline, and the thing `Affects::Detail` was added to
|
|
// make true: dragging a sharpening slider must not re-run the
|
|
// demosaic, the framing, or the fused colour pass. The demosaic is not
|
|
// a key here at all — no parameter in this graph can reach it — and
|
|
// the other two must come out unchanged.
|
|
let mut g = EditGraph::with_detail_probe();
|
|
let before = g.invalidation();
|
|
|
|
g.set_param(PROBE, PROBE_RADIUS, 0.05);
|
|
let after = g.invalidation();
|
|
|
|
assert_ne!(
|
|
before.of(Affects::Detail),
|
|
after.of(Affects::Detail),
|
|
"the detail stage's own key must move"
|
|
);
|
|
assert_eq!(
|
|
before.through(Affects::Colour),
|
|
after.through(Affects::Colour),
|
|
"the fused colour pass's result is still valid, so its cached \
|
|
linear intermediate must be reusable"
|
|
);
|
|
assert_eq!(
|
|
before.through(Affects::Geometry),
|
|
after.through(Affects::Geometry)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn moving_a_colour_parameter_leaves_geometry_alone_and_redoes_detail() {
|
|
// The other direction, and the half that is easy to get wrong by
|
|
// wishing. Exposure does not touch the framing — FR-DEV-3d says so in
|
|
// as many words. It *does* invalidate the detail stage's output,
|
|
// because the detail stage reads what the colour pass wrote, and
|
|
// pretending otherwise would show a sharpened version of the previous
|
|
// exposure. The stage's own parameters are still untouched, which is
|
|
// what `of` reports and `through` does not.
|
|
let mut g = EditGraph::with_detail_probe();
|
|
g.set_param(PROBE, PROBE_RADIUS, 0.05);
|
|
let before = g.invalidation();
|
|
|
|
g.set_param(exposure::ID, exposure::EXPOSURE, 1.0);
|
|
let after = g.invalidation();
|
|
|
|
assert_eq!(
|
|
before.through(Affects::Geometry),
|
|
after.through(Affects::Geometry),
|
|
"adjusting exposure shall not re-tile geometry (FR-DEV-3d)"
|
|
);
|
|
assert_ne!(before.of(Affects::Colour), after.of(Affects::Colour));
|
|
assert_eq!(
|
|
before.of(Affects::Detail),
|
|
after.of(Affects::Detail),
|
|
"the sharpening settings did not change"
|
|
);
|
|
assert_ne!(
|
|
before.through(Affects::Detail),
|
|
after.through(Affects::Detail),
|
|
"but its input did, so its cached output is stale"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn cropping_invalidates_everything_downstream_of_it() {
|
|
// Geometry is upstream of both other stages: it decides which source
|
|
// pixel every colour is read from, and — because the detail stage runs
|
|
// at render resolution — how many render pixels a kernel spans.
|
|
let mut g = EditGraph::with_detail_probe();
|
|
g.set_param(PROBE, PROBE_RADIUS, 0.05);
|
|
let before = g.invalidation();
|
|
|
|
g.set_crop(CropRect {
|
|
x: 0.1,
|
|
y: 0.1,
|
|
width: 0.5,
|
|
height: 0.5,
|
|
});
|
|
let after = g.invalidation();
|
|
|
|
assert_ne!(before.of(Affects::Geometry), after.of(Affects::Geometry));
|
|
assert_ne!(
|
|
before.through(Affects::Colour),
|
|
after.through(Affects::Colour)
|
|
);
|
|
assert_ne!(
|
|
before.through(Affects::Detail),
|
|
after.through(Affects::Detail)
|
|
);
|
|
// Scoped, though: neither later stage's *own* settings moved.
|
|
assert_eq!(before.of(Affects::Colour), after.of(Affects::Colour));
|
|
assert_eq!(before.of(Affects::Detail), after.of(Affects::Detail));
|
|
}
|
|
|
|
#[test]
|
|
fn scrolling_the_view_invalidates_the_render_without_being_an_edit() {
|
|
// The view rect is not an edit — it is excluded from the sidecar, the
|
|
// structure hash and `is_active` — but it absolutely is an input to
|
|
// every pixel. A key that ignored it would leave the previous part of
|
|
// the photograph on screen after a pan, which looks like a repaint bug
|
|
// and is a cache bug.
|
|
let mut g = EditGraph::default_chain();
|
|
let before = g.invalidation();
|
|
g.framing_mut().set_view(CropRect {
|
|
x: 0.25,
|
|
y: 0.25,
|
|
width: 0.5,
|
|
height: 0.5,
|
|
});
|
|
assert_ne!(
|
|
before.of(Affects::Geometry),
|
|
g.invalidation().of(Affects::Geometry)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn returning_a_slider_to_where_it_was_returns_the_key() {
|
|
// A cache key that drifted with the *path* rather than the state would
|
|
// never hit after an undo, which is the moment it is most wanted.
|
|
let mut g = EditGraph::with_detail_probe();
|
|
let origin = g.invalidation();
|
|
g.set_param(exposure::ID, exposure::EXPOSURE, 1.5);
|
|
g.set_param(PROBE, PROBE_RADIUS, 0.05);
|
|
assert_ne!(origin, g.invalidation());
|
|
|
|
g.set_param(exposure::ID, exposure::EXPOSURE, 0.0);
|
|
g.set_param(PROBE, PROBE_RADIUS, 0.0);
|
|
assert_eq!(origin, g.invalidation(), "the state is what is hashed");
|
|
}
|
|
|
|
#[test]
|
|
fn a_local_adjustment_belongs_to_the_colour_stage() {
|
|
// A mask layer's chain is fused into the same dispatch as the global
|
|
// one, so changing it is a colour change and nothing more. Its
|
|
// *shape* counts too: which pixels the dispatch treats differently is
|
|
// as much a part of the result as by how much.
|
|
use crate::mask::{MaskLayer, MaskSource};
|
|
let mut g = EditGraph::with_detail_probe();
|
|
let before = g.invalidation();
|
|
|
|
g.masks_mut().push(MaskLayer::new(
|
|
"l1",
|
|
MaskSource::Linear {
|
|
centre: (0.5, 0.5),
|
|
angle: 0.0,
|
|
width: 0.2,
|
|
},
|
|
));
|
|
let with_layer = g.invalidation();
|
|
assert_ne!(before.of(Affects::Colour), with_layer.of(Affects::Colour));
|
|
assert_eq!(
|
|
before.of(Affects::Geometry),
|
|
with_layer.of(Affects::Geometry)
|
|
);
|
|
assert_eq!(before.of(Affects::Detail), with_layer.of(Affects::Detail));
|
|
|
|
// Moving the gradient is a different mask, so a different result.
|
|
if let Some(layer) = g.masks_mut().get_mut("l1") {
|
|
layer.source = MaskSource::Linear {
|
|
centre: (0.2, 0.7),
|
|
angle: 0.4,
|
|
width: 0.2,
|
|
};
|
|
}
|
|
assert_ne!(
|
|
with_layer.of(Affects::Colour),
|
|
g.invalidation().of(Affects::Colour)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn the_render_scale_folds_in_the_crop_and_the_zoom() {
|
|
// What a detail operation is handed, and the reason it does not need
|
|
// to know that a crop or a zoom happened: both arrive already folded
|
|
// into one ratio.
|
|
let mut g = EditGraph::default_chain();
|
|
let source = (6000, 4000);
|
|
|
|
// Fit: a 1500px panel over a 6000px frame is a quarter scale.
|
|
let fit = g.render_scale(source, (1500, 1000));
|
|
assert!((fit.ratio() - 0.25).abs() < 1e-3);
|
|
|
|
// Zoomed to 1:1 — the view rect shrinks to what the panel can hold,
|
|
// the render target keeps its size, and the preview becomes exact.
|
|
g.framing_mut().set_view(CropRect {
|
|
x: 0.25,
|
|
y: 0.25,
|
|
width: 0.25,
|
|
height: 0.25,
|
|
});
|
|
let one_to_one = g.render_scale(source, (1500, 1000));
|
|
assert!((one_to_one.ratio() - 1.0).abs() < 1e-3);
|
|
assert!(one_to_one.resolves(1.0));
|
|
|
|
// A crop shows fewer source pixels in the same panel, which is more
|
|
// render pixels each — a sharpening radius genuinely does grow.
|
|
let mut cropped = EditGraph::default_chain();
|
|
cropped.set_crop(CropRect {
|
|
x: 0.25,
|
|
y: 0.25,
|
|
width: 0.5,
|
|
height: 0.5,
|
|
});
|
|
let after = cropped.render_scale(source, (1500, 1000));
|
|
assert!(after.ratio() > fit.ratio());
|
|
}
|
|
}
|