Merge branch 'worktree-agent-a1e5c8cb565255f5b' into master

# Conflicts:
#	docs/traceability.md
This commit is contained in:
2026-08-27 19:21:10 +02:00
29 changed files with 3530 additions and 1768 deletions
+6
View File
@@ -12,6 +12,12 @@ license.workspace = true
dr-types.workspace = true
log.workspace = true
# A dependency of the library, not only of the build script, since FR-PLG-2:
# `src/declared/` reads the same declaration format at *load* time, so that an
# operation found in a file at startup is the same kind of thing as one found
# at compile time. The reader itself is one file shared by both.
serde_norway.workspace = true
# Nodes are declared in `ops/*.yaml` and compiled to Rust by `build.rs`
# (ARCH §5.7). The same reasoning as `ui/dr-ui`'s style.yaml: the declaration
# is the source of truth, the Rust is generated into OUT_DIR where it cannot
+216 -1295
View File
File diff suppressed because it is too large Load Diff
+15 -1
View File
@@ -6,9 +6,23 @@ directory — there is no list to extend, no shader to edit, and no UI change.
`../build.rs` compiles each declaration into Rust implementing
[`Operation`](../src/operation.rs), generated into `OUT_DIR`. The result is
indistinguishable downstream from a hand-written operation: the same
`&'static OpDescriptor`, the same fused-shader composition, the same sidecar
`Arc<OpDescriptor>`, the same fused-shader composition, the same sidecar
round-trip.
**The same declaration also runs without being compiled** (FR-PLG-2).
[`DeclaredOp`](../src/declared/mod.rs) reads this format at *load* time and
implements `Operation` from it directly — one interpreter over many
declarations, where `build.rs` emits generated code per node. Both paths exist
on purpose: the built-ins stay compiled, because a generated `match` is faster
than an interpreted one and because the `tests:` blocks below have to run under
`cargo test`.
The reading half is one file, [`src/declared/decl.rs`](../src/declared/decl.rs),
shared by both — so everything documented here means exactly one thing, and
`tests/declared_parity.rs` asserts the two backends compose byte-identical WGSL
for every node in this directory. Nothing in this document is specific to the
build-time path.
## `attributes:` — what the operation is about
Required, one or more of `tone`, `colour`, `detail`, `optics`, `geometry`,
File diff suppressed because it is too large Load Diff
+447
View File
@@ -0,0 +1,447 @@
//! TRACES: FR-PLG-2
//! The little expression language a node declaration derives its uniforms in.
//!
//! Uniforms are functions of parameters — `exp2(exposure)`, `blacks / 100 *
//! 0.02` — and that derivation is the one piece of a node that is genuinely
//! computation rather than description. The language is arithmetic over the
//! node's own parameters plus a fixed set of maths functions: enough for every
//! operation in the chain, and small enough that a reader of the YAML can see
//! exactly what will happen.
//!
//! # Why this file is compiled twice
//!
//! It is `#[path]`-included by `build.rs` as well as being a module of the
//! crate, and it deliberately depends on nothing but `std` so that it can be.
//!
//! There are two backends over one grammar. `build.rs` renders an [`Expr`] to
//! Rust source, so a built-in node's arithmetic costs nothing at run time and
//! an unknown name is a build error naming the file. [`Expr::eval`] evaluates
//! the same tree directly, which is what lets a declaration loaded at run time
//! produce uniforms without a compiler.
//!
//! **The two must agree bit for bit**, or a plugin is not the same kind of
//! thing as a built-in and `tests/declared_parity.rs` says so. Sharing the
//! tokeniser and the parser removes the larger half of the ways they could
//! drift; the remaining half is the pair of backends, and each entry in
//! [`FUNCTIONS`] below is written twice on purpose, once here and once in
//! `build.rs`, with the parity test standing between them.
use std::collections::BTreeSet;
/// A number in a declaration, as the `f32` the arithmetic will actually use.
///
/// **The route through the decimal string is load-bearing, not clumsiness.**
/// `build.rs` renders a number as a Rust literal — `0.02f32` — and `rustc`
/// rounds that decimal text to the nearest `f32` exactly once. Writing
/// `n as f32` here would instead round the `f64` YAML parsed to the nearest
/// `f32`, which is a *second* rounding on top of the one that produced the
/// `f64`, and double rounding does not always land where single rounding does.
///
/// So this reproduces what the compiler sees: `{:?}` is the shortest decimal
/// that round-trips the `f64`, which is exactly the literal `build.rs` emits,
/// and parsing it as `f32` is exactly what `rustc` does with it.
pub fn as_f32(n: f64) -> f32 {
// Infallible in practice: `{:?}` on a finite `f64` is always a parseable
// decimal, and the infinities and NaN it can also print all parse back.
// The fallback is the direct cast rather than a panic, because a bad
// number in a declaration is the reader's error to report, not this
// function's to crash on.
format!("{n:?}").parse::<f32>().unwrap_or(n as f32)
}
/// The maths functions a declaration may call, and how many arguments each
/// takes.
///
/// A closed list rather than a passthrough to `f32`: a node is a description,
/// and letting it name arbitrary Rust would make the YAML a second, worse
/// place to write code. Closed for the same reason `WidgetKind` and
/// `attributes` are closed (FR-PLG-2d) — a typo must be an error rather than a
/// silent new category of one.
///
/// Shared by both backends so that the *set* of callable functions cannot
/// drift even though the two translations of each must be written separately.
pub const FUNCTIONS: &[(&str, usize)] = &[
("exp2", 1),
("log2", 1),
("exp", 1),
("sqrt", 1),
("abs", 1),
("floor", 1),
("ceil", 1),
("round", 1),
("pow", 2),
("min", 2),
("max", 2),
("clamp", 3),
("mix", 3),
];
/// A parsed expression over a node's parameters.
#[derive(Debug, Clone, PartialEq)]
pub enum Expr {
Num(f64),
Param(String),
Neg(Box<Expr>),
Bin(char, Box<Expr>, Box<Expr>),
Call(String, Vec<Expr>),
}
/// Parse and validate an expression against the parameters a node declares.
///
/// Validation happens here rather than in either backend, so that "this names
/// a parameter that does not exist" is one error message in one place and
/// cannot be reported at build time but missed at load time.
pub fn parse(src: &str, params: &BTreeSet<&str>) -> Result<Expr, String> {
let tokens = tokenise(src)?;
let mut parser = Parser { tokens, at: 0 };
let expr = parser.expr()?;
if parser.at < parser.tokens.len() {
return Err(format!(
"unexpected `{}` after the end of the expression",
parser.tokens[parser.at]
));
}
check(&expr, params)?;
Ok(expr)
}
/// Every name and arity in the tree resolves.
fn check(expr: &Expr, params: &BTreeSet<&str>) -> Result<(), String> {
match expr {
Expr::Num(_) => Ok(()),
Expr::Param(name) => {
if params.contains(name.as_str()) {
return Ok(());
}
let known: Vec<&str> = params.iter().copied().collect();
Err(format!(
"`{name}` is not a parameter of this node. Its parameters \
are: {}",
known.join(", ")
))
}
Expr::Neg(inner) => check(inner, params),
Expr::Bin(_, l, r) => {
check(l, params)?;
check(r, params)
}
Expr::Call(name, args) => {
let Some((_, arity)) = FUNCTIONS.iter().find(|(f, _)| *f == name) else {
let known: Vec<&str> = FUNCTIONS.iter().map(|(f, _)| *f).collect();
return Err(format!(
"`{name}` is not one of the maths functions a node may \
call. Available: {}",
known.join(", ")
));
};
if args.len() != *arity {
return Err(format!(
"`{name}` takes {arity} argument(s), given {}",
args.len()
));
}
for a in args {
check(a, params)?;
}
Ok(())
}
}
}
impl Expr {
/// TRACES: FR-PLG-2
/// Evaluate this expression for a set of parameter values.
///
/// **Every step is an `f32` operation in the same order `build.rs` renders
/// it**, which is what makes the interpreted result bit-identical to the
/// compiled one rather than merely close. Rust's `f32` arithmetic is IEEE
/// 754 with no excess precision, so `(a * b) + c` here and `(a * b) + c`
/// in generated source are the same number down to the last bit — and the
/// parity test asserts exactly that rather than an epsilon, because a
/// tolerance is how a real divergence gets to hide.
///
/// `param` is asked for a parameter's current value. It is a closure
/// rather than a map so the caller can serve the values out of whatever it
/// already has, which for [`super::DeclaredOp`] is a plain `Vec<f32>`
/// indexed in declaration order.
///
/// Infallible: [`parse`] has already established that every name resolves
/// and every call has the right arity. An unknown parameter reaching here
/// would be a reader that let one through, so `param` decides what to do
/// about it rather than this returning a `Result` every caller would
/// unwrap.
pub fn eval(&self, param: &dyn Fn(&str) -> f32) -> f32 {
match self {
Expr::Num(n) => as_f32(*n),
Expr::Param(name) => param(name),
Expr::Neg(inner) => -inner.eval(param),
Expr::Bin(op, l, r) => {
let (l, r) = (l.eval(param), r.eval(param));
match op {
'+' => l + r,
'-' => l - r,
'*' => l * r,
'/' => l / r,
// `tokenise` only ever produces these four as binary
// operators, and `Parser` only ever builds `Bin` from what
// `tokenise` produced.
_ => unreachable!("`{op}` is not a binary operator"),
}
}
Expr::Call(name, args) => {
let a = |i: usize| args[i].eval(param);
match name.as_str() {
"exp2" => f32::exp2(a(0)),
"log2" => f32::log2(a(0)),
"exp" => f32::exp(a(0)),
"sqrt" => f32::sqrt(a(0)),
"abs" => f32::abs(a(0)),
"floor" => f32::floor(a(0)),
"ceil" => f32::ceil(a(0)),
"round" => f32::round(a(0)),
"pow" => f32::powf(a(0), a(1)),
"min" => f32::min(a(0), a(1)),
"max" => f32::max(a(0), a(1)),
"clamp" => f32::clamp(a(0), a(1), a(2)),
// Spelled out rather than called, matching what `build.rs`
// renders: Rust has no `mix`, and this linear form is what
// WGSL's `mix` means. The association matters — `a + (b -
// a) * t` and `a * (1 - t) + b * t` are the same value in
// real arithmetic and different ones in `f32`.
"mix" => {
let (x, y, t) = (a(0), a(1), a(2));
x + (y - x) * t
}
// `parse` rejects anything not in `FUNCTIONS`.
_ => unreachable!("`{name}` is not a declared maths function"),
}
}
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum Tok {
Num(f64),
Ident(String),
Sym(char),
}
impl std::fmt::Display for Tok {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Tok::Num(n) => write!(f, "{n}"),
Tok::Ident(s) => write!(f, "{s}"),
Tok::Sym(c) => write!(f, "{c}"),
}
}
}
fn tokenise(src: &str) -> Result<Vec<Tok>, String> {
let bytes: Vec<char> = src.chars().collect();
let mut out = Vec::new();
let mut i = 0;
while i < bytes.len() {
let c = bytes[i];
if c.is_whitespace() {
i += 1;
} else if c.is_ascii_digit()
|| (c == '.' && bytes.get(i + 1).is_some_and(char::is_ascii_digit))
{
let start = i;
while i < bytes.len() && (bytes[i].is_ascii_digit() || bytes[i] == '.') {
i += 1;
}
let text: String = bytes[start..i].iter().collect();
let n = text
.parse::<f64>()
.map_err(|_| format!("`{text}` is not a number"))?;
out.push(Tok::Num(n));
} else if c.is_ascii_alphabetic() || c == '_' {
let start = i;
while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == '_') {
i += 1;
}
out.push(Tok::Ident(bytes[start..i].iter().collect()));
} else if "+-*/(),".contains(c) {
out.push(Tok::Sym(c));
i += 1;
} else {
return Err(format!(
"`{c}` is not valid in an expression; the language is \
arithmetic (+ - * /), parentheses, numbers, this node's \
parameters, and the maths functions"
));
}
}
if out.is_empty() {
return Err("the expression is empty".into());
}
Ok(out)
}
struct Parser {
tokens: Vec<Tok>,
at: usize,
}
impl Parser {
fn peek(&self) -> Option<&Tok> {
self.tokens.get(self.at)
}
fn eat(&mut self, sym: char) -> bool {
if self.peek() == Some(&Tok::Sym(sym)) {
self.at += 1;
return true;
}
false
}
fn expr(&mut self) -> Result<Expr, String> {
let mut left = self.term()?;
loop {
if self.eat('+') {
left = Expr::Bin('+', Box::new(left), Box::new(self.term()?));
} else if self.eat('-') {
left = Expr::Bin('-', Box::new(left), Box::new(self.term()?));
} else {
return Ok(left);
}
}
}
fn term(&mut self) -> Result<Expr, String> {
let mut left = self.unary()?;
loop {
if self.eat('*') {
left = Expr::Bin('*', Box::new(left), Box::new(self.unary()?));
} else if self.eat('/') {
left = Expr::Bin('/', Box::new(left), Box::new(self.unary()?));
} else {
return Ok(left);
}
}
}
fn unary(&mut self) -> Result<Expr, String> {
if self.eat('-') {
return Ok(Expr::Neg(Box::new(self.unary()?)));
}
self.primary()
}
fn primary(&mut self) -> Result<Expr, String> {
match self.peek().cloned() {
Some(Tok::Num(n)) => {
self.at += 1;
Ok(Expr::Num(n))
}
Some(Tok::Ident(name)) => {
self.at += 1;
if !self.eat('(') {
return Ok(Expr::Param(name));
}
let mut args = Vec::new();
if !self.eat(')') {
loop {
args.push(self.expr()?);
if self.eat(',') {
continue;
}
if self.eat(')') {
break;
}
return Err(format!("expected `,` or `)` in the call to `{name}`"));
}
}
Ok(Expr::Call(name, args))
}
Some(Tok::Sym('(')) => {
self.at += 1;
let inner = self.expr()?;
if !self.eat(')') {
return Err("unclosed `(`".into());
}
Ok(inner)
}
Some(t) => Err(format!("unexpected `{t}`")),
None => Err("the expression ends early".into()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn params() -> BTreeSet<&'static str> {
["a", "b"].into_iter().collect()
}
fn eval(src: &str, a: f32, b: f32) -> f32 {
parse(src, &params())
.expect("parses")
.eval(&|name| match name {
"a" => a,
"b" => b,
other => panic!("no parameter {other}"),
})
}
#[test]
fn arithmetic_follows_the_usual_precedence() {
assert_eq!(eval("a + b * 2", 1.0, 3.0), 7.0);
assert_eq!(eval("(a + b) * 2", 1.0, 3.0), 8.0);
}
#[test]
fn unary_minus_binds_tighter_than_addition() {
// `-(a + b)` and `-a + b` are different numbers, and the parser has to
// agree with the renderer about which one `-a + b` is.
assert_eq!(eval("-a + b", 1.0, 3.0), 2.0);
assert_eq!(eval("-(a + b)", 1.0, 3.0), -4.0);
}
#[test]
fn a_number_is_rounded_once_the_way_the_compiler_rounds_a_literal() {
// The whole reason `as_f32` goes through the decimal string. If this
// ever regresses to `n as f32`, the values a declared node produces
// drift from the generated one in the last bit, and every parity
// assertion has to become a tolerance to keep passing — which is
// exactly the silent disagreement the test exists to catch.
assert_eq!(as_f32(0.02), 0.02f32);
assert_eq!(as_f32(0.1), 0.1f32);
// A third: eight significant digits, which is past what an `f32`
// resolves, so this is the case where a second rounding could land
// somewhere the compiler's single one does not.
assert_eq!(as_f32(1.0 / 3.0), 0.333_333_34_f32);
}
#[test]
fn mix_is_the_linear_form_wgsl_means() {
assert_eq!(eval("mix(a, b, 0.25)", 0.0, 4.0), 1.0);
}
#[test]
fn an_unknown_parameter_names_the_ones_that_exist() {
// The error is the whole value of validating in the parser: whoever
// wrote the typo needs the list, and needs it identically whether the
// declaration was read by the build script or at load time.
let err = parse("c * 2", &params()).unwrap_err();
assert!(err.contains("`c` is not a parameter"), "{err}");
assert!(err.contains("a, b"), "{err}");
}
#[test]
fn an_unknown_function_is_rejected_rather_than_passed_through() {
let err = parse("tan(a)", &params()).unwrap_err();
assert!(err.contains("not one of the maths functions"), "{err}");
}
#[test]
fn a_wrong_arity_is_caught_where_it_is_written() {
let err = parse("pow(a)", &params()).unwrap_err();
assert!(err.contains("takes 2 argument(s), given 1"), "{err}");
}
}
+487
View File
@@ -0,0 +1,487 @@
//! TRACES: FR-PLG-2 | FR-PLG-2d
//! Running a node declaration without compiling it.
//!
//! # The format already existed
//!
//! `ops/*.yaml` plus `build.rs` has been the class-1 plugin format since the
//! declarative nodes landed — it was simply resolved at build time:
//!
//! ```text
//! ops/exposure.yaml ──build.rs──▶ generated impl Operation ──▶ fused shader
//! ```
//!
//! Nothing about that requires the declaration to be present when the compiler
//! runs. Everything a declaration produces is *data plus a WGSL string*, and
//! the composer already assembles WGSL at run time from whichever operations
//! are active. So this module is not a new mechanism; it is the existing one,
//! loaded later.
//!
//! [`DeclaredOp`] is **one interpreter over many declarations**, where
//! `build.rs` emits generated code per node. It implements [`Operation`] from
//! an owned [`Declaration`], which is only possible because descriptors became
//! owned — see [`crate::descriptor::OpDescriptor`] for why a `&'static`
//! descriptor made a run-time node impossible.
//!
//! # Both paths stay
//!
//! The generated path is not removed and should not be. FR-PLG-2 says so, and
//! the reasons are good ones: a generated `match` is faster than an
//! interpreted one, the built-ins' declared tests have to run under `cargo
//! test`, and generated source is *inspectable* in a way an interpreter's
//! internal state is not.
//!
//! What matters is that the two are **indistinguishable downstream**, and that
//! is a test rather than an intention: `tests/declared_parity.rs` parses every
//! built-in `ops/*.yaml` at run time and asserts the composed WGSL is
//! byte-for-byte identical to what the generated implementation produces, for
//! the same parameter values. If the two ever disagree, a plugin is not the
//! same kind of thing as a built-in and the premise of the whole plugin plan
//! has failed quietly.
//!
//! # What this is not, yet
//!
//! Not load-time WGSL validation (FR-PLG-11), not id namespacing (FR-PLG-2's
//! `author.name`), and not a plugin directory read at startup. Those are
//! separate work and are deliberately absent — a declaration reaching
//! [`DeclaredOp`] here is one that ships in this repository, so its WGSL has
//! already been compiled by the build and its id has already been checked for
//! collisions.
pub mod decl;
pub mod expr;
use std::sync::{Arc, LazyLock};
pub use decl::{Declaration, Node, SharedHelpers};
use crate::descriptor::{
intern, Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Presentation,
Scale, Unit, WidgetDemand, WidgetKind,
};
use crate::operation::{Helper, Operation, Uniform};
use expr::{as_f32, Expr};
/// The shared WGSL helper library, as the built-in nodes see it.
///
/// The very same `_helpers.yaml` `build.rs` reads, embedded rather than read
/// from disk: there is no file path an installed application could look it up
/// at, and embedding is what makes it impossible for the compiled helpers and
/// the interpreted ones to be two different files.
///
/// Panics if the bundled file does not parse, which is a build-time fact about
/// this repository rather than anything a user can cause — `build.rs` reads the
/// same text on the same build and fails first.
pub fn builtin_helpers() -> &'static SharedHelpers {
static LIBRARY: LazyLock<SharedHelpers> = LazyLock::new(|| {
decl::read_helpers(include_str!("../../ops/_helpers.yaml"))
.unwrap_or_else(|e| panic!("the bundled {}: {e}", decl::HELPERS_FILE))
});
&LIBRARY
}
/// TRACES: FR-PLG-2
/// An operation built from a declaration at run time.
///
/// Holds everything the trait has to answer with, resolved once when the
/// declaration is read: the descriptor, the uniform expressions, the helper
/// list and the fragment text. Nothing is re-parsed per call, so the per-frame
/// cost of an interpreted node is the arithmetic in [`Expr::eval`] and nothing
/// else — the same arithmetic the generated node does, simply walked rather
/// than inlined.
#[derive(Debug, Clone)]
pub struct DeclaredOp {
descriptor: Arc<OpDescriptor>,
/// Parameter ids in declaration order, parallel to `defaults` and
/// `values`.
///
/// Three parallel `Vec`s rather than one of triples because the hot read
/// is `values` alone, and because `set_param` writes exactly one of them.
/// They are built together and never resized.
params: Vec<ParamId>,
defaults: Vec<f32>,
values: Vec<f32>,
uniforms: Vec<DeclaredUniform>,
/// The declared `active:` rule, or `None` for the default one.
active: Option<Expr>,
wgsl: String,
helpers: Vec<Helper>,
presentation: Option<Presentation>,
order: i64,
}
/// One uniform: the name the fragment reads it by, and how to compute it.
#[derive(Debug, Clone)]
struct DeclaredUniform {
/// Interned when the declaration was read, not on every `uniforms()` call.
/// See [`crate::operation::Uniform::name`].
name: &'static str,
expr: Expr,
}
impl DeclaredOp {
/// Read one `ops/<id>.yaml` and build the operation it declares.
///
/// `ctx` names the source in error messages — a path, usually.
///
/// A `rust:` node is an error here rather than a silent `None`: it names a
/// hand-written type in this crate, which is by definition not something a
/// declaration can produce, and a caller that got one back as "nothing to
/// do" would drop an operation out of the chain without saying so.
pub fn from_yaml(text: &str, ctx: &str, library: &SharedHelpers) -> Result<Self, String> {
let names = library.names();
match decl::read_node(text, ctx, &names)? {
Node::Declared(d) => Self::new(&d, library),
Node::Rust { id, ty, .. } => Err(format!(
"`{id}` declares `rust: {ty}`, which names a hand-written type \
rather than describing an operation. Only a full declaration \
can be read at run time."
)),
}
}
/// Build the operation a parsed declaration describes.
pub fn new(declaration: &Declaration, library: &SharedHelpers) -> Result<Self, String> {
let params: Vec<ParamId> = declaration
.params
.iter()
.map(|p| ParamId::interned(&p.id))
.collect();
let defaults: Vec<f32> = declaration
.params
.iter()
.map(|p| as_f32(p.default))
.collect();
// Helpers in the order the composer will see them: the shared ones the
// node asked for, in the order it asked, then its own definitions.
// Order decides emission order in the generated shader, so it is part
// of the output rather than an implementation detail.
let mut helpers =
Vec::with_capacity(declaration.shared_helpers.len() + declaration.local_helpers.len());
for name in &declaration.shared_helpers {
// `decl::read_node` has already rejected a name the library does
// not define, so this is a library that changed underneath a
// declaration rather than a declaration with a typo in it.
let helper = library.get(name).ok_or_else(|| {
format!(
"`{}` asks for the shared helper `{name}`, which this \
helper library does not define",
declaration.id
)
})?;
helpers.push(Helper {
name: intern(&helper.name),
source: intern(&helper.source()),
});
}
for helper in &declaration.local_helpers {
helpers.push(Helper {
name: intern(&helper.name),
source: intern(&helper.source()),
});
}
Ok(Self {
descriptor: Arc::new(OpDescriptor {
id: OpId::interned(&declaration.id),
label: LocalizedKey::interned(&declaration.label),
params: declaration.params.iter().map(param_descriptor).collect(),
attributes: declaration
.attributes
.iter()
.copied()
.map(attribute)
.collect(),
}),
values: defaults.clone(),
params,
defaults,
uniforms: declaration
.uniforms
.iter()
.map(|u| DeclaredUniform {
name: intern(&u.name),
expr: u.expr.clone(),
})
.collect(),
active: declaration.active.clone(),
wgsl: declaration.wgsl_body(),
helpers,
presentation: declaration.presentation.as_deref().map(presentation),
order: declaration.order,
})
}
/// Where this node sits in the chain, from its `order:`.
///
/// Not part of [`Operation`] — the graph holds operations in a `Vec` and
/// order *is* that position (ARCH §3.4). Exposed so whoever assembles a
/// chain out of declarations can sort them, which is what `build.rs` does
/// at the other end.
pub fn order(&self) -> i64 {
self.order
}
fn index_of(&self, id: ParamId) -> Option<usize> {
self.params.iter().position(|p| *p == id)
}
/// One parameter's current value, by the name an expression calls it.
///
/// Returns 0.0 for a name that is not a parameter, matching what the
/// generated `param()` does with an unknown id. It cannot happen —
/// [`expr::parse`] rejects a name that is not declared — but a silent zero
/// is a better failure here than a panic inside a render.
fn value_named(&self, name: &str) -> f32 {
self.params
.iter()
.position(|p| p.0 == name)
.map_or(0.0, |i| self.values[i])
}
}
impl Operation for DeclaredOp {
fn descriptor(&self) -> Arc<OpDescriptor> {
self.descriptor.clone()
}
fn set_param(&mut self, id: ParamId, value: f32) {
match self.index_of(id) {
Some(i) => self.values[i] = value,
// The same complaint the generated `set_param` makes, for the same
// reason: a parameter that does not exist is a sidecar or a UI
// naming something this build does not have, and dropping it
// silently is how an edit comes to be half-applied.
None => log::warn!("{}: unknown parameter {id}", self.descriptor.id),
}
}
fn param(&self, id: ParamId) -> f32 {
self.index_of(id).map_or(0.0, |i| self.values[i])
}
fn is_active(&self) -> bool {
match &self.active {
Some(expr) => expr.eval(&|name| self.value_named(name)) != 0.0,
// The default rule, and the honest one: the operation is doing
// something exactly when a parameter has moved off its default.
// Short-circuiting in declaration order, which is what the
// generated `a != d || b != d` does.
None => self.values.iter().zip(&self.defaults).any(|(v, d)| v != d),
}
}
fn wgsl_body(&self) -> String {
self.wgsl.clone()
}
fn uniforms(&self) -> Vec<Uniform> {
self.uniforms
.iter()
.map(|u| Uniform {
name: u.name,
value: u.expr.eval(&|name| self.value_named(name)),
})
.collect()
}
fn helpers(&self) -> &[Helper] {
&self.helpers
}
fn presentation(&self) -> Option<Presentation> {
self.presentation.clone()
}
}
/// A declared parameter as the descriptor the panel reads.
///
/// Every arm calls the constructor `build.rs` renders a call to, so the two
/// produce the same `ParamDescriptor` by construction rather than by
/// coincidence.
fn param_descriptor(p: &decl::ParamDef) -> ParamDescriptor {
let id = intern(&p.id);
let label = intern(&p.label);
match &p.kind {
decl::Kind::Stops { min, max } => {
ParamDescriptor::stops(id, label, as_f32(*min), as_f32(*max))
}
decl::Kind::Amount => ParamDescriptor::amount(id, label),
decl::Kind::Switch => ParamDescriptor::switch(id, label),
decl::Kind::Fraction { default } => ParamDescriptor::fraction(id, label, as_f32(*default)),
decl::Kind::Scalar {
min,
max,
default,
unit,
scale,
precision,
} => ParamDescriptor::scalar(
id,
label,
as_f32(*min),
as_f32(*max),
as_f32(*default),
match unit {
decl::Unit::None => Unit::None,
decl::Unit::Stops => Unit::Stops,
decl::Unit::Kelvin => Unit::Kelvin,
decl::Unit::Percent => Unit::Percent,
},
match scale {
decl::Scale::Linear => Scale::Linear,
decl::Scale::Perceptual => Scale::Perceptual,
},
// `decl::read_kind` refuses a precision that does not fit, so this
// cast cannot lose anything.
*precision as u8,
),
decl::Kind::Enum { variants } => ParamDescriptor::choice(
id,
label,
variants.iter().map(|v| LocalizedKey::interned(v)).collect(),
),
}
}
fn attribute(a: decl::Attr) -> Attribute {
match a {
decl::Attr::Tone => Attribute::Tone,
decl::Attr::Colour => Attribute::Colour,
decl::Attr::Detail => Attribute::Detail,
decl::Attr::Optics => Attribute::Optics,
decl::Attr::Geometry => Attribute::Geometry,
decl::Attr::Effect => Attribute::Effect,
}
}
fn widget(w: decl::Widget) -> WidgetKind {
match w {
decl::Widget::ToneCurve => WidgetKind::ToneCurve,
decl::Widget::ColourWheel => WidgetKind::ColourWheel,
decl::Widget::CropOverlay => WidgetKind::CropOverlay,
decl::Widget::GradientHandle => WidgetKind::GradientHandle,
decl::Widget::BrushMask => WidgetKind::BrushMask,
decl::Widget::WhitePoint => WidgetKind::WhitePoint,
}
}
fn presentation(p: &decl::PresentationDef) -> Presentation {
Presentation {
widgets: p.widgets.iter().copied().map(widget).collect(),
demand: WidgetDemand {
two_dimensional: p.two_dimensional,
precise_pointing: p.precise_pointing,
},
params: p.params.iter().map(|n| ParamId::interned(n)).collect(),
}
}
#[cfg(test)]
mod tests {
use super::*;
/// TRACES: FR-PLG-2d
/// The two spellings of the attribute vocabulary are one vocabulary.
///
/// `decl::Attr` exists because the reader is compiled by `build.rs`, which
/// cannot see `crate::descriptor`. That is a duplicated closed list, and a
/// duplicated closed list is exactly the thing FR-PLG-2d warns about: an
/// attribute added to one and not the other would put an operation in a
/// category the panel does not know it has.
#[test]
fn the_attribute_vocabulary_is_the_same_on_both_sides() {
assert_eq!(decl::Attr::ALL.len(), Attribute::ALL.len());
for (a, b) in decl::Attr::ALL.iter().zip(Attribute::ALL) {
// Same order, so an index into one indexes the other.
assert_eq!(attribute(*a), b);
// And the name a declaration writes resolves to the same variant.
assert_eq!(Attribute::from_name(a.name()), Some(b), "{}", a.name());
}
}
/// TRACES: FR-PLG-2d
/// Every `WidgetKind` is nameable from a declaration.
///
/// A widget the core can ask for but a declaration cannot name is a widget
/// only a hand-written operation may have, which would make the two kinds
/// of node unequal in exactly the way FR-PLG-2 forbids.
#[test]
fn every_widget_kind_can_be_declared() {
assert_eq!(decl::Widget::ALL.len(), 6);
let named: Vec<WidgetKind> = decl::Widget::ALL.iter().copied().map(widget).collect();
for kind in [
WidgetKind::ToneCurve,
WidgetKind::ColourWheel,
WidgetKind::CropOverlay,
WidgetKind::GradientHandle,
WidgetKind::BrushMask,
WidgetKind::WhitePoint,
] {
assert!(named.contains(&kind), "{kind:?} cannot be declared");
}
}
#[test]
fn the_bundled_helper_library_parses() {
// It is `include_str!`'d, so a syntax error in it is a panic at first
// use rather than a build failure. This is the first use.
assert!(!builtin_helpers().helpers.is_empty());
}
fn exposure() -> DeclaredOp {
DeclaredOp::from_yaml(
include_str!("../../ops/exposure.yaml"),
"ops/exposure.yaml",
builtin_helpers(),
)
.expect("exposure declares an operation")
}
#[test]
fn a_declaration_becomes_an_operation_with_its_declared_descriptor() {
let op = exposure();
let d = op.descriptor();
assert_eq!(d.id, OpId("exposure"));
assert_eq!(d.label, LocalizedKey("op.exposure"));
assert_eq!(d.params.len(), 1);
assert_eq!(d.params[0].id, ParamId("exposure"));
assert_eq!(d.attributes, vec![Attribute::Tone]);
}
#[test]
fn a_declared_operation_is_neutral_until_a_parameter_moves() {
let mut op = exposure();
assert!(!op.is_active());
assert_eq!(op.uniforms()[0].value, 1.0);
op.set_param(ParamId("exposure"), 1.0);
assert!(op.is_active());
// A stop is a doubling — the same assertion `exposure.yaml`'s own
// declared test makes against the generated implementation.
assert_eq!(op.uniforms()[0].value, 2.0);
}
#[test]
fn an_interned_id_matches_a_literal_one() {
// The property that lets a declared operation be addressed by the same
// `ParamId` constants the generated code matches on. If interning ever
// stopped deduplicating, this would still pass — `ParamId` compares
// string contents — but the point is that the two are interchangeable
// at every call site.
let mut op = exposure();
op.set_param(ParamId::interned("exposure"), 2.0);
assert_eq!(op.param(ParamId("exposure")), 2.0);
}
#[test]
fn a_rust_node_is_refused_rather_than_silently_dropped() {
let err = DeclaredOp::from_yaml(
include_str!("../../ops/tone_curve.yaml"),
"ops/tone_curve.yaml",
builtin_helpers(),
)
.expect_err("a `rust:` node is not a declaration");
assert!(err.contains("hand-written type"), "{err}");
}
}
+142 -29
View File
@@ -8,12 +8,75 @@
//! 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)
@@ -24,6 +87,13 @@ impl fmt::Display for ParamId {
#[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)
@@ -34,6 +104,13 @@ impl fmt::Display for OpId {
#[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:
@@ -170,7 +247,11 @@ pub enum ParamKind {
Enum {
/// In index order. The label is a localisation key, resolved by the
/// frontend — `core/` must not depend on a localiser (NFR-A11Y-1).
variants: &'static [LocalizedKey],
///
/// 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>,
},
}
@@ -204,7 +285,7 @@ pub struct Presentation {
/// 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: &'static [WidgetKind],
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
@@ -215,7 +296,7 @@ pub struct Presentation {
///
/// Parameters absent from this list are presented normally, so an
/// operation can pair a curve with an ordinary strength slider.
pub params: &'static [ParamId],
pub params: Vec<ParamId>,
}
impl Presentation {
@@ -328,11 +409,13 @@ impl ParamDescriptor {
/// 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.
pub const fn choice(
id: &'static str,
label: &'static str,
variants: &'static [LocalizedKey],
) -> Self {
//
// 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),
@@ -366,13 +449,15 @@ impl ParamDescriptor {
/// A general scalar with an explicit range and default.
//
// Eight arguments, and a builder would be the usual answer — but this has
// to be `const` so descriptors can be `static`, which rules out the
// `&mut self` builder the pattern usually takes. (A `self`-by-value step
// *is* const-callable — `faceted` below is one — but eight of them would
// be eight methods to say what one call already says.) The two common
// shapes have their own constructors above; this is the escape hatch for
// the rest.
// 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,
@@ -404,10 +489,13 @@ impl ParamDescriptor {
/// 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. Taking `self` by
/// value is what keeps it usable in the `static` descriptors — a `&mut
/// self` builder is what cannot be `const`.
pub const fn faceted(mut self, facet: Facet) -> Self {
/// 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
}
@@ -418,10 +506,10 @@ impl ParamDescriptor {
/// 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 {
match &self.kind {
ParamKind::Scalar { min, max, .. } => {
if value.is_finite() {
value.clamp(min, max)
value.clamp(*min, *max)
} else {
// A NaN from a corrupt sidecar would otherwise poison the
// uniform block and blank the image.
@@ -544,20 +632,45 @@ impl Attribute {
}
}
/// The static description of an operation.
/// 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: &'static [ParamDescriptor],
pub params: Vec<ParamDescriptor>,
/// What this operation is about (ARCH §4.3a).
///
/// **Never empty**, and `build.rs` refuses to generate 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: &'static [Attribute],
/// **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 {
+7 -1
View File
@@ -519,7 +519,13 @@ pub fn compose_detail_with(
) -> ComposedDetail {
// Every pass of every active detail operation, flattened, carrying the
// operation it came from for the uniform prefix and the helper set.
let mut planned: Vec<(&'static str, &'static [Helper], DetailPass, usize)> = Vec::new();
//
// The helper slice borrows from the operation rather than being `'static`:
// `Operation::helpers` hands out a slice owned by the operation now, so
// that a node built from a declaration at load time can own its list
// (FR-PLG-2). The borrow lasts as long as `ops`, which outlives this
// function's body.
let mut planned: Vec<(&str, &[Helper], DetailPass, usize)> = Vec::new();
for (index, pass) in spots.iter().enumerate() {
planned.push((
crate::spot::SPOT_ID,
+27 -24
View File
@@ -29,6 +29,7 @@
//! was built for: a horizontal pass then a vertical one is mathematically a 2D
//! box average, so if the ping-pong is wired backwards or a pass reads its own
//! output the result is visibly not a box blur rather than subtly wrong.
use std::sync::{Arc, LazyLock};
use crate::descriptor::{
Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, ParamKind, Scale, Unit,
@@ -36,28 +37,30 @@ use crate::descriptor::{
use crate::detail::{DetailPass, DetailStage, RenderScale};
use crate::operation::{Affects, Operation, Uniform};
static DESCRIPTOR: OpDescriptor = OpDescriptor {
id: OpId("detail_probe"),
label: LocalizedKey("op.detail_probe"),
params: &[ParamDescriptor {
id: ParamId("radius"),
label: LocalizedKey("param.detail_probe.radius"),
// A fraction of the frame's shorter edge, which is the unit
// `RenderScale::frame_fraction` converts and the unit a mask feather
// is already stored in. Stating it in pixels is the mistake this
// whole stage is arranged to make impossible.
kind: ParamKind::Scalar {
min: 0.0,
max: 0.25,
scale: Scale::Linear,
unit: Unit::None,
precision: 4,
},
default: 0.0,
facet: None,
}],
attributes: &[Attribute::Detail],
};
static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
Arc::new(OpDescriptor {
id: OpId("detail_probe"),
label: LocalizedKey("op.detail_probe"),
params: vec![ParamDescriptor {
id: ParamId("radius"),
label: LocalizedKey("param.detail_probe.radius"),
// A fraction of the frame's shorter edge, which is the unit
// `RenderScale::frame_fraction` converts and the unit a mask feather
// is already stored in. Stating it in pixels is the mistake this
// whole stage is arranged to make impossible.
kind: ParamKind::Scalar {
min: 0.0,
max: 0.25,
scale: Scale::Linear,
unit: Unit::None,
precision: 4,
},
default: 0.0,
facet: None,
}],
attributes: vec![Attribute::Detail],
})
});
/// A separable box blur whose radius is a fraction of the frame's shorter edge.
#[derive(Debug, Clone, Copy, Default)]
@@ -85,8 +88,8 @@ impl BoxBlur {
}
impl Operation for BoxBlur {
fn descriptor(&self) -> &'static OpDescriptor {
&DESCRIPTOR
fn descriptor(&self) -> Arc<OpDescriptor> {
DESCRIPTOR.clone()
}
fn set_param(&mut self, _id: ParamId, value: f32) {
+54 -51
View File
@@ -40,6 +40,7 @@
use std::f32::consts::PI;
use std::fmt::Write as _;
use std::sync::{Arc, LazyLock};
use crate::descriptor::{
Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Presentation, Scale,
@@ -73,50 +74,52 @@ static FRAMING_PARAMS: [ParamId; 8] = [
CROP_X, CROP_Y, CROP_W, CROP_H, ANGLE, ROTATION, FLIP_H, FLIP_V,
];
static DESCRIPTOR: OpDescriptor = OpDescriptor {
// The shape of the frame, and the only operation that changes the
// output's dimensions.
attributes: &[Attribute::Geometry],
id: ID,
label: LocalizedKey("op.framing"),
params: &[
// Straightening. Degrees rather than a normalised amount because a
// photographer reading "-1.4°" off a horizon knows what it means.
ParamDescriptor::scalar(
"angle",
"param.angle",
-MAX_STRAIGHTEN,
MAX_STRAIGHTEN,
0.0,
Unit::None,
Scale::Linear,
2,
),
// Quarter turns, 0..3. Separate from `angle` because these are exact
// and lossless, and because reorienting a frame is a different
// gesture from nudging a horizon.
ParamDescriptor::scalar(
"rotation",
"param.rotation",
0.0,
3.0,
0.0,
Unit::None,
Scale::Linear,
0,
),
ParamDescriptor::switch("flip_h", "param.flip_h"),
ParamDescriptor::switch("flip_v", "param.flip_v"),
// The crop rect, in fractions of the source. Normalised rather than
// in pixels so a crop survives being applied to a proxy, a full
// resolution render, or an export at another size — the same reason
// the viewport renders at display resolution (FR-DSP-1).
ParamDescriptor::fraction("crop_x", "param.crop_x", 0.0),
ParamDescriptor::fraction("crop_y", "param.crop_y", 0.0),
ParamDescriptor::fraction("crop_w", "param.crop_w", 1.0),
ParamDescriptor::fraction("crop_h", "param.crop_h", 1.0),
],
};
static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
Arc::new(OpDescriptor {
// The shape of the frame, and the only operation that changes the
// output's dimensions.
attributes: vec![Attribute::Geometry],
id: ID,
label: LocalizedKey("op.framing"),
params: vec![
// Straightening. Degrees rather than a normalised amount because a
// photographer reading "-1.4°" off a horizon knows what it means.
ParamDescriptor::scalar(
"angle",
"param.angle",
-MAX_STRAIGHTEN,
MAX_STRAIGHTEN,
0.0,
Unit::None,
Scale::Linear,
2,
),
// Quarter turns, 0..3. Separate from `angle` because these are exact
// and lossless, and because reorienting a frame is a different
// gesture from nudging a horizon.
ParamDescriptor::scalar(
"rotation",
"param.rotation",
0.0,
3.0,
0.0,
Unit::None,
Scale::Linear,
0,
),
ParamDescriptor::switch("flip_h", "param.flip_h"),
ParamDescriptor::switch("flip_v", "param.flip_v"),
// The crop rect, in fractions of the source. Normalised rather than
// in pixels so a crop survives being applied to a proxy, a full
// resolution render, or an export at another size — the same reason
// the viewport renders at display resolution (FR-DSP-1).
ParamDescriptor::fraction("crop_x", "param.crop_x", 0.0),
ParamDescriptor::fraction("crop_y", "param.crop_y", 0.0),
ParamDescriptor::fraction("crop_w", "param.crop_w", 1.0),
ParamDescriptor::fraction("crop_h", "param.crop_h", 1.0),
],
})
});
/// A normalised crop rectangle, in fractions of the source image.
#[derive(Debug, Clone, Copy, PartialEq)]
@@ -252,8 +255,8 @@ impl Framing {
Self::default()
}
pub fn descriptor(&self) -> &'static OpDescriptor {
&DESCRIPTOR
pub fn descriptor(&self) -> Arc<OpDescriptor> {
DESCRIPTOR.clone()
}
/// TRACES: FR-DEV-3a | FR-DEV-3b | FR-UI-7
@@ -280,7 +283,7 @@ impl Framing {
/// into the generated panel underneath a crop control that already exists.
pub fn presentation(&self) -> Option<Presentation> {
Some(Presentation {
widgets: &[WidgetKind::CropOverlay],
widgets: vec![WidgetKind::CropOverlay],
demand: WidgetDemand {
// A crop rect is dragged by its corners; nothing about that
// reduces to one axis at a time.
@@ -290,7 +293,7 @@ impl Framing {
// modality, so a thumb is as workable as a mouse.
precise_pointing: false,
},
params: &FRAMING_PARAMS,
params: FRAMING_PARAMS.to_vec(),
})
}
@@ -1426,7 +1429,7 @@ mod tests {
fn every_parameter_at_its_extremes_is_survivable() {
// The whole descriptor driven to both ends, which is what a codegen
// test does and what a corrupt sidecar can do.
for p in DESCRIPTOR.params {
for p in &DESCRIPTOR.params {
for value in [-1e9, -1.0, 0.0, 1.0, 1e9, f32::NAN] {
let mut f = Framing::new();
f.set_param(p.id, p.clamp(value));
@@ -1610,7 +1613,7 @@ mod tests {
// The same contract the operations honour, checked against the
// descriptor rather than a literal.
let mut f = Framing::new();
for p in DESCRIPTOR.params {
for p in &DESCRIPTOR.params {
f.set_param(p.id, p.default);
}
assert!(!f.is_active(), "descriptor defaults must be neutral");
@@ -1618,7 +1621,7 @@ mod tests {
#[test]
fn every_default_is_within_its_declared_range() {
for p in DESCRIPTOR.params {
for p in &DESCRIPTOR.params {
assert_eq!(p.clamp(p.default), p.default, "{} is out of range", p.id);
}
}
+13 -9
View File
@@ -51,8 +51,8 @@ pub struct OpCapability {
/// 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; `build.rs` refuses an operation that declares none.
pub attributes: &'static [Attribute],
/// Never empty; both readers refuse an operation that declares none.
pub attributes: Vec<Attribute>,
}
/// TRACES: FR-DEV-3a | FR-DEV-3b
@@ -243,7 +243,7 @@ impl EditGraph {
/// 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<&'static OpDescriptor> {
pub fn descriptors(&self) -> Vec<Arc<OpDescriptor>> {
self.ops.iter().map(|o| o.descriptor()).collect()
}
@@ -281,7 +281,7 @@ impl EditGraph {
})
.collect(),
presentation: op.presentation(),
attributes: desc.attributes,
attributes: desc.attributes.clone(),
}
});
@@ -310,7 +310,7 @@ impl EditGraph {
// sliders for framing *without naming framing* — see
// `Framing::presentation`.
presentation: self.framing.presentation(),
attributes: desc.attributes,
attributes: desc.attributes.clone(),
};
ops.chain(std::iter::once(framing)).collect()
@@ -427,7 +427,11 @@ impl EditGraph {
pub fn set_param(&mut self, op: OpId, param: ParamId, value: f32) {
if op == crate::framing::ID {
let Some(desc) = self.framing.descriptor().param(param) else {
// 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;
};
@@ -469,7 +473,7 @@ impl EditGraph {
/// 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 {
for p in &op.descriptor().params {
op.set_param(p.id, p.default);
}
}
@@ -625,7 +629,7 @@ impl EditGraph {
// 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 {
for p in &self.framing.descriptor().params {
geometry = hash_bytes(geometry, p.id.0.as_bytes());
geometry = mix(
geometry,
@@ -944,7 +948,7 @@ mod tests {
.capabilities()
.iter()
.flat_map(|c| {
c.params.iter().map(move |p| match p.kind {
c.params.iter().map(move |p| match &p.kind {
ParamKind::Scalar {
min,
max,
+45 -24
View File
@@ -53,6 +53,7 @@
//! wearing the same lens, which defeats the point of a lens profile.
use std::fmt::Write as _;
use std::sync::Arc;
use crate::descriptor::{OpDescriptor, ParamId};
use crate::operation::{Helper, Uniform};
@@ -62,8 +63,10 @@ use crate::operation::{Helper, Uniform};
/// Object-safe for the same reason [`crate::operation::Operation`] is: the
/// graph holds `Box<dyn Warp>` in order, so the geometry chain is data.
pub trait Warp: Send + Sync {
/// Static description, driving UI generation exactly as for an operation.
fn descriptor(&self) -> &'static OpDescriptor;
/// This warp's description, driving UI generation exactly as for an
/// operation — including being owned rather than `&'static`, for the
/// reason [`crate::descriptor::OpDescriptor`] gives.
fn descriptor(&self) -> Arc<OpDescriptor>;
/// Set a parameter. Values arrive already clamped to the descriptor.
fn set_param(&mut self, id: ParamId, value: f32);
@@ -204,32 +207,38 @@ fn sanitise(id: &str) -> String {
#[cfg(test)]
mod tests {
use std::sync::LazyLock;
use super::*;
use crate::descriptor::Attribute;
use crate::descriptor::{LocalizedKey, OpDescriptor, OpId, ParamDescriptor};
static DESC_A: OpDescriptor = OpDescriptor {
id: OpId("warp_a"),
label: LocalizedKey("a"),
params: &[ParamDescriptor::amount("amount", "a.amount")],
attributes: &[Attribute::Tone],
};
static DESC_B: OpDescriptor = OpDescriptor {
id: OpId("warp_b"),
label: LocalizedKey("b"),
params: &[ParamDescriptor::amount("amount", "b.amount")],
attributes: &[Attribute::Tone],
};
static DESC_A: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
Arc::new(OpDescriptor {
id: OpId("warp_a"),
label: LocalizedKey("a"),
params: vec![ParamDescriptor::amount("amount", "a.amount")],
attributes: vec![Attribute::Tone],
})
});
static DESC_B: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
Arc::new(OpDescriptor {
id: OpId("warp_b"),
label: LocalizedKey("b"),
params: vec![ParamDescriptor::amount("amount", "b.amount")],
attributes: vec![Attribute::Tone],
})
});
struct Fake {
desc: &'static OpDescriptor,
desc: Arc<OpDescriptor>,
amount: f32,
splits: bool,
}
impl Warp for Fake {
fn descriptor(&self) -> &'static OpDescriptor {
self.desc
fn descriptor(&self) -> Arc<OpDescriptor> {
self.desc.clone()
}
fn set_param(&mut self, _id: ParamId, value: f32) {
self.amount = value;
@@ -254,7 +263,7 @@ mod tests {
}
}
fn fake(desc: &'static OpDescriptor, amount: f32, splits: bool) -> Box<dyn Warp> {
fn fake(desc: Arc<OpDescriptor>, amount: f32, splits: bool) -> Box<dyn Warp> {
Box::new(Fake {
desc,
amount,
@@ -266,7 +275,7 @@ mod tests {
fn no_active_warp_composes_to_nothing() {
// The property that keeps the common case free: an image with no lens
// correction must not pay for a bilinear sample.
let composed = compose_warps(&[fake(&DESC_A, 0.0, false)]);
let composed = compose_warps(&[fake(DESC_A.clone(), 0.0, false)]);
assert!(!composed.is_active());
assert!(composed.uniforms.is_empty());
assert!(!composed.splits_channels);
@@ -274,7 +283,7 @@ mod tests {
#[test]
fn an_active_warp_appears_once() {
let composed = compose_warps(&[fake(&DESC_A, 2.0, false)]);
let composed = compose_warps(&[fake(DESC_A.clone(), 2.0, false)]);
assert!(composed.is_active());
assert!(composed.body.contains("---- warp: warp_a ----"));
assert!(composed.body.contains("u.warp_a_amount"));
@@ -284,7 +293,10 @@ mod tests {
fn uniforms_are_prefixed_so_warps_cannot_collide() {
// Both fakes declare `amount`; without prefixing the generated struct
// would carry a duplicate field and fail to compile.
let composed = compose_warps(&[fake(&DESC_A, 1.0, false), fake(&DESC_B, 2.0, false)]);
let composed = compose_warps(&[
fake(DESC_A.clone(), 1.0, false),
fake(DESC_B.clone(), 2.0, false),
]);
assert!(composed.uniform_fields.contains("warp_a_amount: f32"));
assert!(composed.uniform_fields.contains("warp_b_amount: f32"));
assert_eq!(composed.uniforms, vec![1.0, 2.0]);
@@ -294,7 +306,10 @@ mod tests {
fn channel_splitting_is_requested_by_any_active_warp() {
// One CA warp among several must switch the whole stage to the
// three-sample path.
let composed = compose_warps(&[fake(&DESC_A, 1.0, false), fake(&DESC_B, 1.0, true)]);
let composed = compose_warps(&[
fake(DESC_A.clone(), 1.0, false),
fake(DESC_B.clone(), 1.0, true),
]);
assert!(composed.splits_channels);
}
@@ -302,14 +317,20 @@ mod tests {
fn an_inactive_splitting_warp_does_not_force_three_samples() {
// CA present but at neutral must cost nothing — otherwise every image
// with the panel visible pays triple bandwidth.
let composed = compose_warps(&[fake(&DESC_A, 1.0, false), fake(&DESC_B, 0.0, true)]);
let composed = compose_warps(&[
fake(DESC_A.clone(), 1.0, false),
fake(DESC_B.clone(), 0.0, true),
]);
assert!(composed.is_active());
assert!(!composed.splits_channels);
}
#[test]
fn warps_compose_in_order() {
let composed = compose_warps(&[fake(&DESC_A, 1.0, false), fake(&DESC_B, 1.0, false)]);
let composed = compose_warps(&[
fake(DESC_A.clone(), 1.0, false),
fake(DESC_B.clone(), 1.0, false),
]);
let a = composed.body.find("warp_a").expect("a present");
let b = composed.body.find("warp_b").expect("b present");
assert!(a < b, "warps must chain in graph order");
+6 -4
View File
@@ -31,6 +31,7 @@
//! single multiply and white balance a per-channel scale; on gamma-encoded
//! data neither would be physically meaningful (ARCH §5.2).
pub mod declared;
pub mod descriptor;
pub mod detail;
pub mod framing;
@@ -45,6 +46,7 @@ pub mod sidecar;
pub mod spot;
pub mod state;
pub use declared::{Declaration, DeclaredOp};
pub use descriptor::{
Attribute, Facet, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, ParamKind,
Presentation, Scale, Unit, WidgetDemand, WidgetKind,
@@ -81,13 +83,13 @@ mod tests {
let mut g = EditGraph::default_chain();
for desc in g.descriptors() {
for (i, p) in desc.params.iter().enumerate() {
let v = match p.kind {
let v = match &p.kind {
ParamKind::Scalar { min, max, .. } => {
// A fraction that differs per parameter, so no two
// move in lockstep.
let fraction = 0.15 + 0.05 * (i % 4) as f32;
let step = (max - min) * fraction;
if p.default + step <= max {
if p.default + step <= *max {
p.default + step
} else {
p.default - step
@@ -217,7 +219,7 @@ mod tests {
// A default outside its own range would mean a fresh image opens
// with a value the UI cannot represent.
for desc in EditGraph::default_chain().descriptors() {
for p in desc.params {
for p in &desc.params {
assert_eq!(
p.clamp(p.default),
p.default,
@@ -236,7 +238,7 @@ mod tests {
// operation must read its own default as neutral.
let g = EditGraph::default_chain();
for desc in g.descriptors() {
for p in desc.params {
for p in &desc.params {
assert_eq!(
g.param(desc.id, p.id),
Some(p.default),
+17 -8
View File
@@ -36,6 +36,7 @@
//! see there for what happens when it does not match.
use std::fmt::Write as _;
use std::sync::Arc;
use crate::descriptor::{OpDescriptor, ParamId};
use crate::operation::Operation;
@@ -692,7 +693,7 @@ impl Clone for MaskLayer {
fn clone(&self) -> Self {
let mut ops = layer_chain();
for (dst, src) in ops.iter_mut().zip(&self.ops) {
for p in src.descriptor().params {
for p in &src.descriptor().params {
dst.set_param(p.id, src.param(p.id));
}
}
@@ -947,7 +948,7 @@ impl MaskLayer {
}
}
pub fn descriptors(&self) -> Vec<&'static OpDescriptor> {
pub fn descriptors(&self) -> Vec<Arc<OpDescriptor>> {
self.ops.iter().map(|o| o.descriptor()).collect()
}
@@ -995,7 +996,7 @@ impl MaskLayer {
})
.collect(),
presentation: op.presentation(),
attributes: desc.attributes,
attributes: desc.attributes.clone(),
}
})
.collect()
@@ -1007,7 +1008,7 @@ impl MaskLayer {
/// arrive at — so "start this layer's edit again" must not throw it away.
pub fn reset_adjustments(&mut self) {
for op in &mut self.ops {
for p in op.descriptor().params {
for p in &op.descriptor().params {
op.set_param(p.id, p.default);
}
}
@@ -1026,10 +1027,18 @@ impl MaskLayer {
/// Every non-default parameter, for the sidecar.
pub fn params(&self) -> impl Iterator<Item = (&'static str, &'static str, f32)> + '_ {
self.ops.iter().flat_map(|o| {
let id = o.descriptor().id.0;
o.descriptor().params.iter().filter_map(move |p| {
let v = o.param(p.id);
(v != p.default).then_some((id, p.id.0, v))
let desc = o.descriptor();
let id = desc.id.0;
// Collected rather than borrowed from `desc`: a descriptor is an
// `Arc` handed over by value now (FR-PLG-2), so it would be
// dropped at the end of this closure and the lazy iterator would
// outlive it. The ids and defaults are all this needs, and there
// are a handful of them.
let params: Vec<(ParamId, f32)> =
desc.params.iter().map(|p| (p.id, p.default)).collect();
params.into_iter().filter_map(move |(param, default)| {
let v = o.param(param);
(v != default).then_some((id, param.0, v))
})
})
}
+111 -40
View File
@@ -21,6 +21,7 @@
//! generator emits readable, commented output — see [`compose`].
use std::fmt::Write as _;
use std::sync::Arc;
use dr_types::{ColourSpace, Transfer};
@@ -171,7 +172,7 @@ impl Invalidation {
pub(crate) fn hash_op(h: u64, op: &dyn Operation) -> u64 {
let desc = op.descriptor();
let mut h = hash_bytes(h, desc.id.0.as_bytes());
for p in desc.params {
for p in &desc.params {
h = hash_bytes(h, p.id.0.as_bytes());
h = mix(h, u64::from(canonical_bits(op.param(p.id))));
}
@@ -213,6 +214,12 @@ pub(crate) const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
pub struct Uniform {
/// Field name as it appears in WGSL. Prefixed with the op id by the
/// composer, so two operations may both declare `amount`.
///
/// `&'static str` for the reason [`crate::descriptor::intern`] gives about
/// ids: a uniform name is a small, deduplicated, process-lifetime piece of
/// vocabulary, and a declared operation interns its names once when it is
/// parsed rather than allocating them on every `uniforms()` call — which
/// happens per composition, and composition happens per frame.
pub name: &'static str,
pub value: f32,
}
@@ -222,8 +229,32 @@ pub struct Uniform {
/// Object-safe: the pipeline holds `Box<dyn Operation>` in graph order, so
/// order is data rather than code (ARCH §3.4).
pub trait Operation: Send + Sync {
/// Static description, driving UI generation (FR-DEV-3a).
fn descriptor(&self) -> &'static OpDescriptor;
/// TRACES: FR-DEV-3a | FR-PLG-2
/// This operation's description, driving UI generation (FR-DEV-3a).
///
/// **Shared and owned rather than `&'static`.** See [`OpDescriptor`] for
/// why — in short, a `&'static` descriptor is one a compile-time literal
/// can produce and a load-time declaration cannot, which would make a
/// plugin a second-class kind of operation for a reason that is purely an
/// artefact of how the built-ins happen to be written.
///
/// # What this costs, and where
///
/// One `Arc` clone and drop per call. Descriptors are read when a panel is
/// built (`EditGraph::capabilities`), when a sidecar is written or read,
/// and when the history names what changed — none of which is a per-frame
/// path.
///
/// There is **one** exception, and it is worth stating plainly rather than
/// letting somebody discover it with a profiler: [`compose_full`] reads
/// `descriptor().id` once per *active* operation to prefix its uniforms,
/// and `dr-ui` composes on every frame it draws. That is a handful of
/// atomic increments — a dozen or so, against a composition that is
/// already building several kilobytes of WGSL text from scratch on the
/// same call. If composition ever stops being a per-frame operation, this
/// stops being a question at all; while it is one, the refcount is not
/// what makes it expensive.
fn descriptor(&self) -> Arc<OpDescriptor>;
/// Set a parameter. Values arrive already clamped to the descriptor.
fn set_param(&mut self, id: ParamId, value: f32);
@@ -321,7 +352,13 @@ pub trait Operation: Send + Sync {
/// Emitted once per *distinct* function name even if several operations
/// request it, so shared helpers (luminance, soft clipping) are declared
/// exactly once.
fn helpers(&self) -> &'static [Helper] {
///
/// Borrowed from `self` rather than `'static`, for the reason
/// [`Self::descriptor`] is owned: a generated operation returns a
/// `&'static [Helper]` and coerces, while an operation built from a
/// declaration at load time owns its list. The [`Helper`] *strings*
/// themselves stay `&'static` — they are interned, like the ids.
fn helpers(&self) -> &[Helper] {
&[]
}
@@ -1184,31 +1221,37 @@ pub(crate) fn sanitise(id: &str) -> String {
#[cfg(test)]
mod tests {
use super::*;
use std::sync::LazyLock;
use crate::descriptor::Attribute;
use crate::descriptor::{LocalizedKey, OpId, ParamDescriptor};
static DESC_A: OpDescriptor = OpDescriptor {
id: OpId("op_a"),
label: LocalizedKey("a"),
params: &[ParamDescriptor::amount("amount", "a.amount")],
attributes: &[Attribute::Tone],
};
static DESC_B: OpDescriptor = OpDescriptor {
id: OpId("op_b"),
label: LocalizedKey("b"),
params: &[ParamDescriptor::amount("amount", "b.amount")],
attributes: &[Attribute::Tone],
};
static DESC_A: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
Arc::new(OpDescriptor {
id: OpId("op_a"),
label: LocalizedKey("a"),
params: vec![ParamDescriptor::amount("amount", "a.amount")],
attributes: vec![Attribute::Tone],
})
});
static DESC_B: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
Arc::new(OpDescriptor {
id: OpId("op_b"),
label: LocalizedKey("b"),
params: vec![ParamDescriptor::amount("amount", "b.amount")],
attributes: vec![Attribute::Tone],
})
});
struct Fake {
desc: &'static OpDescriptor,
desc: Arc<OpDescriptor>,
amount: f32,
helper: Option<Helper>,
}
impl Operation for Fake {
fn descriptor(&self) -> &'static OpDescriptor {
self.desc
fn descriptor(&self) -> Arc<OpDescriptor> {
self.desc.clone()
}
fn set_param(&mut self, _id: ParamId, value: f32) {
self.amount = value;
@@ -1241,7 +1284,7 @@ mod tests {
source: "fn luma(c: vec3<f32>) -> f32 { return c.g; }",
}];
fn fake(desc: &'static OpDescriptor, amount: f32, helper: bool) -> Box<dyn Operation> {
fn fake(desc: Arc<OpDescriptor>, amount: f32, helper: bool) -> Box<dyn Operation> {
Box::new(Fake {
desc,
amount,
@@ -1253,7 +1296,7 @@ mod tests {
fn an_inactive_operation_contributes_nothing() {
// The point of composing rather than branching: an op at neutral
// must not appear in the source at all.
let ops = vec![fake(&DESC_A, 0.0, false)];
let ops = vec![fake(DESC_A.clone(), 0.0, false)];
let shader = compose(&ops);
assert!(
!shader.source.contains("op_a"),
@@ -1274,7 +1317,7 @@ mod tests {
#[test]
fn an_active_operation_appears_once() {
let ops = vec![fake(&DESC_A, 2.0, false)];
let ops = vec![fake(DESC_A.clone(), 2.0, false)];
let shader = compose(&ops);
assert!(shader.source.contains("---- op_a ----"));
assert!(shader.source.contains("u.op_a_amount"));
@@ -1285,7 +1328,10 @@ mod tests {
// Both fakes declare a uniform called `amount`. Without prefixing,
// the generated struct would have a duplicate field and fail to
// compile — the failure mode that makes naive concatenation fragile.
let ops = vec![fake(&DESC_A, 1.0, false), fake(&DESC_B, 2.0, false)];
let ops = vec![
fake(DESC_A.clone(), 1.0, false),
fake(DESC_B.clone(), 2.0, false),
];
let shader = compose(&ops);
assert!(shader.source.contains("op_a_amount: f32"));
assert!(shader.source.contains("op_b_amount: f32"));
@@ -1295,7 +1341,10 @@ mod tests {
#[test]
fn uniform_values_follow_declaration_order() {
let ops = vec![fake(&DESC_A, 1.5, false), fake(&DESC_B, 2.5, false)];
let ops = vec![
fake(DESC_A.clone(), 1.5, false),
fake(DESC_B.clone(), 2.5, false),
];
let shader = compose(&ops);
assert_eq!(shader.uniforms[PREAMBLE_FIELDS], 1.5);
assert_eq!(shader.uniforms[PREAMBLE_FIELDS + 1], 2.5);
@@ -1305,7 +1354,10 @@ mod tests {
fn a_shared_helper_is_emitted_once() {
// Two operations wanting the same helper must not produce a
// duplicate function definition.
let ops = vec![fake(&DESC_A, 1.0, true), fake(&DESC_B, 1.0, true)];
let ops = vec![
fake(DESC_A.clone(), 1.0, true),
fake(DESC_B.clone(), 1.0, true),
];
let shader = compose(&ops);
assert_eq!(
shader.source.matches("fn luma(").count(),
@@ -1319,7 +1371,17 @@ mod tests {
// WGSL rejects a uniform struct whose size is not a multiple of 16.
for n in 0..6 {
let ops: Vec<Box<dyn Operation>> = (0..n)
.map(|i| fake(if i % 2 == 0 { &DESC_A } else { &DESC_B }, 1.0, false))
.map(|i| {
fake(
if i % 2 == 0 {
DESC_A.clone()
} else {
DESC_B.clone()
},
1.0,
false,
)
})
.collect();
let shader = compose(&ops);
assert_eq!(
@@ -1335,11 +1397,14 @@ mod tests {
fn structure_hash_ignores_values_but_tracks_the_op_set() {
// The property the shader cache depends on: moving a slider must not
// trigger a recompile, but enabling an operation must.
let a1 = compose(&[fake(&DESC_A, 1.0, false)]).structure_hash;
let a2 = compose(&[fake(&DESC_A, 9.0, false)]).structure_hash;
let a1 = compose(&[fake(DESC_A.clone(), 1.0, false)]).structure_hash;
let a2 = compose(&[fake(DESC_A.clone(), 9.0, false)]).structure_hash;
assert_eq!(a1, a2, "a value change must reuse the compiled pipeline");
let both = compose(&[fake(&DESC_A, 1.0, false), fake(&DESC_B, 1.0, false)]);
let both = compose(&[
fake(DESC_A.clone(), 1.0, false),
fake(DESC_B.clone(), 1.0, false),
]);
assert_ne!(a1, both.structure_hash, "a different op-set must recompile");
}
@@ -1347,8 +1412,14 @@ mod tests {
fn structure_hash_is_order_sensitive() {
// Operation order is data (ARCH §3.4); two orders are different
// shaders and must not share a cache entry.
let ab = compose(&[fake(&DESC_A, 1.0, false), fake(&DESC_B, 1.0, false)]);
let ba = compose(&[fake(&DESC_B, 1.0, false), fake(&DESC_A, 1.0, false)]);
let ab = compose(&[
fake(DESC_A.clone(), 1.0, false),
fake(DESC_B.clone(), 1.0, false),
]);
let ba = compose(&[
fake(DESC_B.clone(), 1.0, false),
fake(DESC_A.clone(), 1.0, false),
]);
assert_ne!(ab.structure_hash, ba.structure_hash);
}
@@ -1412,7 +1483,7 @@ mod tests {
// Exposure and the tonal controls act on white-balanced values; if
// the multiply came afterwards, every operation would be reasoning
// about a green-cast image.
let ops = vec![fake(&DESC_A, 2.0, false)];
let ops = vec![fake(DESC_A.clone(), 2.0, false)];
let source = compose(&ops).source;
let wb = source.find("u.as_shot_wb").expect("wb applied");
let op = source.find("---- op_a ----").expect("op present");
@@ -1472,7 +1543,7 @@ mod tests {
// The other half, and the one that would fail silently: a bug that
// suppressed the tail unconditionally renders every ordinary edit
// flat and uncorrected, which reads as a broken camera profile.
let source = compose(&[fake(&DESC_A, 2.0, false)]).source;
let source = compose(&[fake(DESC_A.clone(), 2.0, false)]).source;
assert!(source.contains("base_curve_last.z > 0.5"));
assert!(source.contains("Camera space -> linear sRGB"));
}
@@ -1484,7 +1555,7 @@ mod tests {
// stock loaded is the default state of every photograph in the
// catalogue, and it must not disturb the camera's own rendering.
let film: Box<dyn Operation> = Box::new(crate::ops::FilmSim::new());
let source = compose(&[film, fake(&DESC_A, 2.0, false)]).source;
let source = compose(&[film, fake(DESC_A.clone(), 2.0, false)]).source;
assert!(source.contains("base_curve_last.z > 0.5"));
assert!(source.contains("Camera space -> linear sRGB"));
}
@@ -1493,7 +1564,7 @@ mod tests {
fn the_camera_matrix_is_applied_after_the_operations() {
// Adjustments are meaningful in sensor-native space, where highlight
// headroom still exists; converting first would clip it away.
let ops = vec![fake(&DESC_A, 2.0, false)];
let ops = vec![fake(DESC_A.clone(), 2.0, false)];
let source = compose(&ops).source;
let op = source.find("---- op_a ----").expect("op present");
let matrix = source.find("u.cam_to_srgb_0").expect("matrix applied");
@@ -1514,7 +1585,7 @@ mod tests {
// Before the matrix: the curve was tuned against this body's own
// primaries. Applied after the conversion it would be a Canon
// rendering acting on sRGB values, which is a different curve.
let ops = vec![fake(&DESC_A, 2.0, false)];
let ops = vec![fake(DESC_A.clone(), 2.0, false)];
let source = compose(&ops).source;
let op = source.find("---- op_a ----").expect("op present");
let curve = source
@@ -1592,7 +1663,7 @@ mod tests {
// it must not pick up an identity matrix multiply for the sake of
// generality. Asserted against the source rather than against timing,
// which would not fail reliably.
let ops = vec![fake(&DESC_A, 1.0, false)];
let ops = vec![fake(DESC_A.clone(), 1.0, false)];
let srgb = compose_to(&ops, ColourSpace::Srgb).source;
assert!(
!srgb.contains("Linear sRGB -> linear sRGB"),
@@ -1607,7 +1678,7 @@ mod tests {
// in linear sRGB, the primaries conversion carries it into the wider
// space, and only then is it clipped — clipping first would discard
// exactly the colours the wider space was chosen to keep.
let source = compose_to(&[fake(&DESC_A, 1.0, false)], ColourSpace::DisplayP3).source;
let source = compose_to(&[fake(DESC_A.clone(), 1.0, false)], ColourSpace::DisplayP3).source;
let camera = source.find("u.cam_to_srgb_0").expect("camera matrix");
let convert = source
.find("Linear sRGB -> linear Display P3")
@@ -1659,7 +1730,7 @@ mod tests {
// an export that came out sRGB and claimed to be Display P3.
let mut seen: Vec<u64> = Vec::new();
for space in ColourSpace::ALL {
let h = compose_to(&[fake(&DESC_A, 1.0, false)], space).structure_hash;
let h = compose_to(&[fake(DESC_A.clone(), 1.0, false)], space).structure_hash;
assert!(!seen.contains(&h), "{space:?} collides with another space");
seen.push(h);
}
@@ -1669,7 +1740,7 @@ mod tests {
fn generated_source_carries_a_do_not_edit_banner() {
// Someone will eventually find this in a debugger and try to fix it
// in place.
let shader = compose(&[fake(&DESC_A, 1.0, false)]);
let shader = compose(&[fake(DESC_A.clone(), 1.0, false)]);
assert!(shader.source.starts_with("// GENERATED"));
}
+36 -33
View File
@@ -31,6 +31,7 @@
//! untouched means a mis-set correction shifts the channels that contribute
//! least to perceived sharpness. Scaling all three about a virtual reference
//! would soften the image even when the correction is right.
use std::sync::{Arc, LazyLock};
use crate::descriptor::{
Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Scale, Unit,
@@ -49,36 +50,38 @@ pub const BLUE: ParamId = ParamId("blue");
/// resolution left to tune by eye at 100%.
const MAX_SCALE: f32 = 0.005;
static DESCRIPTOR: OpDescriptor = OpDescriptor {
attributes: &[Attribute::Optics],
id: ID,
label: LocalizedKey("op.aberration"),
params: &[
// Two independent controls rather than one: the red and blue
// displacements are caused by different ends of the spectrum and are
// not symmetric, so a single "fringing" slider could not remove both.
ParamDescriptor::scalar(
"red",
"param.aberration.red",
-100.0,
100.0,
0.0,
Unit::None,
Scale::Linear,
0,
),
ParamDescriptor::scalar(
"blue",
"param.aberration.blue",
-100.0,
100.0,
0.0,
Unit::None,
Scale::Linear,
0,
),
],
};
static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
Arc::new(OpDescriptor {
attributes: vec![Attribute::Optics],
id: ID,
label: LocalizedKey("op.aberration"),
params: vec![
// Two independent controls rather than one: the red and blue
// displacements are caused by different ends of the spectrum and are
// not symmetric, so a single "fringing" slider could not remove both.
ParamDescriptor::scalar(
"red",
"param.aberration.red",
-100.0,
100.0,
0.0,
Unit::None,
Scale::Linear,
0,
),
ParamDescriptor::scalar(
"blue",
"param.aberration.blue",
-100.0,
100.0,
0.0,
Unit::None,
Scale::Linear,
0,
),
],
})
});
#[derive(Debug, Default, Clone)]
pub struct Aberration {
@@ -113,8 +116,8 @@ impl Aberration {
}
impl Warp for Aberration {
fn descriptor(&self) -> &'static OpDescriptor {
&DESCRIPTOR
fn descriptor(&self) -> Arc<OpDescriptor> {
DESCRIPTOR.clone()
}
fn set_param(&mut self, id: ParamId, value: f32) {
@@ -300,7 +303,7 @@ mod tests {
#[test]
fn every_default_is_neutral() {
let mut a = Aberration::new();
for p in DESCRIPTOR.params {
for p in &DESCRIPTOR.params {
a.set_param(p.id, p.default);
}
assert!(!a.is_active(), "descriptor defaults must be neutral");
+45 -42
View File
@@ -124,6 +124,7 @@
//! would be a guess dressed as a number; `resolves` is the line the stage
//! already draws, and drawing it in two places differently is worse than a
//! visible step.
use std::sync::{Arc, LazyLock};
use crate::descriptor::{
Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Scale, Unit,
@@ -163,46 +164,48 @@ const MAX_KERNEL: f32 = 48.0;
/// every editor's capture sharpening starts.
const DEFAULT_RADIUS: f32 = 1.0;
static DESCRIPTOR: OpDescriptor = OpDescriptor {
id: ID,
label: LocalizedKey("op.capture_sharpen"),
params: &[
// Amount carries the neutral, which is why it is first: the operation
// is off when this is zero regardless of the other two, so a reset is
// one control and the panel's ordering matches the way it is used.
ParamDescriptor::amount("amount", "param.amount"),
// In **source pixels** — see the module documentation. Half a photosite
// is the smallest radius that means anything on a Bayer sensor, and
// three is already past the point where an unsharp mask is sharpening
// rather than adding local contrast; a photographer wanting the latter
// wants clarity, which is a different operation with a different unit.
ParamDescriptor::scalar(
"radius",
"param.radius",
0.5,
3.0,
DEFAULT_RADIUS,
Unit::None,
Scale::Linear,
2,
),
// A fraction, but declared as a scalar rather than through
// `ParamDescriptor::fraction` for its precision alone: four decimal
// places on a control whose whole useful travel is a dozen steps
// reads as noise, and invites fiddling with digits that do nothing.
ParamDescriptor::scalar(
"threshold",
"param.threshold",
0.0,
1.0,
0.0,
Unit::None,
Scale::Linear,
2,
),
],
attributes: &[Attribute::Detail],
};
static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
Arc::new(OpDescriptor {
id: ID,
label: LocalizedKey("op.capture_sharpen"),
params: vec![
// Amount carries the neutral, which is why it is first: the operation
// is off when this is zero regardless of the other two, so a reset is
// one control and the panel's ordering matches the way it is used.
ParamDescriptor::amount("amount", "param.amount"),
// In **source pixels** — see the module documentation. Half a photosite
// is the smallest radius that means anything on a Bayer sensor, and
// three is already past the point where an unsharp mask is sharpening
// rather than adding local contrast; a photographer wanting the latter
// wants clarity, which is a different operation with a different unit.
ParamDescriptor::scalar(
"radius",
"param.radius",
0.5,
3.0,
DEFAULT_RADIUS,
Unit::None,
Scale::Linear,
2,
),
// A fraction, but declared as a scalar rather than through
// `ParamDescriptor::fraction` for its precision alone: four decimal
// places on a control whose whole useful travel is a dozen steps
// reads as noise, and invites fiddling with digits that do nothing.
ParamDescriptor::scalar(
"threshold",
"param.threshold",
0.0,
1.0,
0.0,
Unit::None,
Scale::Linear,
2,
),
],
attributes: vec![Attribute::Detail],
})
});
/// TRACES: FR-DEV-3
/// Capture sharpening: a separable unsharp mask with a contrast threshold.
@@ -275,8 +278,8 @@ impl CaptureSharpen {
}
impl Operation for CaptureSharpen {
fn descriptor(&self) -> &'static OpDescriptor {
&DESCRIPTOR
fn descriptor(&self) -> Arc<OpDescriptor> {
DESCRIPTOR.clone()
}
fn set_param(&mut self, id: ParamId, value: f32) {
+31 -27
View File
@@ -29,6 +29,7 @@
//! the band acts at full strength right up to a hard edge; and with two bands
//! adjusted, each one's share depends on what the other is set to, so turning
//! up one colour's saturation quietly weakened its neighbour's hue shift.
use std::sync::{Arc, LazyLock};
use crate::descriptor::{
Attribute, Facet, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId,
@@ -123,8 +124,9 @@ impl Channel {
}
// Parameter descriptors, one per band per channel. Written out rather than
// generated because `ParamDescriptor` must be `const` to live in a `static`,
// and a const loop cannot build a slice. The macro keeps it honest.
// looped because `concat!` needs literals: every id is built from its band's
// key, and a runtime loop has no way to spell `orange_sat`. The macro keeps it
// honest.
//
// **Every one of them is faceted**, and that is what makes the operation
// legible in a panel. Thirty-six parameters presented as a flat list are
@@ -137,7 +139,7 @@ impl Channel {
// §4.3a); this only says the parameter acts on the band centred there.
macro_rules! band_params {
($(($key:literal, $hue:literal)),* $(,)?) => {
&[
vec![
$(
ParamDescriptor::amount(
concat!($key, "_hue"),
@@ -175,25 +177,27 @@ macro_rules! band_params {
// the same reason as those: `concat!` needs literals, so the keys and hues
// cannot be read out of `BANDS` here. `facets_match_their_bands` below is
// what keeps them from drifting.
static DESCRIPTOR: OpDescriptor = OpDescriptor {
attributes: &[Attribute::Colour],
id: ID,
label: LocalizedKey("op.colour_mixer"),
params: band_params![
("red", 0.0),
("orange", 30.0),
("yellow", 60.0),
("chartreuse", 90.0),
("green", 120.0),
("spring", 150.0),
("cyan", 180.0),
("azure", 210.0),
("blue", 240.0),
("violet", 270.0),
("magenta", 300.0),
("rose", 330.0),
],
};
static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
Arc::new(OpDescriptor {
attributes: vec![Attribute::Colour],
id: ID,
label: LocalizedKey("op.colour_mixer"),
params: band_params![
("red", 0.0),
("orange", 30.0),
("yellow", 60.0),
("chartreuse", 90.0),
("green", 120.0),
("spring", 150.0),
("cyan", 180.0),
("azure", 210.0),
("blue", 240.0),
("violet", 270.0),
("magenta", 300.0),
("rose", 330.0),
],
})
});
static MIXER_HELPERS: &[Helper] = &[
helpers::LUMINANCE,
@@ -306,8 +310,8 @@ impl ColourMixer {
}
impl Operation for ColourMixer {
fn descriptor(&self) -> &'static OpDescriptor {
&DESCRIPTOR
fn descriptor(&self) -> Arc<OpDescriptor> {
DESCRIPTOR.clone()
}
fn set_param(&mut self, id: ParamId, value: f32) {
@@ -505,7 +509,7 @@ mod tests {
fn every_descriptor_id_resolves_to_a_band_and_channel() {
// The link between the descriptor list and the value array. A
// mismatch would make a slider silently adjust nothing.
for p in DESCRIPTOR.params {
for p in &DESCRIPTOR.params {
assert!(
ColourMixer::index_of(p.id).is_some(),
"{} does not map to a band",
@@ -547,7 +551,7 @@ mod tests {
// and a hue mistyped there would put a row's swatch on a colour the
// band does not act on — a control that lies about what it edits,
// which is worse than one with no swatch at all.
for p in DESCRIPTOR.params {
for p in &DESCRIPTOR.params {
let facet = p.facet.expect("every mixer parameter is faceted");
let (band_key, _) = p.id.0.rsplit_once('_').expect("id is band_channel");
let band = BANDS
@@ -577,7 +581,7 @@ mod tests {
// the aspect keyed per band, grouping by it would produce thirty-six
// groups of one and nothing would have been gained.
let mut per_aspect = std::collections::BTreeMap::new();
for p in DESCRIPTOR.params {
for p in &DESCRIPTOR.params {
let facet = p.facet.expect("faceted");
*per_aspect.entry(facet.aspect.0).or_insert(0) += 1;
}
+34 -30
View File
@@ -79,6 +79,7 @@
//! whole composition scheme rests on (ARCH §5.6).
use std::fmt::Write as _;
use std::sync::{Arc, LazyLock};
use crate::descriptor::{
Attribute, Facet, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Presentation,
@@ -341,11 +342,12 @@ const fn facet_of(aspect: &'static str, channel: Channel) -> Facet {
/// One channel's ten descriptors, defaulted onto the identity diagonal.
///
/// Written out per point rather than looped because a `ParamDescriptor` has to
/// be `const` to live in a `static`, and a const loop cannot build a slice.
/// Written out per point rather than looped because `concat!` needs literals:
/// the parameter ids are built from the channel's prefix, and a runtime loop
/// has no way to spell `r_p0_x`.
macro_rules! channel_params {
($(($prefix:literal, $channel:expr)),* $(,)?) => {
&[$(
vec![$(
coord(concat!($prefix, "p0_x"), "param.curve.p0_x", 0.0)
.faceted(facet_of("param.curve.p0_x", $channel)),
coord(concat!($prefix, "p0_y"), "param.curve.p0_y", 0.0)
@@ -370,26 +372,28 @@ macro_rules! channel_params {
};
}
static DESCRIPTOR: OpDescriptor = OpDescriptor {
// Both, and this is the case the plural exists for: the master curve is
// tonal and the per-channel curves are chromatic. Filing it under one
// would hide it from half the people looking for it.
attributes: &[Attribute::Tone, Attribute::Colour],
id: ID,
label: LocalizedKey("op.tone_curve"),
// Defaults lie on y = x, so a fresh curve is the identity and the
// operation reports itself inactive — on every channel.
//
// The master's ten come first, and stay first: a frontend addresses a
// point by its offset from the first parameter of the run it is drawing,
// and this is also the order one falling back to sliders reads them in.
params: channel_params![
("", Channel::Master),
("r_", Channel::Red),
("g_", Channel::Green),
("b_", Channel::Blue),
],
};
static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
Arc::new(OpDescriptor {
// Both, and this is the case the plural exists for: the master curve is
// tonal and the per-channel curves are chromatic. Filing it under one
// would hide it from half the people looking for it.
attributes: vec![Attribute::Tone, Attribute::Colour],
id: ID,
label: LocalizedKey("op.tone_curve"),
// Defaults lie on y = x, so a fresh curve is the identity and the
// operation reports itself inactive — on every channel.
//
// The master's ten come first, and stay first: a frontend addresses a
// point by its offset from the first parameter of the run it is drawing,
// and this is also the order one falling back to sliders reads them in.
params: channel_params![
("", Channel::Master),
("r_", Channel::Red),
("g_", Channel::Green),
("b_", Channel::Blue),
],
})
});
/// One span of a monotone cubic Hermite spline. Shared by all four curves.
const CURVE_SPAN: Helper = Helper {
@@ -699,8 +703,8 @@ impl ToneCurve {
}
impl Operation for ToneCurve {
fn descriptor(&self) -> &'static OpDescriptor {
&DESCRIPTOR
fn descriptor(&self) -> Arc<OpDescriptor> {
DESCRIPTOR.clone()
}
fn set_param(&mut self, id: ParamId, value: f32) {
@@ -727,7 +731,7 @@ impl Operation for ToneCurve {
Some(Presentation {
// One entry: there is no second way to draw a tone curve that is
// better than the sliders the frontend falls back to anyway.
widgets: &[WidgetKind::ToneCurve],
widgets: vec![WidgetKind::ToneCurve],
demand: WidgetDemand {
// A point is dragged in x and y together — that is what a
// curve *is*, and a frontend that can only move one axis at a
@@ -739,7 +743,7 @@ impl Operation for ToneCurve {
// leave the other thirty stranded as sliders beneath the plot;
// which of the four it draws at a time is its own affair, and the
// facets are what let it decide without naming a channel.
params: &CURVE_PARAMS,
params: CURVE_PARAMS.to_vec(),
})
}
@@ -910,7 +914,7 @@ mod tests {
// Opening an unedited image must show the image.
let c = ToneCurve::new();
assert!(!c.is_active());
for p in DESCRIPTOR.params {
for p in &DESCRIPTOR.params {
assert_eq!(c.param(p.id), p.default);
}
}
@@ -927,7 +931,7 @@ mod tests {
#[test]
fn every_parameter_id_maps_to_a_point() {
for p in DESCRIPTOR.params {
for p in &DESCRIPTOR.params {
assert!(
ToneCurve::index_of(p.id).is_some(),
"{} does not map to a point",
@@ -1196,7 +1200,7 @@ mod tests {
);
assert_eq!(presentation.choose(|_| false), None);
assert_eq!(presentation.params.len(), DESCRIPTOR.params.len());
for p in DESCRIPTOR.params {
for p in &DESCRIPTOR.params {
assert!(
presentation.params.contains(&p.id),
"{} is not owned by the widget",
+24 -21
View File
@@ -25,6 +25,7 @@
//! set three correlated coefficients, and hand-correcting a lens with no
//! profile is a "make the horizon straight" task, which one term does well.
//! The full triple is reachable by loading a profile.
use std::sync::{Arc, LazyLock};
use crate::descriptor::{
Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Scale, Unit,
@@ -35,25 +36,27 @@ use crate::operation::{Helper, Uniform};
pub const ID: OpId = OpId("distortion");
pub const AMOUNT: ParamId = ParamId("amount");
static DESCRIPTOR: OpDescriptor = OpDescriptor {
attributes: &[Attribute::Optics],
id: ID,
label: LocalizedKey("op.distortion"),
// ±100 maps to a ±0.25 cubic coefficient. That covers an uncorrected
// fisheye at one end and strong pincushion at the other; beyond it the
// inverse mapping stops being single-valued near the corners and the
// correction folds the image over itself.
params: &[ParamDescriptor::scalar(
"amount",
"param.distortion.amount",
-100.0,
100.0,
0.0,
Unit::None,
Scale::Linear,
0,
)],
};
static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
Arc::new(OpDescriptor {
attributes: vec![Attribute::Optics],
id: ID,
label: LocalizedKey("op.distortion"),
// ±100 maps to a ±0.25 cubic coefficient. That covers an uncorrected
// fisheye at one end and strong pincushion at the other; beyond it the
// inverse mapping stops being single-valued near the corners and the
// correction folds the image over itself.
params: vec![ParamDescriptor::scalar(
"amount",
"param.distortion.amount",
-100.0,
100.0,
0.0,
Unit::None,
Scale::Linear,
0,
)],
})
});
/// The cubic coefficient at full slider travel.
const MAX_COEFF: f32 = 0.25;
@@ -108,8 +111,8 @@ impl Distortion {
}
impl Warp for Distortion {
fn descriptor(&self) -> &'static OpDescriptor {
&DESCRIPTOR
fn descriptor(&self) -> Arc<OpDescriptor> {
DESCRIPTOR.clone()
}
fn set_param(&mut self, id: ParamId, value: f32) {
+28 -25
View File
@@ -29,6 +29,7 @@
//! Declared as a plain struct here rather than imported, so that dr-pipeline
//! keeps its no-dependency property (ARCH §6.5a) exactly as `vignetting` does
//! with `Pa`.
use std::sync::{Arc, LazyLock};
use crate::descriptor::{Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId};
use crate::operation::{Operation, Uniform};
@@ -73,29 +74,31 @@ static MATRIX_FIELDS: [[&str; 3]; 3] = [
["m20", "m21", "m22"],
];
static DESCRIPTOR: OpDescriptor = OpDescriptor {
// Tone and colour both, and not `Effect`: a stock is not something applied
// on top of a photograph, it is what the photograph was made on.
attributes: &[Attribute::Tone, Attribute::Colour],
id: ID,
label: LocalizedKey("op.film_sim"),
params: &[
ParamDescriptor::stops("exposure", "param.film_sim.exposure", -3.0, 3.0),
ParamDescriptor::stops("print_exposure", "param.film_sim.print_exposure", -3.0, 3.0),
// TRACES: FR-DEV-3f
// Development, in stops of push. Bounded by what the manufacturers
// actually published: Double-X's measured axis spans about -1 to +2,
// and beyond a range like that a curve would have to be invented.
ParamDescriptor::stops("push", "param.film_sim.push", -1.0, 3.0),
// TRACES: FR-DEV-3f
// Which frame this was taken on — the half of the enlargement a
// photograph cannot supply. A crystal is a fixed size in micrometres,
// so how grainy a picture looks is film size against output size, and
// the same emulsion on 4x5 renders about three times smoother than on
// 35mm at the same print.
ParamDescriptor::choice("format", "param.film_sim.format", &FORMATS),
],
};
static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
Arc::new(OpDescriptor {
// Tone and colour both, and not `Effect`: a stock is not something applied
// on top of a photograph, it is what the photograph was made on.
attributes: vec![Attribute::Tone, Attribute::Colour],
id: ID,
label: LocalizedKey("op.film_sim"),
params: vec![
ParamDescriptor::stops("exposure", "param.film_sim.exposure", -3.0, 3.0),
ParamDescriptor::stops("print_exposure", "param.film_sim.print_exposure", -3.0, 3.0),
// TRACES: FR-DEV-3f
// Development, in stops of push. Bounded by what the manufacturers
// actually published: Double-X's measured axis spans about -1 to +2,
// and beyond a range like that a curve would have to be invented.
ParamDescriptor::stops("push", "param.film_sim.push", -1.0, 3.0),
// TRACES: FR-DEV-3f
// Which frame this was taken on — the half of the enlargement a
// photograph cannot supply. A crystal is a fixed size in micrometres,
// so how grainy a picture looks is film size against output size, and
// the same emulsion on 4x5 renders about three times smoother than on
// 35mm at the same print.
ParamDescriptor::choice("format", "param.film_sim.format", FORMATS.to_vec()),
],
})
});
/// A stock reduced to what a shader runs, as `dr-film` bakes it.
///
@@ -190,8 +193,8 @@ impl FilmSim {
}
impl Operation for FilmSim {
fn descriptor(&self) -> &'static OpDescriptor {
&DESCRIPTOR
fn descriptor(&self) -> Arc<OpDescriptor> {
DESCRIPTOR.clone()
}
fn set_param(&mut self, id: ParamId, value: f32) {
+25 -15
View File
@@ -153,6 +153,7 @@
//! this file.
use std::marker::PhantomData;
use std::sync::{Arc, LazyLock};
use crate::descriptor::{Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId};
use crate::detail::{DetailPass, DetailStage, RenderScale};
@@ -181,7 +182,12 @@ const TRUNCATION: f32 = 2.0;
/// between clarity and texture can be read side by side, which is the one
/// thing a reader comes to this file to do.
pub struct Recipe {
descriptor: &'static OpDescriptor,
/// The static this operation's descriptor is built in. A `LazyLock`
/// rather than a reference to a descriptor, because a descriptor is an
/// owned value handed out as an `Arc` now (FR-PLG-2), and a `const`
/// recipe cannot hold an `Arc` — only a reference to the static that
/// makes one.
descriptor: &'static LazyLock<Arc<OpDescriptor>>,
helpers: &'static [Helper],
/// The Gaussian's σ, as a fraction of the frame's shorter edge.
sigma: f32,
@@ -253,19 +259,23 @@ impl Band for Fine {
};
}
static CLARITY_DESCRIPTOR: OpDescriptor = OpDescriptor {
id: CLARITY,
label: LocalizedKey("op.clarity"),
params: &[ParamDescriptor::amount("amount", "param.clarity.amount")],
attributes: &[Attribute::Detail],
};
static CLARITY_DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
Arc::new(OpDescriptor {
id: CLARITY,
label: LocalizedKey("op.clarity"),
params: vec![ParamDescriptor::amount("amount", "param.clarity.amount")],
attributes: vec![Attribute::Detail],
})
});
static TEXTURE_DESCRIPTOR: OpDescriptor = OpDescriptor {
id: TEXTURE,
label: LocalizedKey("op.texture"),
params: &[ParamDescriptor::amount("amount", "param.texture.amount")],
attributes: &[Attribute::Detail],
};
static TEXTURE_DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
Arc::new(OpDescriptor {
id: TEXTURE,
label: LocalizedKey("op.texture"),
params: vec![ParamDescriptor::amount("amount", "param.texture.amount")],
attributes: vec![Attribute::Detail],
})
});
/// Luminance as a position on a logarithmic scale, floored.
///
@@ -394,8 +404,8 @@ impl<B: Band> LocalContrast<B> {
}
impl<B: Band> Operation for LocalContrast<B> {
fn descriptor(&self) -> &'static OpDescriptor {
B::RECIPE.descriptor
fn descriptor(&self) -> Arc<OpDescriptor> {
Arc::clone(B::RECIPE.descriptor)
}
fn set_param(&mut self, _id: ParamId, value: f32) {
+1 -1
View File
@@ -179,7 +179,7 @@ mod tests {
// perfectly and silently breaks the sidecar.
for mut op in chain() {
let descriptor = op.descriptor();
for p in descriptor.params {
for p in &descriptor.params {
let crate::descriptor::ParamKind::Scalar { min, max, .. } = p.kind else {
continue;
};
+37 -34
View File
@@ -147,6 +147,7 @@
//! lie. The chroma radius, ten times larger, still resolves — which is also
//! true of the fault it treats, since a blotch twenty pixels across survives
//! being halved.
use std::sync::{Arc, LazyLock};
use crate::descriptor::{
Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Scale, Unit,
@@ -158,38 +159,40 @@ pub const ID: OpId = OpId("noise_reduction");
pub const LUMINANCE: ParamId = ParamId("luminance");
pub const CHROMA: ParamId = ParamId("chroma");
static DESCRIPTOR: OpDescriptor = OpDescriptor {
id: ID,
label: LocalizedKey("op.noise_reduction"),
attributes: &[Attribute::Detail],
// Zero to a hundred rather than the symmetric `amount` shape the tonal
// controls use. There is no meaningful negative: "minus fifty noise
// reduction" would be adding grain, which is a look rather than a repair
// and belongs to a different operation carrying `Attribute::Effect`. A
// control whose left half does nothing is worse than one that stops.
params: &[
ParamDescriptor::scalar(
"luminance",
"param.noise_reduction.luminance",
0.0,
100.0,
0.0,
Unit::None,
Scale::Linear,
0,
),
ParamDescriptor::scalar(
"chroma",
"param.noise_reduction.chroma",
0.0,
100.0,
0.0,
Unit::None,
Scale::Linear,
0,
),
],
};
static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
Arc::new(OpDescriptor {
id: ID,
label: LocalizedKey("op.noise_reduction"),
attributes: vec![Attribute::Detail],
// Zero to a hundred rather than the symmetric `amount` shape the tonal
// controls use. There is no meaningful negative: "minus fifty noise
// reduction" would be adding grain, which is a look rather than a repair
// and belongs to a different operation carrying `Attribute::Effect`. A
// control whose left half does nothing is worse than one that stops.
params: vec![
ParamDescriptor::scalar(
"luminance",
"param.noise_reduction.luminance",
0.0,
100.0,
0.0,
Unit::None,
Scale::Linear,
0,
),
ParamDescriptor::scalar(
"chroma",
"param.noise_reduction.chroma",
0.0,
100.0,
0.0,
Unit::None,
Scale::Linear,
0,
),
],
})
});
/// The luminance radius at the lowest and the highest amount, in **source**
/// pixels.
@@ -365,8 +368,8 @@ fn inv_spatial(kernel: u32) -> f32 {
}
impl Operation for NoiseReduction {
fn descriptor(&self) -> &'static OpDescriptor {
&DESCRIPTOR
fn descriptor(&self) -> Arc<OpDescriptor> {
DESCRIPTOR.clone()
}
fn set_param(&mut self, id: ParamId, value: f32) {
+19 -16
View File
@@ -32,6 +32,7 @@
//! division is the whole reason this operation must run before the tonal
//! stages: a corner recovered by two stops has to be recovered while the
//! highlight headroom to hold it still exists (ARCH §5.2).
use std::sync::{Arc, LazyLock};
use crate::descriptor::{Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId};
use crate::operation::{Helper, Operation, Uniform};
@@ -45,19 +46,21 @@ pub const AMOUNT: ParamId = ParamId("amount");
/// fast prime wide open — the case that actually needs correcting.
const MAX_K1: f32 = -0.5;
static DESCRIPTOR: OpDescriptor = OpDescriptor {
// Optics rather than effect: this carries lens-profile coefficients
// and corrects what the lens did. A *creative* vignette is a different
// operation that does not exist yet, and would be `Effect`.
attributes: &[Attribute::Optics],
id: ID,
label: LocalizedKey("op.vignetting"),
// Bidirectional deliberately. Negative values *add* falloff, which is a
// legitimate creative choice as well as a correction, and a control that
// only removed vignetting would need a second one beside it to put any
// back.
params: &[ParamDescriptor::amount("amount", "param.vignetting.amount")],
};
static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
Arc::new(OpDescriptor {
// Optics rather than effect: this carries lens-profile coefficients
// and corrects what the lens did. A *creative* vignette is a different
// operation that does not exist yet, and would be `Effect`.
attributes: vec![Attribute::Optics],
id: ID,
label: LocalizedKey("op.vignetting"),
// Bidirectional deliberately. Negative values *add* falloff, which is a
// legitimate creative choice as well as a correction, and a control that
// only removed vignetting would need a second one beside it to put any
// back.
params: vec![ParamDescriptor::amount("amount", "param.vignetting.amount")],
})
});
/// The `pa` polynomial coefficients, as Lensfun stores them.
#[derive(Debug, Clone, Copy, PartialEq)]
@@ -107,8 +110,8 @@ impl Vignetting {
}
impl Operation for Vignetting {
fn descriptor(&self) -> &'static OpDescriptor {
&DESCRIPTOR
fn descriptor(&self) -> Arc<OpDescriptor> {
DESCRIPTOR.clone()
}
fn set_param(&mut self, id: ParamId, value: f32) {
@@ -371,7 +374,7 @@ mod tests {
#[test]
fn every_default_is_neutral() {
let mut v = Vignetting::new();
for p in DESCRIPTOR.params {
for p in &DESCRIPTOR.params {
v.set_param(p.id, p.default);
}
assert!(!v.is_active());
+11 -2
View File
@@ -1297,8 +1297,17 @@ impl PartialMask {
.ops
.iter()
.find(|o| o.descriptor().id.0 == op)
.and_then(|o| o.descriptor().params.iter().find(|p| p.id.0 == param))
.map(|p| p.id)
// The descriptor is bound inside the closure rather than
// chained through: it is an owned `Arc` now, so a
// `ParamDescriptor` borrowed out of it would not outlive the
// expression. The `ParamId` is `Copy`, so it does.
.and_then(|o| {
o.descriptor()
.params
.iter()
.find(|p| p.id.0 == param)
.map(|p| p.id)
})
else {
log::warn!("sidecar: unknown mask parameter {op}.{param}; ignoring");
continue;
+435
View File
@@ -0,0 +1,435 @@
//! TRACES: FR-PLG-2
//! The generated path and the interpreted path produce the same operation.
//!
//! # Why this test is the point
//!
//! 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. Two
//! implementations sit behind that claim: `build.rs` compiles `ops/*.yaml`
//! into Rust, and [`DeclaredOp`] interprets the same declaration at run time.
//!
//! **If the two ever disagree, the claim fails quietly.** A plugin would be a
//! second-class kind of node — one whose colours come out fractionally
//! different, or whose fragment lands in the shader with a different comment,
//! or whose uniform arrives in a different slot — and nothing would say so.
//! The photographer would see a look they could not reproduce with a built-in
//! and would have no way to find out why.
//!
//! So this parses every built-in declaration at run time and asserts the
//! composed WGSL is **byte for byte** what the generated implementation
//! produces, with the uniform block bit for bit identical, at a spread of
//! parameter values.
//!
//! # Byte-for-byte, and bit-for-bit, on purpose
//!
//! Not "equivalent", not "within an epsilon". A tolerance is where a real
//! divergence hides: the arithmetic in a declaration is `f32` at both ends and
//! there is no reason for a single bit to differ, so any difference at all is
//! a bug in one of the two backends and should read as one. The one place
//! this bites is number literals, which is why `expr::as_f32` rounds a decimal
//! exactly once — see its own documentation.
//!
//! # What is not covered, and why that is honest
//!
//! A `rust:` node — `tone_curve`, `colour_mixer`, `film_sim`,
//! `capture_sharpen`, `noise_reduction`, `clarity`, `texture` — names a
//! hand-written type and has no declaration to interpret. It is not skipped
//! silently: [`every_declared_node_is_checked`] asserts the two sets partition
//! `ops/` between them, so a node that stops being declared cannot quietly
//! drop out of this file's coverage.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use dr_pipeline::declared::{builtin_helpers, decl, DeclaredOp, Node};
use dr_pipeline::descriptor::ParamKind;
use dr_pipeline::operation::{compose, ComposedShader, Operation};
use dr_pipeline::ops;
use dr_pipeline::ParamId;
/// The declarations this crate ships, read from disk rather than embedded.
///
/// From disk deliberately: `build.rs` reads these very files, so reading the
/// same bytes is what makes the comparison a comparison of the two *readers*
/// rather than of two snapshots that were taken at different times.
fn ops_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("ops")
}
/// Every `<id>.yaml` in `ops/`, keyed by id, excluding `_helpers.yaml`.
fn declaration_files() -> BTreeMap<String, String> {
let mut out = BTreeMap::new();
for entry in std::fs::read_dir(ops_dir()).expect("ops/ is readable") {
let path = entry.expect("a directory entry").path();
if path.extension().and_then(|e| e.to_str()) != Some("yaml") {
continue;
}
let stem = path
.file_stem()
.and_then(|s| s.to_str())
.expect("a file name")
.to_string();
if stem.starts_with('_') {
continue;
}
out.insert(stem, std::fs::read_to_string(&path).expect("readable"));
}
assert!(!out.is_empty(), "ops/ declares no nodes");
out
}
/// Parse one declaration the way both backends do.
fn read(id: &str, text: &str) -> Node {
let library = builtin_helpers();
decl::read_node(text, &format!("ops/{id}.yaml"), &library.names())
.unwrap_or_else(|e| panic!("ops/{id}.yaml: {e}"))
}
/// The generated implementation of one node, taken out of the default chain.
///
/// Out of `chain()` rather than constructed by name, because that is how the
/// application gets one: if the two ever differed, the chain's version is the
/// one a photograph would be developed with.
fn generated(id: &str) -> Box<dyn Operation> {
let chain = ops::chain();
let index = chain
.iter()
.position(|o| o.descriptor().id.0 == id)
.unwrap_or_else(|| panic!("`{id}` is not in the default chain"));
let mut chain = chain;
chain.remove(index)
}
/// Values worth setting a parameter to, spanning its declared range.
///
/// Both ends, because that is where a rounding difference in a `min:` or a
/// `max:` would show; the default, because that is the neutral every
/// `is_active` is written against; and two interior points that are not
/// round numbers, because a value like 37.5 exercises the arithmetic in a way
/// 0 and 100 do not.
fn probe_values(kind: &ParamKind, default: f32) -> Vec<f32> {
match kind {
ParamKind::Scalar { min, max, .. } => {
let span = max - min;
vec![
default,
*min,
*max,
min + span * 0.375,
min + span * 0.8125,
// A value that is not representable as a short decimal, to
// catch a backend that round-trips a uniform through text.
min + span / 3.0,
]
}
ParamKind::Bool => vec![0.0, 1.0],
ParamKind::Enum { variants } => (0..variants.len()).map(|i| i as f32).collect(),
}
}
/// Put every parameter back where it started.
fn reset(op: &mut dyn Operation) {
let descriptor = op.descriptor();
for p in &descriptor.params {
op.set_param(p.id, p.default);
}
}
/// Assert the two operations are indistinguishable at their current settings.
fn assert_same(id: &str, setting: &str, generated: &dyn Operation, declared: &dyn Operation) {
let g = generated.descriptor();
let d = declared.descriptor();
assert_eq!(*g, *d, "{id} [{setting}]: the descriptors differ");
assert_eq!(
generated.is_active(),
declared.is_active(),
"{id} [{setting}]: the two disagree about whether the operation is doing anything"
);
assert_eq!(
generated.wgsl_body(),
declared.wgsl_body(),
"{id} [{setting}]: the fragment bodies differ"
);
assert_eq!(
generated.helpers(),
declared.helpers(),
"{id} [{setting}]: the helper sets differ"
);
let (gu, du) = (generated.uniforms(), declared.uniforms());
assert_eq!(
gu.len(),
du.len(),
"{id} [{setting}]: different numbers of uniforms"
);
for (a, b) in gu.iter().zip(&du) {
assert_eq!(
a.name, b.name,
"{id} [{setting}]: uniforms in a different order"
);
// Bits, not values: `assert_eq!` on `f32` would call two NaNs unequal
// and would call `0.0` and `-0.0` equal, and both of those are
// differences worth failing on.
assert_eq!(
a.value.to_bits(),
b.value.to_bits(),
"{id} [{setting}]: uniform `{}` is {} generated and {} declared",
a.name,
a.value,
b.value
);
}
}
/// Assert two compositions are the same shader.
fn assert_same_shader(what: &str, a: &ComposedShader, b: &ComposedShader) {
// The source first, and compared as whole strings: a diff in the middle of
// several kilobytes of WGSL is unreadable as an assertion message, so the
// failure below points at the first differing line instead.
if a.source != b.source {
let line = a
.source
.lines()
.zip(b.source.lines())
.position(|(x, y)| x != y);
match line {
Some(n) => panic!(
"{what}: the composed WGSL differs at line {}:\n generated: {:?}\n declared: {:?}",
n + 1,
a.source.lines().nth(n).unwrap_or(""),
b.source.lines().nth(n).unwrap_or(""),
),
None => panic!(
"{what}: the composed WGSL differs in length: {} generated, {} declared",
a.source.len(),
b.source.len()
),
}
}
assert_eq!(
a.uniforms.len(),
b.uniforms.len(),
"{what}: different uniform block sizes"
);
for (i, (x, y)) in a.uniforms.iter().zip(&b.uniforms).enumerate() {
assert_eq!(
x.to_bits(),
y.to_bits(),
"{what}: uniform slot {i} is {x} generated and {y} declared"
);
}
// The hash is taken over the source, so it follows — but it is what the
// pipeline cache keys on, and asserting it says that a declared node and
// its generated twin would share a compiled pipeline rather than quietly
// splitting the cache in two.
assert_eq!(a.structure_hash, b.structure_hash, "{what}: structure hash");
assert_eq!(a.output_mode, b.output_mode, "{what}: output mode");
}
/// Compose a single operation, the way the display path composes a chain.
fn compose_one(op: Box<dyn Operation>) -> (ComposedShader, Box<dyn Operation>) {
let shader = compose(std::slice::from_ref(&op));
(shader, op)
}
/// TRACES: FR-PLG-2
/// Every declared built-in composes to the same shader either way.
#[test]
fn a_declaration_read_at_run_time_composes_byte_for_byte_as_the_generated_one() {
let library = builtin_helpers();
let mut checked = 0;
for (id, text) in declaration_files() {
let Node::Declared(declaration) = read(&id, &text) else {
continue;
};
let mut declared =
DeclaredOp::new(&declaration, library).unwrap_or_else(|e| panic!("ops/{id}.yaml: {e}"));
let mut generated = generated(&id);
// The neutral state first: it is the state every image starts in, and
// an operation that is inactive in one path and active in the other
// would put a whole fragment into one shader and not the other.
assert_same(&id, "neutral", generated.as_ref(), &declared);
let descriptor = declared.descriptor();
for p in &descriptor.params {
for value in probe_values(&p.kind, p.default) {
reset(generated.as_mut());
reset(&mut declared);
generated.set_param(p.id, value);
declared.set_param(p.id, value);
let setting = format!("{} = {value}", p.id);
assert_same(&id, &setting, generated.as_ref(), &declared);
let (gs, back) = compose_one(generated);
generated = back;
let (ds, _) = compose_one(Box::new(declared.clone()));
assert_same_shader(&format!("{id} [{setting}]"), &gs, &ds);
checked += 1;
}
}
// And every parameter moved at once, which is the only case that
// exercises the *order* uniforms are emitted in.
reset(generated.as_mut());
reset(&mut declared);
for p in &descriptor.params {
let value =
probe_values(&p.kind, p.default)[3.min(probe_values(&p.kind, p.default).len() - 1)];
generated.set_param(p.id, value);
declared.set_param(p.id, value);
}
assert_same(&id, "all parameters moved", generated.as_ref(), &declared);
let (gs, _) = compose_one(generated);
let (ds, _) = compose_one(Box::new(declared));
assert_same_shader(&format!("{id} [all parameters moved]"), &gs, &ds);
checked += 1;
}
// A test that silently checked nothing would pass forever. There are eight
// declared nodes and several settings each, so this is a floor rather than
// a count anybody has to maintain.
assert!(
checked > 20,
"only {checked} comparisons ran; the declarations were not found"
);
}
/// TRACES: FR-PLG-2
/// The whole chain composes identically with the declared nodes swapped in.
///
/// The single-operation test above is the sharper one — it isolates each node
/// — but it cannot see an interaction. This composes the *default develop
/// chain*, with every declared node replaced by its interpreted twin and the
/// `rust:` nodes left alone, so it covers uniform slot ordering across
/// operations, helper de-duplication between them, and the order the fragments
/// land in the shader.
#[test]
fn the_whole_chain_composes_identically_with_interpreted_nodes() {
let library = builtin_helpers();
let files = declaration_files();
let mut generated_chain = ops::chain();
let mut declared_chain = ops::chain();
let mut swapped = 0;
for i in 0..declared_chain.len() {
let id = declared_chain[i].descriptor().id.0.to_string();
let text = files
.get(&id)
.unwrap_or_else(|| panic!("`{id}` is in the chain but has no ops/{id}.yaml"));
if let Node::Declared(declaration) = read(&id, text) {
declared_chain[i] = Box::new(
DeclaredOp::new(&declaration, library)
.unwrap_or_else(|e| panic!("ops/{id}.yaml: {e}")),
);
swapped += 1;
}
// Move every operation off neutral, declared or not, so the chain is
// not a list of fragments that were all omitted. A neutral chain
// composes to a shader with no operation blocks in it at all, which
// would make this test pass while asserting nothing.
let descriptor = generated_chain[i].descriptor();
for p in &descriptor.params {
let value = probe_values(&p.kind, p.default)[1];
generated_chain[i].set_param(p.id, value);
declared_chain[i].set_param(p.id, value);
}
}
assert!(
swapped >= 8,
"only {swapped} nodes were swapped for declared ones"
);
assert_same_shader(
"the default chain",
&compose(&generated_chain),
&compose(&declared_chain),
);
}
/// TRACES: FR-PLG-2
/// Nothing in `ops/` escapes this file unnoticed.
///
/// The coverage guard. A declaration that stopped parsing, or a node that
/// quietly became `rust:`, would otherwise reduce what the parity test covers
/// without anything failing — which is exactly the silent divergence the whole
/// file exists to prevent.
#[test]
fn every_declared_node_is_checked() {
let files = declaration_files();
let mut declared = Vec::new();
let mut hand_written = Vec::new();
for (id, text) in &files {
match read(id, text) {
Node::Declared(_) => declared.push(id.clone()),
Node::Rust { ty, .. } => hand_written.push((id.clone(), ty)),
}
}
// Every file is one or the other, and the chain holds exactly them.
assert_eq!(declared.len() + hand_written.len(), files.len());
assert_eq!(
ops::DECLARED_IDS.len(),
files.len(),
"the chain and ops/ hold different numbers of nodes"
);
for id in ops::DECLARED_IDS {
assert!(
files.contains_key(*id),
"`{id}` is in the chain but not in ops/"
);
}
// Named rather than counted, so that a node changing sides is a failure
// somebody reads rather than a number they update.
let hand: Vec<&str> = hand_written.iter().map(|(id, _)| id.as_str()).collect();
assert_eq!(
hand,
[
"capture_sharpen",
"clarity",
"colour_mixer",
"film_sim",
"noise_reduction",
"texture",
"tone_curve",
],
"the set of hand-written nodes changed; if that is deliberate, update \
this list and the module documentation above"
);
assert!(
declared.len() >= 8,
"only {} declared nodes: {declared:?}",
declared.len()
);
}
/// TRACES: FR-PLG-2
/// A declared operation is addressed by the ids the generated one uses.
///
/// The practical form of "indistinguishable downstream": the sidecar stores
/// parameters by `(op_id, param_id)` text, so a declared node whose interned
/// ids did not compare equal to the generated constants would load an edit
/// that silently did nothing.
#[test]
fn an_interpreted_node_answers_to_the_generated_parameter_ids() {
let mut declared = DeclaredOp::from_yaml(
&std::fs::read_to_string(ops_dir().join("exposure.yaml")).expect("readable"),
"ops/exposure.yaml",
builtin_helpers(),
)
.expect("exposure is a declaration");
declared.set_param(ops::exposure::EXPOSURE, 1.5);
assert_eq!(declared.param(ParamId("exposure")), 1.5);
assert_eq!(declared.descriptor().id, ops::exposure::ID);
}