Decides D21 by measurement. The photo gallery holds Lightroom 6 exports of raws in the library, each carrying its Camera Raw settings; clustered by those settings, 663 had no look applied. On 60 of them with their raws, a third held out, the held-out MSE against Lightroom's JPEG was about 1200 for 0.20.0's sigmoid (0.7 EV darker and flatter), 224 for the DNG reference curve after baseline exposure, and about 140 once its input is bent by 1.5/1.4 about grey. So the curve choice defaults to the DNG reference, keeping its index (sidecars record it), and the default contrast is 1.5. Contrast under the DNG reference curve is now a power relative to REFERENCE_CONTRAST (1.4), where the table is untouched; the sigmoid at that contrast still matches the retired base curve. JPEGs are unaffected: the view transform skips a rendered source.
845 lines
35 KiB
Rust
845 lines
35 KiB
Rust
//! Parameter descriptors — operations described as data (ARCH §3.3).
|
||
//!
|
||
//! The core never builds a control. It publishes what its parameters *are*,
|
||
//! and `dr-ui` maps each `ParamKind` to a widget appropriate to the current
|
||
//! input modality (ARCH §4.3). Adding an operation therefore needs no UI
|
||
//! change (FR-DEV-3c).
|
||
//!
|
||
//! Labels are keys, not strings: resolving them needs a localiser, and
|
||
//! `core/` must not depend on one (NFR-A11Y-1).
|
||
|
||
use std::collections::HashSet;
|
||
use std::fmt;
|
||
use std::sync::{LazyLock, Mutex};
|
||
|
||
/// TRACES: FR-PLG-2
|
||
/// Give a string read at run time the `'static` lifetime the identifier types
|
||
/// carry.
|
||
///
|
||
/// # Why the identifiers stayed `&'static str` when the descriptors did not
|
||
///
|
||
/// [`OpDescriptor`] became owned so a declaration read at *load* time can
|
||
/// produce one (FR-PLG-2). The three identifier newtypes below deliberately
|
||
/// did not follow it.
|
||
///
|
||
/// An id is not content; it is a key. [`ParamId`] is `Copy`, is compared in
|
||
/// `match` arms against the constants `build.rs` generates, is a map key in
|
||
/// the sidecar and in history, and is threaded through `dr-ui` into Slint
|
||
/// model rows. An `Arc<str>` there would put a refcount on every one of those
|
||
/// and would take `match id { EXPOSURE => .. }` away from the generated code —
|
||
/// which is precisely the inspectability of the built-in chain that keeping
|
||
/// the generated path was for.
|
||
///
|
||
/// So ids are interned instead, and interning is honest about its lifetime
|
||
/// rather than pretending to one. The set of interned ids is:
|
||
///
|
||
/// - **Bounded.** One entry per *distinct* string, deduplicated on the way in.
|
||
/// Parsing the same declaration a thousand times adds nothing after the
|
||
/// first.
|
||
/// - **Process-lifetime by construction.** A loaded declaration's vocabulary
|
||
/// is never withdrawn. Nothing unloads a plugin, and nothing could: the
|
||
/// sidecar on disk stores parameters by `(op_id, param_id)`, so an id has to
|
||
/// stay resolvable for as long as any edit naming it can be opened.
|
||
///
|
||
/// A leak whose bound is "the distinct ids this process has ever seen" is a
|
||
/// different thing from one that grows with use, and this is the first.
|
||
pub fn intern(s: &str) -> &'static str {
|
||
static POOL: LazyLock<Mutex<HashSet<&'static str>>> =
|
||
LazyLock::new(|| Mutex::new(HashSet::new()));
|
||
|
||
// A poisoned pool is still a correct pool: every entry in it is a
|
||
// `&'static str` that was interned successfully, and a panic elsewhere
|
||
// while the lock was held cannot have made one invalid. Refusing to
|
||
// intern here would turn an unrelated panic into an application that can
|
||
// no longer read a declaration.
|
||
let mut pool = POOL.lock().unwrap_or_else(|e| e.into_inner());
|
||
if let Some(found) = pool.get(s) {
|
||
return found;
|
||
}
|
||
let leaked: &'static str = Box::leak(s.to_owned().into_boxed_str());
|
||
pool.insert(leaked);
|
||
leaked
|
||
}
|
||
|
||
/// Identifies a parameter within an operation.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||
pub struct ParamId(pub &'static str);
|
||
|
||
impl ParamId {
|
||
/// The same id, from a name read out of a declaration at load time.
|
||
///
|
||
/// Equal to `ParamId("exposure")` when the name is `"exposure"`: the
|
||
/// derived `PartialEq` compares the `str` contents, not the pointer, which
|
||
/// is what lets an interned id match a generated `match` arm. See
|
||
/// [`intern`].
|
||
pub fn interned(name: &str) -> Self {
|
||
Self(intern(name))
|
||
}
|
||
}
|
||
|
||
impl fmt::Display for ParamId {
|
||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||
f.write_str(self.0)
|
||
}
|
||
}
|
||
|
||
/// Identifies an operation kind.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||
pub struct OpId(pub &'static str);
|
||
|
||
impl OpId {
|
||
/// The same id, from a declaration read at load time. See [`intern`].
|
||
pub fn interned(name: &str) -> Self {
|
||
Self(intern(name))
|
||
}
|
||
}
|
||
|
||
impl fmt::Display for OpId {
|
||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||
f.write_str(self.0)
|
||
}
|
||
}
|
||
|
||
/// A localisation key. The UI resolves it; the core never sees the string.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub struct LocalizedKey(pub &'static str);
|
||
|
||
impl LocalizedKey {
|
||
/// The same key, from a declaration read at load time. See [`intern`].
|
||
pub fn interned(key: &str) -> Self {
|
||
Self(intern(key))
|
||
}
|
||
}
|
||
|
||
/// What a slider's travel means.
|
||
///
|
||
/// Photographic controls are rarely linear in their underlying quantity:
|
||
/// exposure is linear in stops but exponential in light, and a temperature
|
||
/// slider that is linear in kelvin feels wrong at both ends.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum Scale {
|
||
Linear,
|
||
/// Even *perceptual* steps across the range, for controls whose effect
|
||
/// concentrates near one end.
|
||
Perceptual,
|
||
}
|
||
|
||
/// The unit a value carries, for display.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum Unit {
|
||
None,
|
||
/// Exposure value — photographers think in stops, not multipliers.
|
||
Stops,
|
||
Kelvin,
|
||
Percent,
|
||
}
|
||
|
||
/// A control that does not reduce to a slider or a switch.
|
||
///
|
||
/// The core names the *kind* of widget; the frontend owns what it looks like
|
||
/// and how it behaves (ARCH §3.3). This is deliberately a small closed enum
|
||
/// rather than an open string: a UI must be able to match exhaustively and
|
||
/// know it has covered everything the core can ask for.
|
||
///
|
||
/// **Every one of these is a hint over ordinary scalar parameters**, never a
|
||
/// new kind of value. A curve is its point coordinates, a colour wheel is
|
||
/// three numbers, a crop is four edges — all [`ParamKind::Scalar`], all
|
||
/// individually addressable, all persisted by the sidecar with no special
|
||
/// case. That is what makes the fallback in [`Presentation::widgets`] honest:
|
||
/// a frontend that implements none of these still renders every parameter as
|
||
/// a slider and the edit works, merely more tediously.
|
||
///
|
||
/// It is also why there is no `Colour` *kind*. A colour is three or four
|
||
/// numbers, and introducing a value type that is not an `f32` would reach
|
||
/// through the graph, the uniform block and the sidecar format to buy a
|
||
/// control that [`ColourWheel`](WidgetKind::ColourWheel) already describes.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum WidgetKind {
|
||
/// A tone curve, edited by dragging points on a grid.
|
||
///
|
||
/// The underlying parameters are the point coordinates, x and y
|
||
/// interleaved.
|
||
ToneCurve,
|
||
/// Colour grading wheels: a hue-and-strength pad per tonal range.
|
||
ColourWheel,
|
||
/// On-canvas crop and straighten handles. Parameters are the four crop
|
||
/// edges as fractions, and the straighten angle.
|
||
CropOverlay,
|
||
/// On-canvas placement of a linear or radial mask.
|
||
GradientHandle,
|
||
/// On-canvas brush strokes.
|
||
BrushMask,
|
||
/// An eyedropper bound to the canvas, setting white balance from a pixel.
|
||
///
|
||
/// **The one widget that reads the photograph rather than driving it**,
|
||
/// and that is what gives it a contract the others do not need. A curve
|
||
/// tells its frontend where its points are and the frontend moves them; an
|
||
/// eyedropper is handed a colour and has to work out what the parameters
|
||
/// should become, which is only possible if the operation says enough
|
||
/// about itself to be inverted:
|
||
///
|
||
/// * The first two parameters in [`Presentation::params`] are its axes —
|
||
/// the first trading red against blue, the second green against
|
||
/// magenta — and each is monotonic in its axis.
|
||
/// * The operation publishes exactly three uniforms: the linear
|
||
/// per-channel gains, in red, green, blue order.
|
||
///
|
||
/// The same kind of contract [`Self::ToneCurve`] carries when it says its
|
||
/// parameters are point coordinates interleaved, and it is checked rather
|
||
/// than trusted — see [`crate::neutral`], which does the inverting so that
|
||
/// no frontend has to hold a second copy of the declared response.
|
||
WhitePoint,
|
||
}
|
||
|
||
impl WidgetKind {
|
||
/// TRACES: FR-UI-7
|
||
/// Whether this widget is manipulated on the photograph rather than in a
|
||
/// panel.
|
||
///
|
||
/// A property of the widget itself, not of the screen: a crop is dragged
|
||
/// on the image wherever the image is, and that is true of a phone and a
|
||
/// workstation alike. Where the panel then *puts* the affordance that
|
||
/// turns it on, and how large its handles are, stay frontend decisions
|
||
/// (ARCH §4.3a).
|
||
pub fn is_on_canvas(self) -> bool {
|
||
matches!(
|
||
self,
|
||
Self::CropOverlay | Self::GradientHandle | Self::BrushMask | Self::WhitePoint
|
||
)
|
||
}
|
||
}
|
||
|
||
/// TRACES: FR-DEV-3a
|
||
/// What a widget inherently needs in order to be usable.
|
||
///
|
||
/// **Demands describe the control, not the screen** (ARCH §4.3a). A curve
|
||
/// needs two-dimensional pointing and a certain amount of room to be worth
|
||
/// drawing at all; those are facts about curves. Whether *this* window has
|
||
/// that room, at what breakpoint, on what platform, is the frontend's
|
||
/// question, and a demand carrying pixels or a platform name would be the core
|
||
/// answering it — a core that reasons about pixels will eventually be wrong
|
||
/// about a display it never saw.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||
pub struct WidgetDemand {
|
||
/// Needs the value to be dragged in two dimensions at once. A frontend
|
||
/// with only a keyboard, or a strictly linear input, should skip it.
|
||
pub two_dimensional: bool,
|
||
/// Needs pointing accurate to a small fraction of the control. A frontend
|
||
/// driving a television with a remote should skip it; touch is fine, since
|
||
/// hit regions grow to the modality (FR-UI-7).
|
||
pub precise_pointing: bool,
|
||
}
|
||
|
||
/// The shape of a parameter's value.
|
||
///
|
||
/// **Every variant is carried as an `f32`.** That is not an implementation
|
||
/// detail to be tidied away later — it is what lets one storage path serve
|
||
/// every parameter: the sidecar writes a number, the uniform block takes a
|
||
/// number, and `set_param` is one function rather than one per shape. A
|
||
/// variant that needed a richer value would reach through all three, which is
|
||
/// why a colour is a [`WidgetKind::ColourWheel`] over three scalars rather
|
||
/// than a kind of its own.
|
||
#[derive(Debug, Clone, PartialEq)]
|
||
pub enum ParamKind {
|
||
Scalar {
|
||
min: f32,
|
||
max: f32,
|
||
scale: Scale,
|
||
unit: Unit,
|
||
precision: u8,
|
||
},
|
||
Bool,
|
||
/// TRACES: FR-DEV-3a
|
||
/// One of a short, fixed list of named alternatives.
|
||
///
|
||
/// The value is the chosen variant's **index**, held as an `f32` like
|
||
/// everything else — small integers are exact in binary32, so this costs
|
||
/// nothing in fidelity and keeps the parameter on the ordinary storage
|
||
/// path.
|
||
///
|
||
/// Distinct from a `Scalar` running 0..n because the numbers are not on a
|
||
/// scale: interpolating between two of them is meaningless, dragging
|
||
/// through them is not a gesture anyone wants, and the labels are the
|
||
/// whole point. A UI that treated this as a scalar would render a slider
|
||
/// reading "2" where the user needs to see "Bicubic".
|
||
Enum {
|
||
/// In index order. The label is a localisation key, resolved by the
|
||
/// frontend — `core/` must not depend on a localiser (NFR-A11Y-1).
|
||
///
|
||
/// Owned rather than `&'static`, for the reason [`OpDescriptor`]
|
||
/// gives: a declaration parsed at load time has nowhere to put a
|
||
/// `'static` slice.
|
||
variants: Vec<LocalizedKey>,
|
||
},
|
||
}
|
||
|
||
/// TRACES: FR-DEV-3a | FR-DEV-3b
|
||
/// How an operation would like its parameters presented.
|
||
///
|
||
/// A *hint*, never a requirement. An operation's parameters are always
|
||
/// individually addressable scalars; this only says that several of them
|
||
/// form one conceptual control, and which widget draws it best. A UI is free
|
||
/// to ignore it entirely and render plain sliders — the edit still works, it
|
||
/// is merely more tedious.
|
||
///
|
||
/// Sitting on the operation rather than on a parameter is what allows a
|
||
/// widget to span several parameters, which a curve necessarily does.
|
||
///
|
||
/// Declared through [`crate::Operation::presentation`] — a defaulted trait
|
||
/// method rather than a field on [`OpDescriptor`], so the great majority of
|
||
/// operations, which want plain sliders, say nothing at all.
|
||
#[derive(Debug, Clone, PartialEq)]
|
||
pub struct Presentation {
|
||
/// Widgets that would draw these parameters, **in descending order of
|
||
/// preference** (ARCH §4.3a).
|
||
///
|
||
/// The frontend walks the list and takes the first it both implements and
|
||
/// can afford. Falling off the end is not an error: every parameter
|
||
/// remains an individually addressable scalar, so plain sliders are always
|
||
/// the final fallback and the edit still works.
|
||
///
|
||
/// A list rather than one kind because the alternatives are real. A colour
|
||
/// grading operation is best as a wheel, acceptable as a hue-and-strength
|
||
/// pair of sliders, and an operation that can say so gets a good control
|
||
/// on a workstation and a usable one on a phone without the core knowing
|
||
/// which it is talking to.
|
||
pub widgets: Vec<WidgetKind>,
|
||
/// What the preferred widget needs in order to be worth drawing.
|
||
///
|
||
/// Applies to the list as a whole rather than per entry: a frontend that
|
||
/// cannot meet the demand skips to plain sliders, which is the same answer
|
||
/// it gives for a widget it has not implemented.
|
||
pub demand: WidgetDemand,
|
||
/// The parameters these widgets own, in the order they expect them.
|
||
///
|
||
/// Parameters absent from this list are presented normally, so an
|
||
/// operation can pair a curve with an ordinary strength slider.
|
||
pub params: Vec<ParamId>,
|
||
}
|
||
|
||
impl Presentation {
|
||
/// The first widget in the preference list that `supported` accepts.
|
||
///
|
||
/// The walk lives here rather than in each frontend so that "first
|
||
/// supported, else sliders" is written once and cannot drift between a
|
||
/// desktop UI, a test harness and whatever consumes capabilities next.
|
||
pub fn choose(&self, supported: impl Fn(WidgetKind) -> bool) -> Option<WidgetKind> {
|
||
self.widgets.iter().copied().find(|w| supported(*w))
|
||
}
|
||
}
|
||
|
||
/// TRACES: FR-DEV-3a
|
||
/// A parameter's place in an operation whose parameters form a grid.
|
||
///
|
||
/// Most operations are a short list of unrelated controls. A few are the
|
||
/// *same* control applied to a series of subjects: the colour mixer is twelve
|
||
/// hue bands times hue, saturation and luminance, and rendered as a flat list
|
||
/// of thirty-six it says none of that — the panel showed "Hue / Sat / Lum"
|
||
/// twelve times over with nothing naming the band.
|
||
///
|
||
/// So a parameter may say which **aspect** it adjusts and which **subject** it
|
||
/// adjusts it on. A UI is free to ignore both and render a flat list; nothing
|
||
/// becomes unreachable, it merely reads as thirty-six anonymous sliders again.
|
||
///
|
||
/// **Why this is not the core deciding presentation** (ARCH §4.3a). Which
|
||
/// band a parameter belongs to, and that its centre is at 30°, are facts about
|
||
/// what the operation *does* — the mixer genuinely weights pixels around 30°,
|
||
/// and that number is the one it weights around. What colour to draw from it,
|
||
/// at what saturation, whether to draw anything at all, and in what order to
|
||
/// stack the runs are all presentation, and stay in `dr-ui`. The line: a hue
|
||
/// in degrees is data; a hex colour in a descriptor would be the core choosing
|
||
/// appearance, and is forbidden.
|
||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||
pub struct Facet {
|
||
/// What this parameter adjusts. Parameters sharing an aspect are one
|
||
/// control applied to different subjects.
|
||
pub aspect: LocalizedKey,
|
||
/// What it adjusts it on.
|
||
pub subject: LocalizedKey,
|
||
/// Where the subject sits on the hue wheel, in degrees, where the subject
|
||
/// is a colour. `None` for one that is not.
|
||
pub subject_hue: Option<f32>,
|
||
}
|
||
|
||
/// One parameter of an operation.
|
||
#[derive(Debug, Clone, PartialEq)]
|
||
pub struct ParamDescriptor {
|
||
pub id: ParamId,
|
||
pub label: LocalizedKey,
|
||
pub kind: ParamKind,
|
||
pub default: f32,
|
||
/// Where this parameter sits among its siblings, for an operation whose
|
||
/// parameters form a grid. `None` — the usual case — is a parameter that
|
||
/// stands on its own.
|
||
pub facet: Option<Facet>,
|
||
}
|
||
|
||
impl ParamDescriptor {
|
||
/// A scalar in stops — the exposure-like controls.
|
||
pub const fn stops(id: &'static str, label: &'static str, min: f32, max: f32) -> Self {
|
||
Self {
|
||
id: ParamId(id),
|
||
label: LocalizedKey(label),
|
||
kind: ParamKind::Scalar {
|
||
min,
|
||
max,
|
||
scale: Scale::Linear,
|
||
unit: Unit::Stops,
|
||
precision: 2,
|
||
},
|
||
default: 0.0,
|
||
facet: None,
|
||
}
|
||
}
|
||
|
||
/// A symmetric −100…+100 control, the familiar shape for tone and colour
|
||
/// adjustments. Neutral at zero, so a double-tap reset is meaningful.
|
||
pub const fn amount(id: &'static str, label: &'static str) -> Self {
|
||
Self {
|
||
id: ParamId(id),
|
||
label: LocalizedKey(label),
|
||
kind: ParamKind::Scalar {
|
||
min: -100.0,
|
||
max: 100.0,
|
||
scale: Scale::Linear,
|
||
unit: Unit::None,
|
||
precision: 0,
|
||
},
|
||
default: 0.0,
|
||
facet: None,
|
||
}
|
||
}
|
||
|
||
/// A toggle. Neutral when off, so the reset contract still holds.
|
||
pub const fn switch(id: &'static str, label: &'static str) -> Self {
|
||
Self {
|
||
id: ParamId(id),
|
||
label: LocalizedKey(label),
|
||
kind: ParamKind::Bool,
|
||
default: 0.0,
|
||
facet: None,
|
||
}
|
||
}
|
||
|
||
/// A toggle that is **on** when nothing has been chosen.
|
||
///
|
||
/// The reset contract is unchanged — a default is still the neutral value
|
||
/// and a sidecar still stores only departures from it. What differs is
|
||
/// which state is neutral, and that is a fact about the setting rather
|
||
/// than about switches: a correction the file itself asked for is on
|
||
/// unless the photographer says otherwise, so "off" is the edit and
|
||
/// storing it is right. A switch written the other way round would have to
|
||
/// be labelled for its negation — "Ignore the lens profile" — and every
|
||
/// photograph that simply wanted correcting would carry a stored
|
||
/// parameter saying so.
|
||
pub const fn switch_on(id: &'static str, label: &'static str) -> Self {
|
||
Self {
|
||
id: ParamId(id),
|
||
label: LocalizedKey(label),
|
||
kind: ParamKind::Bool,
|
||
default: 1.0,
|
||
facet: None,
|
||
}
|
||
}
|
||
|
||
/// One of a fixed list of alternatives, defaulting to the first.
|
||
///
|
||
/// The first rather than a caller-chosen index, so the reset contract
|
||
/// holds the way it does for every other kind: index 0 is the neutral
|
||
/// choice, and an operation whose default is not its first variant has
|
||
/// listed them in the wrong order.
|
||
//
|
||
// Not `const`, unlike its four siblings, and the reason is the `Vec` in
|
||
// [`ParamKind::Enum`]: a heap allocation cannot happen in a const context.
|
||
// Nothing is lost — every descriptor now lives inside a `LazyLock`
|
||
// initialiser rather than a `static`, because `descriptor()` hands out an
|
||
// `Arc` and an `Arc` is not const-constructible either.
|
||
pub fn choice(id: &'static str, label: &'static str, variants: Vec<LocalizedKey>) -> Self {
|
||
Self {
|
||
id: ParamId(id),
|
||
label: LocalizedKey(label),
|
||
kind: ParamKind::Enum { variants },
|
||
default: 0.0,
|
||
facet: None,
|
||
}
|
||
}
|
||
|
||
/// The same choice with another variant as its default.
|
||
///
|
||
/// For a choice whose variants were numbered before its default was
|
||
/// settled: a sidecar records the index, so reordering the variants to
|
||
/// put the default first would change what saved edits mean.
|
||
pub fn with_default(self, default: f32) -> Self {
|
||
Self { default, ..self }
|
||
}
|
||
|
||
/// A 0…1 fraction — a proportion of something, rather than an amount.
|
||
///
|
||
/// Its own constructor because the crop rect needs four of them and the
|
||
/// default differs per edge: an origin starts at 0 and an extent at 1.
|
||
/// Precision of 4 because at 6000px a step of 0.0001 is under a pixel,
|
||
/// and a coarser one would make a crop edge unplaceable.
|
||
pub const fn fraction(id: &'static str, label: &'static str, default: f32) -> Self {
|
||
Self {
|
||
id: ParamId(id),
|
||
label: LocalizedKey(label),
|
||
kind: ParamKind::Scalar {
|
||
min: 0.0,
|
||
max: 1.0,
|
||
scale: Scale::Linear,
|
||
unit: Unit::None,
|
||
precision: 4,
|
||
},
|
||
default,
|
||
facet: None,
|
||
}
|
||
}
|
||
|
||
/// A general scalar with an explicit range and default.
|
||
//
|
||
// Eight arguments, and a builder would be the usual answer — but eight
|
||
// `&mut self` steps would be eight methods to say what one call already
|
||
// says, and each one would be a place for a caller to forget a field. The
|
||
// two common shapes have their own constructors above; this is the escape
|
||
// hatch for the rest.
|
||
//
|
||
// Still `const` although no descriptor is a `static` any more: it costs
|
||
// nothing, and it keeps the five constructors uniform where only `choice`
|
||
// genuinely cannot be.
|
||
#[allow(clippy::too_many_arguments)]
|
||
pub const fn scalar(
|
||
id: &'static str,
|
||
label: &'static str,
|
||
min: f32,
|
||
max: f32,
|
||
default: f32,
|
||
unit: Unit,
|
||
scale: Scale,
|
||
precision: u8,
|
||
) -> Self {
|
||
Self {
|
||
id: ParamId(id),
|
||
label: LocalizedKey(label),
|
||
kind: ParamKind::Scalar {
|
||
min,
|
||
max,
|
||
scale,
|
||
unit,
|
||
precision,
|
||
},
|
||
default,
|
||
facet: None,
|
||
}
|
||
}
|
||
|
||
/// The same parameter, placed in its operation's grid.
|
||
///
|
||
/// A method rather than a sixth constructor, because a facet is orthogonal
|
||
/// to the shape of the value: a faceted parameter is still an amount, or
|
||
/// still a scalar in stops, and pairing every constructor with a faceted
|
||
/// twin would double the list above to say one thing.
|
||
//
|
||
// No longer `const`: a `ParamDescriptor` can now carry a `Vec` (an enum's
|
||
// variants), which gives the type drop glue, and assigning over a field of
|
||
// such a type is not something a const function may do. Nothing is lost —
|
||
// every descriptor is built inside a `LazyLock` initialiser now.
|
||
pub fn faceted(mut self, facet: Facet) -> Self {
|
||
self.facet = Some(facet);
|
||
self
|
||
}
|
||
|
||
/// Clamp a value into this parameter's declared range.
|
||
///
|
||
/// Applied before the value reaches a shader: a slider dragged past its
|
||
/// bounds, or a sidecar written by a newer version with a wider range,
|
||
/// must not produce out-of-range uniforms.
|
||
pub fn clamp(&self, value: f32) -> f32 {
|
||
match &self.kind {
|
||
ParamKind::Scalar { min, max, .. } => {
|
||
if value.is_finite() {
|
||
value.clamp(*min, *max)
|
||
} else {
|
||
// A NaN from a corrupt sidecar would otherwise poison the
|
||
// uniform block and blank the image.
|
||
self.default
|
||
}
|
||
}
|
||
ParamKind::Bool => {
|
||
if value != 0.0 {
|
||
1.0
|
||
} else {
|
||
0.0
|
||
}
|
||
}
|
||
// Rounded before clamping, because the value arriving here is an
|
||
// `f32` that has been through a sidecar and possibly a slider: an
|
||
// index of 1.9999 is variant 2, and truncating it to 1 would
|
||
// silently select the wrong option. A non-finite index falls back
|
||
// to the default for the same reason a scalar does.
|
||
ParamKind::Enum { variants } => {
|
||
if value.is_finite() {
|
||
let last = variants.len().saturating_sub(1) as f32;
|
||
value.round().clamp(0.0, last)
|
||
} else {
|
||
self.default
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// What an operation *is about*.
|
||
///
|
||
/// A statement of the operation's nature, in the same category as
|
||
/// [`ParamKind`]: the core saying what a thing is, not where it is drawn. A
|
||
/// frontend may render these as tabs, as section headings, as a filter, or
|
||
/// ignore them entirely — that choice is composition and belongs to whoever
|
||
/// knows the window (ARCH §4.3a).
|
||
///
|
||
/// # Why the core may say this at all
|
||
///
|
||
/// The line §4.3a draws is between *what a thing is* and *what is drawn,
|
||
/// where it sits, how wide it is, and whether it is visible*. "White balance
|
||
/// is a colour operation" is the first kind. It is also knowledge the core is
|
||
/// uniquely placed to hold: the person adding an operation knows what it does,
|
||
/// and a frontend that had to work it out would be doing so by matching on the
|
||
/// operation's name — which is the one thing `ui/` may never do (FR-DEV-3a).
|
||
///
|
||
/// What this deliberately is **not** is a tab name. There is no `Attribute`
|
||
/// for "the third tab", the order below is declaration order rather than
|
||
/// screen order, and an operation carrying two attributes appears wherever the
|
||
/// frontend decides that means — twice, once, or nowhere.
|
||
///
|
||
/// # Plural on purpose
|
||
///
|
||
/// An operation may carry several. The tone curve is genuinely both tonal and
|
||
/// chromatic — it has an RGB curve and per-channel curves — and forcing it to
|
||
/// pick one would file it away from half the people looking for it.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||
pub enum Attribute {
|
||
/// Lightness and its distribution: exposure, contrast, the recovery
|
||
/// controls, the tone curve.
|
||
Tone,
|
||
/// Hue and saturation: white balance, the colour mixer, vibrance.
|
||
Colour,
|
||
/// Acutance and noise — what the image is made of at the pixel level.
|
||
/// Sharpening, noise reduction, texture, clarity.
|
||
Detail,
|
||
/// Corrections for the lens that took the photograph: distortion,
|
||
/// chromatic aberration, vignetting.
|
||
Optics,
|
||
/// How the frame is composed: crop, straighten, rotation, flips.
|
||
///
|
||
/// Named for the decision rather than for the maths. The lens corrections
|
||
/// are geometry too — distortion moves pixels exactly as a straighten
|
||
/// does — and lumping the two together would file a correction the
|
||
/// photographer never asked for beside a choice that is the whole reason
|
||
/// they opened the photograph. [`Self::Optics`] is what the lens did;
|
||
/// this is what they decided.
|
||
Compose,
|
||
/// Applied rather than corrected — a look, not a fix.
|
||
Effect,
|
||
}
|
||
|
||
impl Attribute {
|
||
/// Every attribute, in roughly the order a photographer works through
|
||
/// them.
|
||
///
|
||
/// That makes it a reasonable *default* for a frontend that wants one. It
|
||
/// is not a screen order: nothing here obliges a frontend to show them
|
||
/// all, show them in this sequence, or show them at all.
|
||
///
|
||
/// `Optics` leads because correcting the lens is not a decision about the
|
||
/// photograph — it is undoing what the equipment did, a property of the
|
||
/// capture rather than a choice — and it moves the ground every later
|
||
/// judgement stands on. Removing a vignette *brightens the frame*, so an
|
||
/// exposure set before the correction has to be set again after it.
|
||
///
|
||
/// `Compose` follows as the first decision actually made about the
|
||
/// photograph, and the one every later judgement is made inside: there is
|
||
/// no sense in balancing tones across a frame that is about to lose a
|
||
/// third of its width.
|
||
///
|
||
/// `Detail` trails because sharpening and noise reduction depend on
|
||
/// everything above them, and are the only ones here that cannot be judged
|
||
/// at fit view at all. Offering them fourth, before the lens has even been
|
||
/// corrected, invites the photographer to settle grain against an image
|
||
/// that is still going to move.
|
||
///
|
||
/// `Effect` after `Colour` is a look laid over a settled picture — and is
|
||
/// the one arguable slot. A spectral film simulation declares
|
||
/// [`crate::Operation::renders`] and takes the view transform's place at
|
||
/// the very end of the chain (D19), which is an argument for treating it as
|
||
/// the rendering rather than one effect among others; an array of six
|
||
/// cannot say that. The tension is recorded here rather than settled.
|
||
///
|
||
/// Both ends were wrong for as long as this list only fed a row of chips
|
||
/// nobody reads in order. It stopped being harmless when the same list
|
||
/// began driving a column read top to bottom.
|
||
pub const ALL: [Attribute; 6] = [
|
||
Attribute::Optics,
|
||
Attribute::Compose,
|
||
Attribute::Tone,
|
||
Attribute::Colour,
|
||
Attribute::Effect,
|
||
Attribute::Detail,
|
||
];
|
||
|
||
/// The localisation key naming this concept.
|
||
///
|
||
/// Naming the concept, the way an operation's own `label` names the
|
||
/// operation. What a frontend *does* with the name — a tab, a heading,
|
||
/// nothing — is still its own affair.
|
||
pub fn label(self) -> LocalizedKey {
|
||
LocalizedKey(match self {
|
||
Self::Tone => "attr.tone",
|
||
Self::Colour => "attr.colour",
|
||
Self::Detail => "attr.detail",
|
||
Self::Optics => "attr.optics",
|
||
Self::Compose => "attr.compose",
|
||
Self::Effect => "attr.effect",
|
||
})
|
||
}
|
||
|
||
/// The name used in `ops/<id>.yaml`, and the inverse of [`Self::from_name`].
|
||
///
|
||
/// A stable identifier rather than a label: it is written into settings
|
||
/// and read back, so changing one of these strings would silently reset a
|
||
/// photographer's choice of what a paste carries. The *displayed* name is
|
||
/// [`Self::label`], which is localised and free to change.
|
||
pub fn name(self) -> &'static str {
|
||
match self {
|
||
Self::Tone => "tone",
|
||
Self::Colour => "colour",
|
||
Self::Detail => "detail",
|
||
Self::Optics => "optics",
|
||
Self::Compose => "compose",
|
||
Self::Effect => "effect",
|
||
}
|
||
}
|
||
|
||
/// Parse the name used in `ops/<id>.yaml`.
|
||
pub fn from_name(name: &str) -> Option<Self> {
|
||
Some(match name {
|
||
"tone" => Self::Tone,
|
||
"colour" => Self::Colour,
|
||
"detail" => Self::Detail,
|
||
"optics" => Self::Optics,
|
||
"compose" => Self::Compose,
|
||
// The name this attribute was persisted under before it was
|
||
// called Compose. Settings written by an older build carry it in
|
||
// `develop.copy_attributes`, and `from_name` returning `None`
|
||
// there does not fail loudly — `presets::scope_for` logs and drops
|
||
// the entry, silently narrowing what a paste carries. Accepted on
|
||
// the way in only; `name` writes the current spelling, so a
|
||
// settings file rewrites itself the first time it is saved.
|
||
"geometry" => Self::Compose,
|
||
"effect" => Self::Effect,
|
||
_ => return None,
|
||
})
|
||
}
|
||
}
|
||
|
||
/// TRACES: FR-PLG-2
|
||
/// The description of an operation.
|
||
///
|
||
/// # Owned, not `&'static`
|
||
///
|
||
/// This used to be a `static` with `&'static [ParamDescriptor]` inside it, and
|
||
/// [`crate::Operation::descriptor`] used to hand out a reference to it. That
|
||
/// shape made a build-time node free and a run-time node **impossible**: a
|
||
/// declaration parsed at startup has nothing to borrow from, so no amount of
|
||
/// interpreting `ops/*.yaml` at load time could ever produce a descriptor the
|
||
/// rest of the application would accept. FR-PLG-2 says a bundled operation and
|
||
/// a third-party plugin are the same kind of thing, differing only in where
|
||
/// the file was found — and a lifetime that only a compile-time literal can
|
||
/// satisfy is exactly a second, weaker format for outsiders.
|
||
///
|
||
/// So the descriptor owns its contents and is handed out as an
|
||
/// `Arc<OpDescriptor>`. The `Arc` rather than a `&self`-borrowed reference
|
||
/// because the callers want to *keep* it: the develop panel collects
|
||
/// descriptors and then mutates the graph, and a borrow would tie the
|
||
/// descriptor's lifetime to a borrow of the operation it came from — which is
|
||
/// the one thing `&'static` was doing right.
|
||
///
|
||
/// The cost is a refcount per read, on a path that reads descriptors when a
|
||
/// panel is built rather than per pixel. See `Operation::descriptor` for the
|
||
/// one place that is read per composition and why it does not matter.
|
||
#[derive(Debug, Clone, PartialEq)]
|
||
pub struct OpDescriptor {
|
||
pub id: OpId,
|
||
pub label: LocalizedKey,
|
||
pub params: Vec<ParamDescriptor>,
|
||
/// What this operation is about (ARCH §4.3a).
|
||
///
|
||
/// **Never empty**, and both the build-time and the load-time reader
|
||
/// refuse an operation that declares none. An operation with no attribute
|
||
/// would be invisible to a frontend that filters by them, and a control
|
||
/// that silently does not exist is a worse failure than a build that stops
|
||
/// — particularly when the cause would be a missing line in a YAML file
|
||
/// nobody looked at.
|
||
pub attributes: Vec<Attribute>,
|
||
}
|
||
|
||
impl OpDescriptor {
|
||
pub fn param(&self, id: ParamId) -> Option<&ParamDescriptor> {
|
||
self.params.iter().find(|p| p.id == id)
|
||
}
|
||
|
||
/// Whether this operation is about `attribute`.
|
||
pub fn has(&self, attribute: Attribute) -> bool {
|
||
self.attributes.contains(&attribute)
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
const P: ParamDescriptor = ParamDescriptor::amount("test", "test.label");
|
||
|
||
#[test]
|
||
fn values_clamp_into_range() {
|
||
assert_eq!(P.clamp(150.0), 100.0);
|
||
assert_eq!(P.clamp(-150.0), -100.0);
|
||
assert_eq!(P.clamp(42.0), 42.0);
|
||
}
|
||
|
||
#[test]
|
||
fn a_nan_falls_back_to_the_default_rather_than_poisoning_the_uniform() {
|
||
// A corrupt sidecar must not blank the image: one NaN in a uniform
|
||
// block propagates through every pixel.
|
||
assert_eq!(P.clamp(f32::NAN), P.default);
|
||
assert_eq!(P.clamp(f32::INFINITY), P.default);
|
||
}
|
||
|
||
#[test]
|
||
fn a_parameter_stands_alone_unless_it_says_otherwise() {
|
||
// The default has to be "no grid": every operation but the mixer is a
|
||
// short list of unrelated controls, and one that accidentally claimed
|
||
// a facet would have its panel section split under a heading it never
|
||
// asked for.
|
||
assert!(P.facet.is_none());
|
||
assert!(ParamDescriptor::switch("s", "s").facet.is_none());
|
||
|
||
let faceted = P.faceted(Facet {
|
||
aspect: LocalizedKey("param.channel.sat"),
|
||
subject: LocalizedKey("band.orange"),
|
||
subject_hue: Some(30.0),
|
||
});
|
||
// Placing a parameter in a grid must not change what the parameter
|
||
// *is* — the value it carries, its range and its default are the same
|
||
// either way.
|
||
assert_eq!(faceted.kind, P.kind);
|
||
assert_eq!(faceted.default, P.default);
|
||
assert_eq!(faceted.facet.unwrap().subject_hue, Some(30.0));
|
||
}
|
||
|
||
#[test]
|
||
fn amount_controls_are_neutral_at_zero() {
|
||
// Double-tap-to-reset and "is this op doing anything" both depend on
|
||
// neutral being zero.
|
||
assert_eq!(P.default, 0.0);
|
||
}
|
||
}
|