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:
2026-08-27 19:08:34 +02:00
co-authored by Claude Opus 5
parent 0c3b8cb1c4
commit 0cd3ef1b3f
9 changed files with 2766 additions and 1286 deletions
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}");
}
}