Run a node declaration without compiling it
`ops/*.yaml` plus `build.rs` has been the class-1 plugin format since the declarative nodes landed — it was simply resolved at build time. Nothing about a declaration requires the compiler: everything it produces is data plus a WGSL string, and the composer already assembles WGSL at run time from whatever operations are active. So this is not a new mechanism. It is the existing one, loaded later (FR-PLG-2). `DeclaredOp` implements `Operation` from an owned `Declaration` — one interpreter over many declarations, where `build.rs` emits generated code per node. The generated path stays, as FR-PLG-2 says it should: 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 state is not. **The reader is now one file, read by both.** `src/declared/decl.rs` and `src/declared/expr.rs` are `#[path]`-included by `build.rs` as well as being modules of the crate, and they produce a neutral `Declaration` that names no Rust type. The build script's job is reduced to *rendering* that declaration as Rust; `DeclaredOp` converts the same declaration into descriptors and `Expr::eval` walks the same tree the renderer writes out. There is one grammar, one set of validations and one set of error messages, so "a plugin is the same kind of thing as a built-in" is structural rather than aspirational. What remains genuinely written twice is the pair of backends — an arithmetic node rendered as Rust here and evaluated there — and that is what the parity test stands between. `tests/declared_parity.rs` 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 both ends of every parameter's range and at four interior points; then again over the whole develop chain with the declared nodes swapped in, which is what covers uniform slot ordering and helper de-duplication between operations. A third test asserts the declared and hand-written nodes partition `ops/` between them, so coverage cannot shrink silently. Bit-for-bit rather than within a tolerance, because a tolerance is where a real divergence hides. The one thing that had to be got right for that to hold is number literals: `expr::as_f32` rounds a decimal exactly once, through the same shortest-round-trip text the compiler is handed, rather than rounding an `f64` a second time. Not in scope, and deliberately untagged: load-time WGSL validation (FR-PLG-11), id namespacing, a plugin directory read at startup, and pass nodes (FR-PLG-2a). Those are separate work, and tagging them from here would be the overstatement the spec's own §7 warns about. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
+193
-1279
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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, ¶ms())
|
||||
.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", ¶ms()).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)", ¶ms()).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)", ¶ms()).unwrap_err();
|
||||
assert!(err.contains("takes 2 argument(s), given 1"), "{err}");
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -9,8 +9,8 @@ Denominators are parsed from [`requirements.md`](requirements.md) at run time, n
|
||||
|
||||
| Metric | Value |
|
||||
|---|---|
|
||||
| Source files scanned | 257 |
|
||||
| TRACES tags found | 746 |
|
||||
| Source files scanned | 258 |
|
||||
| TRACES tags found | 751 |
|
||||
| Requirements defined | 177 |
|
||||
| Requirements covered | 100 |
|
||||
| **Coverage** | **56.5%** (100/177) |
|
||||
@@ -58,9 +58,9 @@ _None._
|
||||
| FR-CULL-9 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:384`](../core/dr-catalog/src/schema.rs#L384), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/identity_ui.rs:1`](../ui/dr-ui/src/identity_ui.rs#L1) |
|
||||
| FR-DEV-2 | [`core/dr-pipeline/src/operation.rs:389`](../core/dr-pipeline/src/operation.rs#L389) |
|
||||
| FR-DEV-3 | [`core/dr-gpu/src/adjust.rs:2165`](../core/dr-gpu/src/adjust.rs#L2165), [`core/dr-gpu/src/adjust.rs:651`](../core/dr-gpu/src/adjust.rs#L651), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/adjust.rs:84`](../core/dr-gpu/src/adjust.rs#L84), [`core/dr-gpu/tests/tone_curve.rs:1`](../core/dr-gpu/tests/tone_curve.rs#L1), [`core/dr-pipeline/src/detail.rs:387`](../core/dr-pipeline/src/detail.rs#L387), [`core/dr-pipeline/src/detail.rs:465`](../core/dr-pipeline/src/detail.rs#L465), [`core/dr-pipeline/src/framing.rs:191`](../core/dr-pipeline/src/framing.rs#L191), [`core/dr-pipeline/src/framing.rs:365`](../core/dr-pipeline/src/framing.rs#L365), [`core/dr-pipeline/src/framing.rs:620`](../core/dr-pipeline/src/framing.rs#L620), [`core/dr-pipeline/src/graph.rs:169`](../core/dr-pipeline/src/graph.rs#L169), [`core/dr-pipeline/src/graph.rs:577`](../core/dr-pipeline/src/graph.rs#L577), [`core/dr-pipeline/src/mask.rs:121`](../core/dr-pipeline/src/mask.rs#L121), [`core/dr-pipeline/src/operation.rs:330`](../core/dr-pipeline/src/operation.rs#L330), [`core/dr-pipeline/src/operation.rs:516`](../core/dr-pipeline/src/operation.rs#L516), [`core/dr-pipeline/src/ops/capture_sharpen.rs:1`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L1), [`core/dr-pipeline/src/ops/capture_sharpen.rs:210`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L210), [`core/dr-pipeline/src/ops/curve.rs:100`](../core/dr-pipeline/src/ops/curve.rs#L100), [`core/dr-pipeline/src/ops/curve.rs:1`](../core/dr-pipeline/src/ops/curve.rs#L1), [`core/dr-pipeline/src/ops/curve.rs:219`](../core/dr-pipeline/src/ops/curve.rs#L219), [`core/dr-pipeline/src/ops/curve.rs:635`](../core/dr-pipeline/src/ops/curve.rs#L635), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:1`](../core/dr-pipeline/src/ops/noise_reduction.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:273`](../core/dr-pipeline/src/ops/noise_reduction.rs#L273), [`core/dr-pipeline/src/sidecar.rs:156`](../core/dr-pipeline/src/sidecar.rs#L156), [`core/dr-pipeline/src/sidecar.rs:1636`](../core/dr-pipeline/src/sidecar.rs#L1636), [`core/dr-pipeline/src/sidecar.rs:1696`](../core/dr-pipeline/src/sidecar.rs#L1696), [`core/dr-pipeline/tests/tone_curve.rs:1`](../core/dr-pipeline/tests/tone_curve.rs#L1), [`ui/dr-ui/src/develop.rs:101`](../ui/dr-ui/src/develop.rs#L101), [`ui/dr-ui/src/develop.rs:1280`](../ui/dr-ui/src/develop.rs#L1280), [`ui/dr-ui/src/develop.rs:163`](../ui/dr-ui/src/develop.rs#L163), [`ui/dr-ui/src/develop.rs:1758`](../ui/dr-ui/src/develop.rs#L1758), [`ui/dr-ui/src/develop.rs:1776`](../ui/dr-ui/src/develop.rs#L1776), [`ui/dr-ui/src/develop.rs:1790`](../ui/dr-ui/src/develop.rs#L1790), [`ui/dr-ui/src/develop.rs:1812`](../ui/dr-ui/src/develop.rs#L1812), [`ui/dr-ui/src/develop.rs:1958`](../ui/dr-ui/src/develop.rs#L1958), [`ui/dr-ui/src/develop.rs:2056`](../ui/dr-ui/src/develop.rs#L2056), [`ui/dr-ui/src/develop.rs:326`](../ui/dr-ui/src/develop.rs#L326), [`ui/dr-ui/src/develop.rs:3287`](../ui/dr-ui/src/develop.rs#L3287), [`ui/dr-ui/src/develop.rs:363`](../ui/dr-ui/src/develop.rs#L363), [`ui/dr-ui/src/develop.rs:3851`](../ui/dr-ui/src/develop.rs#L3851), [`ui/dr-ui/src/develop.rs:3905`](../ui/dr-ui/src/develop.rs#L3905), [`ui/dr-ui/src/develop.rs:3949`](../ui/dr-ui/src/develop.rs#L3949), [`ui/dr-ui/src/develop.rs:3999`](../ui/dr-ui/src/develop.rs#L3999), [`ui/dr-ui/src/develop.rs:628`](../ui/dr-ui/src/develop.rs#L628), [`ui/dr-ui/src/develop.rs:675`](../ui/dr-ui/src/develop.rs#L675), [`ui/dr-ui/src/lib.rs:1422`](../ui/dr-ui/src/lib.rs#L1422), [`ui/dr-ui/src/lib.rs:2124`](../ui/dr-ui/src/lib.rs#L2124), [`ui/dr-ui/src/lib.rs:301`](../ui/dr-ui/src/lib.rs#L301), [`ui/dr-ui/src/library.rs:411`](../ui/dr-ui/src/library.rs#L411), [`ui/dr-ui/src/masks_ui.rs:218`](../ui/dr-ui/src/masks_ui.rs#L218), [`ui/dr-ui/src/masks_ui.rs:41`](../ui/dr-ui/src/masks_ui.rs#L41), [`ui/dr-ui/src/masks_ui.rs:816`](../ui/dr-ui/src/masks_ui.rs#L816), [`ui/dr-ui/src/masks_ui.rs:930`](../ui/dr-ui/src/masks_ui.rs#L930), [`ui/dr-ui/src/segmentation.rs:219`](../ui/dr-ui/src/segmentation.rs#L219), [`ui/dr-ui/src/segmentation.rs:322`](../ui/dr-ui/src/segmentation.rs#L322), [`ui/dr-ui/src/segmentation.rs:350`](../ui/dr-ui/src/segmentation.rs#L350), [`ui/dr-ui/ui/app.slint:2100`](../ui/dr-ui/ui/app.slint#L2100), [`ui/dr-ui/ui/app.slint:981`](../ui/dr-ui/ui/app.slint#L981) |
|
||||
| FR-DEV-3a | [`core/dr-pipeline/build.rs:1814`](../core/dr-pipeline/build.rs#L1814), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/descriptor.rs:194`](../core/dr-pipeline/src/descriptor.rs#L194), [`core/dr-pipeline/src/descriptor.rs:234`](../core/dr-pipeline/src/descriptor.rs#L234), [`core/dr-pipeline/src/descriptor.rs:258`](../core/dr-pipeline/src/descriptor.rs#L258), [`core/dr-pipeline/src/descriptor.rs:313`](../core/dr-pipeline/src/descriptor.rs#L313), [`core/dr-pipeline/src/framing.rs:262`](../core/dr-pipeline/src/framing.rs#L262), [`core/dr-pipeline/src/graph.rs:23`](../core/dr-pipeline/src/graph.rs#L23), [`core/dr-pipeline/src/graph.rs:250`](../core/dr-pipeline/src/graph.rs#L250), [`core/dr-pipeline/src/graph.rs:45`](../core/dr-pipeline/src/graph.rs#L45), [`core/dr-pipeline/src/graph.rs:58`](../core/dr-pipeline/src/graph.rs#L58), [`core/dr-pipeline/src/mask.rs:955`](../core/dr-pipeline/src/mask.rs#L955), [`core/dr-pipeline/src/operation.rs:232`](../core/dr-pipeline/src/operation.rs#L232), [`core/dr-pipeline/src/operation.rs:365`](../core/dr-pipeline/src/operation.rs#L365), [`core/dr-pipeline/src/ops/curve.rs:319`](../core/dr-pipeline/src/ops/curve.rs#L319), [`ui/dr-ui/src/develop.rs:1177`](../ui/dr-ui/src/develop.rs#L1177), [`ui/dr-ui/src/lib.rs:595`](../ui/dr-ui/src/lib.rs#L595) |
|
||||
| FR-DEV-3a | [`core/dr-pipeline/build.rs:756`](../core/dr-pipeline/build.rs#L756), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/descriptor.rs:194`](../core/dr-pipeline/src/descriptor.rs#L194), [`core/dr-pipeline/src/descriptor.rs:234`](../core/dr-pipeline/src/descriptor.rs#L234), [`core/dr-pipeline/src/descriptor.rs:258`](../core/dr-pipeline/src/descriptor.rs#L258), [`core/dr-pipeline/src/descriptor.rs:313`](../core/dr-pipeline/src/descriptor.rs#L313), [`core/dr-pipeline/src/framing.rs:262`](../core/dr-pipeline/src/framing.rs#L262), [`core/dr-pipeline/src/graph.rs:23`](../core/dr-pipeline/src/graph.rs#L23), [`core/dr-pipeline/src/graph.rs:250`](../core/dr-pipeline/src/graph.rs#L250), [`core/dr-pipeline/src/graph.rs:45`](../core/dr-pipeline/src/graph.rs#L45), [`core/dr-pipeline/src/graph.rs:58`](../core/dr-pipeline/src/graph.rs#L58), [`core/dr-pipeline/src/mask.rs:955`](../core/dr-pipeline/src/mask.rs#L955), [`core/dr-pipeline/src/operation.rs:232`](../core/dr-pipeline/src/operation.rs#L232), [`core/dr-pipeline/src/operation.rs:365`](../core/dr-pipeline/src/operation.rs#L365), [`core/dr-pipeline/src/ops/curve.rs:319`](../core/dr-pipeline/src/ops/curve.rs#L319), [`ui/dr-ui/src/develop.rs:1177`](../ui/dr-ui/src/develop.rs#L1177), [`ui/dr-ui/src/lib.rs:595`](../ui/dr-ui/src/lib.rs#L595) |
|
||||
| FR-DEV-3b | [`core/dr-pipeline/src/descriptor.rs:258`](../core/dr-pipeline/src/descriptor.rs#L258), [`core/dr-pipeline/src/framing.rs:262`](../core/dr-pipeline/src/framing.rs#L262), [`core/dr-pipeline/src/graph.rs:58`](../core/dr-pipeline/src/graph.rs#L58), [`core/dr-pipeline/src/operation.rs:365`](../core/dr-pipeline/src/operation.rs#L365) |
|
||||
| FR-DEV-3c | [`core/dr-pipeline/build.rs:1814`](../core/dr-pipeline/build.rs#L1814), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/graph.rs:250`](../core/dr-pipeline/src/graph.rs#L250), [`core/dr-pipeline/src/graph.rs:45`](../core/dr-pipeline/src/graph.rs#L45), [`core/dr-pipeline/src/mask.rs:955`](../core/dr-pipeline/src/mask.rs#L955), [`ui/dr-ui/src/develop.rs:4578`](../ui/dr-ui/src/develop.rs#L4578) |
|
||||
| FR-DEV-3c | [`core/dr-pipeline/build.rs:756`](../core/dr-pipeline/build.rs#L756), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/graph.rs:250`](../core/dr-pipeline/src/graph.rs#L250), [`core/dr-pipeline/src/graph.rs:45`](../core/dr-pipeline/src/graph.rs#L45), [`core/dr-pipeline/src/mask.rs:955`](../core/dr-pipeline/src/mask.rs#L955), [`ui/dr-ui/src/develop.rs:4578`](../ui/dr-ui/src/develop.rs#L4578) |
|
||||
| FR-DEV-3d | [`core/dr-gpu/src/adjust.rs:1041`](../core/dr-gpu/src/adjust.rs#L1041), [`core/dr-gpu/src/adjust.rs:104`](../core/dr-gpu/src/adjust.rs#L104), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/adjust.rs:84`](../core/dr-gpu/src/adjust.rs#L84), [`core/dr-gpu/src/adjust.rs:986`](../core/dr-gpu/src/adjust.rs#L986), [`core/dr-gpu/tests/capture_sharpen.rs:434`](../core/dr-gpu/tests/capture_sharpen.rs#L434), [`core/dr-gpu/tests/detail_stage.rs:242`](../core/dr-gpu/tests/detail_stage.rs#L242), [`core/dr-gpu/tests/local_contrast.rs:476`](../core/dr-gpu/tests/local_contrast.rs#L476), [`core/dr-gpu/tests/noise_reduction.rs:556`](../core/dr-gpu/tests/noise_reduction.rs#L556), [`core/dr-pipeline/src/framing.rs:191`](../core/dr-pipeline/src/framing.rs#L191), [`core/dr-pipeline/src/graph.rs:616`](../core/dr-pipeline/src/graph.rs#L616), [`core/dr-pipeline/src/operation.rs:32`](../core/dr-pipeline/src/operation.rs#L32), [`core/dr-pipeline/src/operation.rs:389`](../core/dr-pipeline/src/operation.rs#L389), [`core/dr-pipeline/src/operation.rs:53`](../core/dr-pipeline/src/operation.rs#L53), [`core/dr-pipeline/src/operation.rs:71`](../core/dr-pipeline/src/operation.rs#L71) |
|
||||
| FR-DEV-3e | [`core/dr-decode/src/base_curve.rs:145`](../core/dr-decode/src/base_curve.rs#L145), [`core/dr-decode/src/base_curve.rs:158`](../core/dr-decode/src/base_curve.rs#L158), [`core/dr-decode/src/base_curve.rs:1`](../core/dr-decode/src/base_curve.rs#L1), [`core/dr-decode/src/base_curve.rs:267`](../core/dr-decode/src/base_curve.rs#L267), [`core/dr-decode/src/base_curve.rs:347`](../core/dr-decode/src/base_curve.rs#L347), [`core/dr-decode/src/base_curve.rs:55`](../core/dr-decode/src/base_curve.rs#L55), [`core/dr-decode/src/lib.rs:121`](../core/dr-decode/src/lib.rs#L121), [`core/dr-decode/src/lib.rs:708`](../core/dr-decode/src/lib.rs#L708), [`core/dr-decode/src/lib.rs:748`](../core/dr-decode/src/lib.rs#L748), [`core/dr-decode/src/profile.rs:102`](../core/dr-decode/src/profile.rs#L102), [`core/dr-decode/src/profile.rs:151`](../core/dr-decode/src/profile.rs#L151), [`core/dr-decode/src/profile.rs:1`](../core/dr-decode/src/profile.rs#L1), [`core/dr-decode/src/profile.rs:235`](../core/dr-decode/src/profile.rs#L235), [`core/dr-decode/src/profile.rs:286`](../core/dr-decode/src/profile.rs#L286), [`core/dr-decode/src/profile.rs:343`](../core/dr-decode/src/profile.rs#L343), [`core/dr-decode/src/profile.rs:458`](../core/dr-decode/src/profile.rs#L458), [`core/dr-decode/src/profile.rs:492`](../core/dr-decode/src/profile.rs#L492), [`core/dr-decode/src/profile.rs:630`](../core/dr-decode/src/profile.rs#L630), [`core/dr-gpu/src/adjust.rs:37`](../core/dr-gpu/src/adjust.rs#L37), [`core/dr-gpu/src/adjust.rs:967`](../core/dr-gpu/src/adjust.rs#L967), [`core/dr-gpu/src/demosaic.rs:121`](../core/dr-gpu/src/demosaic.rs#L121), [`core/dr-gpu/src/demosaic.rs:86`](../core/dr-gpu/src/demosaic.rs#L86), [`core/dr-gpu/tests/base_curve.rs:1`](../core/dr-gpu/tests/base_curve.rs#L1), [`core/dr-pipeline/src/operation.rs:1495`](../core/dr-pipeline/src/operation.rs#L1495), [`core/dr-pipeline/src/operation.rs:1576`](../core/dr-pipeline/src/operation.rs#L1576), [`core/dr-pipeline/src/operation.rs:1601`](../core/dr-pipeline/src/operation.rs#L1601), [`core/dr-pipeline/src/operation.rs:1616`](../core/dr-pipeline/src/operation.rs#L1616), [`core/dr-pipeline/src/operation.rs:1640`](../core/dr-pipeline/src/operation.rs#L1640), [`core/dr-pipeline/src/operation.rs:310`](../core/dr-pipeline/src/operation.rs#L310), [`core/dr-pipeline/src/operation.rs:440`](../core/dr-pipeline/src/operation.rs#L440), [`core/dr-pipeline/src/operation.rs:450`](../core/dr-pipeline/src/operation.rs#L450), [`core/dr-pipeline/src/operation.rs:600`](../core/dr-pipeline/src/operation.rs#L600) |
|
||||
| FR-DEV-3f | [`core/dr-film/src/bake.rs:271`](../core/dr-film/src/bake.rs#L271), [`core/dr-film/src/bake.rs:62`](../core/dr-film/src/bake.rs#L62), [`core/dr-film/src/boolean_grain.rs:1`](../core/dr-film/src/boolean_grain.rs#L1), [`core/dr-film/src/boolean_grain.rs:78`](../core/dr-film/src/boolean_grain.rs#L78), [`core/dr-film/src/grain.rs:140`](../core/dr-film/src/grain.rs#L140), [`core/dr-film/src/grain.rs:1`](../core/dr-film/src/grain.rs#L1), [`core/dr-film/src/grain.rs:302`](../core/dr-film/src/grain.rs#L302), [`core/dr-film/src/grain.rs:79`](../core/dr-film/src/grain.rs#L79), [`core/dr-film/src/lib.rs:160`](../core/dr-film/src/lib.rs#L160), [`core/dr-film/src/lib.rs:1`](../core/dr-film/src/lib.rs#L1), [`core/dr-film/src/profile.rs:100`](../core/dr-film/src/profile.rs#L100), [`core/dr-film/src/profile.rs:142`](../core/dr-film/src/profile.rs#L142), [`core/dr-film/src/profile.rs:182`](../core/dr-film/src/profile.rs#L182), [`core/dr-film/src/profile.rs:259`](../core/dr-film/src/profile.rs#L259), [`core/dr-film/src/profile.rs:502`](../core/dr-film/src/profile.rs#L502), [`core/dr-film/src/profile.rs:73`](../core/dr-film/src/profile.rs#L73), [`core/dr-gpu/src/adjust.rs:139`](../core/dr-gpu/src/adjust.rs#L139), [`core/dr-gpu/src/adjust.rs:196`](../core/dr-gpu/src/adjust.rs#L196), [`core/dr-gpu/src/adjust.rs:357`](../core/dr-gpu/src/adjust.rs#L357), [`core/dr-gpu/src/adjust.rs:483`](../core/dr-gpu/src/adjust.rs#L483), [`core/dr-gpu/src/adjust.rs:77`](../core/dr-gpu/src/adjust.rs#L77), [`core/dr-gpu/tests/film_sim.rs:191`](../core/dr-gpu/tests/film_sim.rs#L191), [`core/dr-gpu/tests/film_sim.rs:1`](../core/dr-gpu/tests/film_sim.rs#L1), [`core/dr-pipeline/src/graph.rs:101`](../core/dr-pipeline/src/graph.rs#L101), [`core/dr-pipeline/src/graph.rs:124`](../core/dr-pipeline/src/graph.rs#L124), [`core/dr-pipeline/src/graph.rs:324`](../core/dr-pipeline/src/graph.rs#L324), [`core/dr-pipeline/src/operation.rs:1065`](../core/dr-pipeline/src/operation.rs#L1065), [`core/dr-pipeline/src/operation.rs:1094`](../core/dr-pipeline/src/operation.rs#L1094), [`core/dr-pipeline/src/operation.rs:1495`](../core/dr-pipeline/src/operation.rs#L1495), [`core/dr-pipeline/src/operation.rs:296`](../core/dr-pipeline/src/operation.rs#L296), [`core/dr-pipeline/src/operation.rs:310`](../core/dr-pipeline/src/operation.rs#L310), [`core/dr-pipeline/src/ops/film_sim.rs:129`](../core/dr-pipeline/src/ops/film_sim.rs#L129), [`core/dr-pipeline/src/ops/film_sim.rs:153`](../core/dr-pipeline/src/ops/film_sim.rs#L153), [`core/dr-pipeline/src/ops/film_sim.rs:1`](../core/dr-pipeline/src/ops/film_sim.rs#L1), [`core/dr-pipeline/src/ops/film_sim.rs:331`](../core/dr-pipeline/src/ops/film_sim.rs#L331), [`core/dr-pipeline/src/ops/film_sim.rs:43`](../core/dr-pipeline/src/ops/film_sim.rs#L43), [`core/dr-pipeline/src/ops/film_sim.rs:87`](../core/dr-pipeline/src/ops/film_sim.rs#L87), [`core/dr-pipeline/src/ops/film_sim.rs:92`](../core/dr-pipeline/src/ops/film_sim.rs#L92), [`core/dr-pipeline/src/sidecar.rs:111`](../core/dr-pipeline/src/sidecar.rs#L111), [`core/dr-pipeline/src/sidecar.rs:167`](../core/dr-pipeline/src/sidecar.rs#L167), [`core/dr-pipeline/src/sidecar.rs:1957`](../core/dr-pipeline/src/sidecar.rs#L1957), [`core/dr-pipeline/src/sidecar.rs:2033`](../core/dr-pipeline/src/sidecar.rs#L2033), [`core/dr-pipeline/src/sidecar.rs:533`](../core/dr-pipeline/src/sidecar.rs#L533), [`core/dr-pipeline/src/sidecar.rs:660`](../core/dr-pipeline/src/sidecar.rs#L660), [`core/dr-pipeline/src/sidecar.rs:792`](../core/dr-pipeline/src/sidecar.rs#L792), [`core/dr-pipeline/src/state.rs:100`](../core/dr-pipeline/src/state.rs#L100), [`core/dr-pipeline/src/state.rs:115`](../core/dr-pipeline/src/state.rs#L115), [`core/dr-pipeline/src/state.rs:60`](../core/dr-pipeline/src/state.rs#L60), [`ui/dr-ui/src/develop.rs:2827`](../ui/dr-ui/src/develop.rs#L2827), [`ui/dr-ui/src/develop.rs:2844`](../ui/dr-ui/src/develop.rs#L2844), [`ui/dr-ui/src/develop.rs:2856`](../ui/dr-ui/src/develop.rs#L2856), [`ui/dr-ui/src/develop.rs:2894`](../ui/dr-ui/src/develop.rs#L2894), [`ui/dr-ui/src/develop.rs:2903`](../ui/dr-ui/src/develop.rs#L2903), [`ui/dr-ui/src/develop.rs:3006`](../ui/dr-ui/src/develop.rs#L3006), [`ui/dr-ui/src/develop.rs:3324`](../ui/dr-ui/src/develop.rs#L3324), [`ui/dr-ui/src/develop.rs:3339`](../ui/dr-ui/src/develop.rs#L3339), [`ui/dr-ui/src/lib.rs:2098`](../ui/dr-ui/src/lib.rs#L2098), [`ui/dr-ui/src/lib.rs:528`](../ui/dr-ui/src/lib.rs#L528), [`ui/dr-ui/src/lib.rs:586`](../ui/dr-ui/src/lib.rs#L586), [`ui/dr-ui/src/library.rs:402`](../ui/dr-ui/src/library.rs#L402), [`ui/dr-ui/src/library.rs:651`](../ui/dr-ui/src/library.rs#L651), [`ui/dr-ui/src/presets.rs:275`](../ui/dr-ui/src/presets.rs#L275), [`ui/dr-ui/ui/adjust.slint:889`](../ui/dr-ui/ui/adjust.slint#L889), [`ui/dr-ui/ui/adjust.slint:959`](../ui/dr-ui/ui/adjust.slint#L959), [`ui/dr-ui/ui/app.slint:2712`](../ui/dr-ui/ui/app.slint#L2712), [`ui/dr-ui/ui/app.slint:775`](../ui/dr-ui/ui/app.slint#L775) |
|
||||
@@ -101,8 +101,8 @@ _None._
|
||||
| FR-PLAT-AND-1 | [`core/dr-types/src/lib.rs:53`](../core/dr-types/src/lib.rs#L53) |
|
||||
| FR-PLAT-AND-3 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1) |
|
||||
| FR-PLAT-LIN-1 | [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/lib.rs:845`](../ui/dr-ui/src/lib.rs#L845), [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1) |
|
||||
| FR-PLG-2 | [`core/dr-pipeline/src/declared/decl.rs:1`](../core/dr-pipeline/src/declared/decl.rs#L1), [`core/dr-pipeline/src/declared/expr.rs:152`](../core/dr-pipeline/src/declared/expr.rs#L152), [`core/dr-pipeline/src/declared/expr.rs:1`](../core/dr-pipeline/src/declared/expr.rs#L1), [`core/dr-pipeline/src/declared/mod.rs:1`](../core/dr-pipeline/src/declared/mod.rs#L1), [`core/dr-pipeline/src/declared/mod.rs:82`](../core/dr-pipeline/src/declared/mod.rs#L82), [`core/dr-pipeline/src/descriptor.rs:15`](../core/dr-pipeline/src/descriptor.rs#L15), [`core/dr-pipeline/src/descriptor.rs:635`](../core/dr-pipeline/src/descriptor.rs#L635), [`core/dr-pipeline/src/operation.rs:232`](../core/dr-pipeline/src/operation.rs#L232) |
|
||||
| FR-PLG-2d | [`core/dr-pipeline/src/declared/decl.rs:112`](../core/dr-pipeline/src/declared/decl.rs#L112), [`core/dr-pipeline/src/declared/decl.rs:152`](../core/dr-pipeline/src/declared/decl.rs#L152), [`core/dr-pipeline/src/declared/decl.rs:1`](../core/dr-pipeline/src/declared/decl.rs#L1), [`core/dr-pipeline/src/declared/decl.rs:414`](../core/dr-pipeline/src/declared/decl.rs#L414), [`core/dr-pipeline/src/declared/decl.rs:67`](../core/dr-pipeline/src/declared/decl.rs#L67), [`core/dr-pipeline/src/declared/mod.rs:1`](../core/dr-pipeline/src/declared/mod.rs#L1), [`core/dr-pipeline/src/declared/mod.rs:384`](../core/dr-pipeline/src/declared/mod.rs#L384), [`core/dr-pipeline/src/declared/mod.rs:403`](../core/dr-pipeline/src/declared/mod.rs#L403) |
|
||||
| FR-PLG-2 | [`core/dr-pipeline/src/declared/decl.rs:1`](../core/dr-pipeline/src/declared/decl.rs#L1), [`core/dr-pipeline/src/declared/expr.rs:152`](../core/dr-pipeline/src/declared/expr.rs#L152), [`core/dr-pipeline/src/declared/expr.rs:1`](../core/dr-pipeline/src/declared/expr.rs#L1), [`core/dr-pipeline/src/declared/mod.rs:1`](../core/dr-pipeline/src/declared/mod.rs#L1), [`core/dr-pipeline/src/declared/mod.rs:82`](../core/dr-pipeline/src/declared/mod.rs#L82), [`core/dr-pipeline/src/descriptor.rs:15`](../core/dr-pipeline/src/descriptor.rs#L15), [`core/dr-pipeline/src/descriptor.rs:635`](../core/dr-pipeline/src/descriptor.rs#L635), [`core/dr-pipeline/src/operation.rs:232`](../core/dr-pipeline/src/operation.rs#L232), [`core/dr-pipeline/tests/declared_parity.rs:1`](../core/dr-pipeline/tests/declared_parity.rs#L1), [`core/dr-pipeline/tests/declared_parity.rs:240`](../core/dr-pipeline/tests/declared_parity.rs#L240), [`core/dr-pipeline/tests/declared_parity.rs:305`](../core/dr-pipeline/tests/declared_parity.rs#L305), [`core/dr-pipeline/tests/declared_parity.rs:358`](../core/dr-pipeline/tests/declared_parity.rs#L358), [`core/dr-pipeline/tests/declared_parity.rs:416`](../core/dr-pipeline/tests/declared_parity.rs#L416) |
|
||||
| FR-PLG-2d | [`core/dr-pipeline/src/declared/decl.rs:112`](../core/dr-pipeline/src/declared/decl.rs#L112), [`core/dr-pipeline/src/declared/decl.rs:152`](../core/dr-pipeline/src/declared/decl.rs#L152), [`core/dr-pipeline/src/declared/decl.rs:1`](../core/dr-pipeline/src/declared/decl.rs#L1), [`core/dr-pipeline/src/declared/decl.rs:420`](../core/dr-pipeline/src/declared/decl.rs#L420), [`core/dr-pipeline/src/declared/decl.rs:67`](../core/dr-pipeline/src/declared/decl.rs#L67), [`core/dr-pipeline/src/declared/mod.rs:1`](../core/dr-pipeline/src/declared/mod.rs#L1), [`core/dr-pipeline/src/declared/mod.rs:384`](../core/dr-pipeline/src/declared/mod.rs#L384), [`core/dr-pipeline/src/declared/mod.rs:403`](../core/dr-pipeline/src/declared/mod.rs#L403) |
|
||||
| FR-RAW-1 | [`core/dr-decode/src/lib.rs:243`](../core/dr-decode/src/lib.rs#L243), [`core/dr-types/src/lib.rs:129`](../core/dr-types/src/lib.rs#L129), [`core/dr-types/src/lib.rs:200`](../core/dr-types/src/lib.rs#L200) |
|
||||
| FR-RAW-3 | [`core/dr-decode/src/lib.rs:139`](../core/dr-decode/src/lib.rs#L139), [`core/dr-decode/src/lib.rs:506`](../core/dr-decode/src/lib.rs#L506), [`core/dr-decode/src/locate.rs:1366`](../core/dr-decode/src/locate.rs#L1366) |
|
||||
| FR-RAW-4 | [`core/dr-decode/src/error.rs:1`](../core/dr-decode/src/error.rs#L1), [`ui/dr-ui/src/lib.rs:186`](../ui/dr-ui/src/lib.rs#L186) |
|
||||
|
||||
Reference in New Issue
Block a user