`Attribute::ALL` has claimed since it was written to be roughly the order a photographer works in, and474dcf0moved `Compose` to the front on exactly that argument. Both ends went on contradicting it. `Optics` sat fifth, so the column offered the lens corrections after the tones they change. Removing a vignette brightens the frame; an exposure judged before that correction has to be judged again after it, which is the definition of the wrong order. `Detail` sat fourth, so sharpening and noise reduction — the only work here that depends on everything above it, and the only work that cannot be judged at fit view at all — were offered before the lens had even been put right. So `Optics, Compose, Tone, Colour, Effect, Detail`. `Optics` leads even `Compose` because it is not a decision about the photograph at all: it is undoing what the equipment did, a property of the capture rather than a choice. `Effect` after `Colour` is a look laid over a settled picture, and is the one slot that is genuinely arguable — a spectral film simulation declares `renders` and replaces the base curve, which is a case for treating it as foundational instead, ande235e99filed film under `Effect` only days ago. The doc comment records that tension rather than pretending to settle it; an array of six cannot say "last, except when it is first". `decl::Attr::ALL` moves with it. It is the second spelling of one vocabulary, compiled by `build.rs` where `descriptor` is not visible, and the agreement test in `declared/mod.rs` zips the two positionally — that test is what caught the last reorder, and it would have caught this one. Nothing persists a position in this list, which is what makes the reorder safe rather than merely tidy. `Scope` packs one bit per attribute indexed by `Attribute::ALL`, but `bits` is private, has no accessor and no `serde`; what reaches a settings file is `develop.copy_attributes`, a list of names read back through `Attribute::from_name`. A photographer's copy scope survives untouched — only the order the names happen to be written in changes.6a97fdfis why this is worth a commit now rather than a shrug: the contradiction was harmless while the list only fed a row of chips nobody reads in order, and stopped being harmless when the same list began driving a column read top to bottom. The matrix follows the two files' shifted line numbers.
797 lines
33 KiB
Rust
797 lines
33 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.
|
||
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,
|
||
}
|
||
}
|
||
|
||
/// 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,
|
||
}
|
||
}
|
||
|
||
/// 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 replaces the base curve, which is an
|
||
/// argument for treating it as foundational rather than final; an array of
|
||
/// six cannot say "last, except when it is first". 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);
|
||
}
|
||
}
|