Files
DarkRoom/core/dr-pipeline/build.rs
T
dtourolle db7b84795c Convert to the working space before the edits, not after
Every point operation ran in camera RGB and the camera matrix came after
them all, against the order ARCH §5.2 draws. So `luminance()` applied
Rec.709 weights to a body's own primaries, a band in the colour mixer
was a different hue on every make of sensor, and the vibrance skin guard
tested channel order in a space where skin does not have one.

Operations now declare a `Stage`. White balance is the only camera-stage
node — its multipliers scale the sensor's channels, and after a matrix
that mixes them the same numbers are a different correction — and says
so with `stage: camera` in its YAML, a key both the build-time generator
and the load-time declared op read. The composer emits the camera nodes,
then the matrix, then the rest, each group in graph order; an empty
chain still gets the matrix.

Film simulation stops converting out of camera space itself, since it is
now handed working-space colour like every other scene node. The base
curve stays where it was, after the operations, and so now acts on
working-space colour; the next commit replaces it (D19).
2026-09-27 16:52:53 -04:00

945 lines
35 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Compiles the nodes in `ops/*.yaml` into Rust implementing `Operation`.
//!
//! # Why a node is a YAML file
//!
//! A develop operation is, in the overwhelming majority of cases, four facts:
//! what its parameters are, what uniforms they compute, what WGSL those
//! uniforms drive, and where it sits in the chain. Written in Rust those four
//! facts arrive wrapped in ninety lines of trait implementation — a `match` on
//! parameter id to a struct field, another `match` back, an `is_active` that
//! compares each field to its default, a `Vec<Uniform>` built by hand. Every
//! one of those is mechanical, and every one is a place to make a silent
//! mistake: a `param()` arm returning the wrong field reads perfectly and
//! breaks the sidecar round-trip.
//!
//! So the four facts are the file, and this generates the rest.
//!
//! # What it does not do
//!
//! It does not change the runtime model. The generated code implements the
//! same [`Operation`](../src/operation.rs) trait, publishes the same
//! `Arc<OpDescriptor>`, and composes through the same fused-shader path.
//! Nothing downstream — not `dr-gpu`, not the develop panel — can tell a
//! declared node from a hand-written one, which is what allows the two to sit
//! side by side in one chain.
//!
//! # And it is no longer the only reader
//!
//! The parsing half of this script lives in `src/declared/`, included here by
//! `#[path]` and compiled into the crate as well (FR-PLG-2). This script's own
//! job is now purely *rendering*: it takes the [`decl::Declaration`] the shared
//! reader produced and writes Rust from it, while at run time
//! [`crate::declared::DeclaredOp`](../src/declared/mod.rs) takes the same
//! declaration and interprets it.
//!
//! That is what makes "a plugin is the same kind of thing as a built-in"
//! checkable rather than merely intended: there is one grammar, one set of
//! error messages, and one place where a node's meaning is decided.
//! `tests/declared_parity.rs` then asserts the two backends agree byte for
//! byte on every node in `ops/`.
//!
//! A node that needs more than the four facts stays in Rust and declares
//! itself with `rust:` instead (see `ops/tone_curve.yaml`). The escape hatch
//! is deliberate: a schema stretched to cover the tone curve's interpolator
//! would be a worse language than Rust, aimed at one caller.
//!
//! # Output
//!
//! `OUT_DIR/nodes.rs`, included by `src/ops/mod.rs`. In `OUT_DIR` rather than
//! beside the hand-written sources for the reason `ui/dr-ui/build.rs` records:
//! a generated file sitting in `src/ops/` looks exactly like the files around
//! it that *are* meant to be edited, and an edit to it survives until the next
//! `touch`.
use std::collections::{BTreeMap, BTreeSet};
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
// The shared reader, compiled into this build script as well as into the
// crate. See its own module documentation, and `#[path]` rather than a copy
// because a copy is precisely what FR-PLG-2 forbids: a plugin and a built-in
// have to be read by the same code or they are not the same kind of thing.
//
// Nothing in either file may name `crate::` items — they are compiled here as
// modules of a build script, where those paths do not exist.
//
// `dead_code` because the run-time half of the shared reader — `Expr::eval`,
// the descriptor conversions' inputs — has no caller here. Silencing it at the
// module rather than at each item keeps the shared files free of attributes
// that only mean something in one of their two compilations.
#[allow(dead_code)]
#[path = "src/declared/decl.rs"]
mod decl;
#[allow(dead_code)]
#[path = "src/declared/expr.rs"]
mod expr;
use decl::{Declaration, Node, ParamDef, SharedHelpers, TestDef};
use expr::Expr;
/// The directory holding node declarations, relative to the manifest.
const OPS_DIR: &str = "ops";
/// Declarations whose name begins with this are not nodes.
const NON_NODE_PREFIX: &str = "_";
const HELPERS_FILE: &str = decl::HELPERS_FILE;
const GENERATED: &str = "nodes.rs";
fn main() {
println!("cargo:rerun-if-changed={OPS_DIR}");
println!("cargo:rerun-if-changed=build.rs");
// The reader is an input to this build script now, so a change to it has
// to regenerate `nodes.rs` — otherwise a fix to the grammar would take
// effect at load time and not at build time, which is the one divergence
// this whole arrangement exists to prevent.
println!("cargo:rerun-if-changed=src/declared/decl.rs");
println!("cargo:rerun-if-changed=src/declared/expr.rs");
let manifest_dir = PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").expect("manifest dir"));
let out_dir = PathBuf::from(std::env::var_os("OUT_DIR").expect("OUT_DIR"));
let ops_dir = manifest_dir.join(OPS_DIR);
let src_ops = manifest_dir.join("src").join(OPS_DIR);
match generate(&ops_dir, &src_ops) {
Ok(rust) => {
let path = out_dir.join(GENERATED);
std::fs::write(&path, rust)
.unwrap_or_else(|e| fail(format!("writing {}: {e}", path.display())));
}
Err(e) => fail(e),
}
}
/// Build scripts report failure through stderr and a non-zero exit; a panic
/// buries the message under a backtrace and the "process didn't exit
/// successfully" boilerplate, which is exactly the wrong thing when the
/// message is the name of the key the author got wrong.
fn fail(message: String) -> ! {
eprintln!("\nerror: {message}\n");
std::process::exit(1);
}
// ---------------------------------------------------------------------------
// Driver
// ---------------------------------------------------------------------------
fn generate(ops_dir: &Path, src_ops: &Path) -> Result<String, String> {
let helpers_path = ops_dir.join(HELPERS_FILE);
let shared = decl::read_helpers(&read_file(&helpers_path)?)?;
let shared_names = shared.names();
let mut nodes = Vec::new();
for path in node_files(ops_dir)? {
let name = file_stem(&path);
let ctx = format!("{OPS_DIR}/{name}.yaml");
let node = decl::read_node(&read_file(&path)?, &ctx, &shared_names)
.map_err(|e| format!("{ctx}: {e}"))?;
// The filename and the id must agree. They are two names for one
// thing, and a node found by one and referred to by the other is a
// node nobody can grep for.
if node.id() != name {
return Err(format!(
"{}/{}.yaml declares `id: {}`; the file name and the id must match",
OPS_DIR,
name,
node.id()
));
}
nodes.push(node);
}
if nodes.is_empty() {
return Err(format!(
"{OPS_DIR}/ declares no nodes; expected at least one `<id>.yaml`"
));
}
check_unique(&nodes)?;
check_no_shadowing(&nodes, src_ops)?;
nodes.sort_by_key(|n| n.order());
emit(&shared, &nodes)
}
/// The node declarations, sorted so generation is deterministic.
///
/// Determinism is not cosmetic here: the generated file is an input to
/// `rustc`'s incremental cache, and a set of nodes that reorders between runs
/// would rebuild the crate every time.
fn node_files(dir: &Path) -> Result<Vec<PathBuf>, String> {
let entries = std::fs::read_dir(dir)
.map_err(|e| format!("cannot read {}: {e}", dir.display()))?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("cannot read {}: {e}", dir.display()))?;
let mut files: Vec<PathBuf> = entries
.into_iter()
.map(|e| e.path())
.filter(|p| p.extension().and_then(|e| e.to_str()) == Some("yaml"))
.filter(|p| !file_stem(p).starts_with(NON_NODE_PREFIX))
.collect();
files.sort();
Ok(files)
}
fn read_file(path: &Path) -> Result<String, String> {
std::fs::read_to_string(path).map_err(|e| format!("cannot read {}: {e}", path.display()))
}
fn file_stem(path: &Path) -> String {
path.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default()
}
fn check_unique(nodes: &[Node]) -> Result<(), String> {
let mut ids = BTreeSet::new();
let mut orders: BTreeMap<i64, &str> = BTreeMap::new();
for node in nodes {
if !ids.insert(node.id()) {
return Err(format!("`{}` is declared by two files", node.id()));
}
// Two nodes at one order is an ambiguous pipeline, and the pipeline's
// order changes what the image looks like. Sorting would silently pick
// one, so refuse instead.
if let Some(other) = orders.insert(node.order(), node.id()) {
return Err(format!(
"`{}` and `{}` both declare `order: {}`; the chain order would \
be ambiguous, and operation order changes the result",
other,
node.id(),
node.order()
));
}
}
Ok(())
}
/// A declared node must not share a name with a hand-written module.
///
/// The same guard `ui/dr-ui/build.rs` applies to `theme.slint`: the generated
/// `pub mod exposure` and a `src/ops/exposure.rs` would both want that name,
/// and the error a reader gets from the collision says nothing about which
/// file to delete.
fn check_no_shadowing(nodes: &[Node], src_ops: &Path) -> Result<(), String> {
for node in nodes {
let Node::Declared(declared) = node else {
continue;
};
let id = &declared.id;
let shadowed = src_ops.join(format!("{id}.rs"));
if shadowed.exists() {
return Err(format!(
"{} exists and would collide with the module generated from \
{OPS_DIR}/{id}.yaml.\nThe node is now declared in YAML; delete \
the stale Rust file.",
shadowed.display()
));
}
}
Ok(())
}
// ---------------------------------------------------------------------------
// The expression language, rendered as Rust
// ---------------------------------------------------------------------------
//
// The grammar and the validation live in `src/declared/expr.rs`, shared with
// the crate. What is left here is the half that is specific to generating
// code: turning a validated [`Expr`] into a Rust `f32` expression, so that a
// built-in node's arithmetic costs nothing at run time.
//
// `Expr::eval` is the other half, and the two must agree bit for bit. Every
// arm below has a counterpart there, written to produce the same operations in
// the same order — including `mix`, which is spelled out in both because
// `a + (b - a) * t` and `a * (1 - t) + b * t` are different numbers in `f32`.
/// Compile a validated expression to a Rust `f32` expression.
fn compile_expr(expr: &Expr) -> String {
unwrap_parens(&render(expr))
}
/// Render an expression as Rust source.
///
/// Infallible: `expr::parse` has already established that every name resolves
/// and every call has the right arity, which is why the validation lives in
/// the shared reader rather than here — an unknown function had to be rejected
/// identically whether the declaration was read by this script or at load
/// time.
fn render(expr: &Expr) -> String {
match expr {
Expr::Num(n) => rust_f32(*n),
Expr::Param(name) => format!("self.{name}"),
// One pair of parentheses, never two: the inner expression brings its
// own, and `-((a * b))` is a clippy warning in code nobody can edit.
// The pair that remains is load-bearing — `-(a + b)` and `-a + b` are
// different numbers.
Expr::Neg(inner) => format!("-({})", unwrap_parens(&render(inner))),
Expr::Bin(op, l, r) => format!("({} {op} {})", render(l), render(r)),
Expr::Call(name, args) => {
let a: Vec<String> = args.iter().map(|x| unwrap_parens(&render(x))).collect();
match name.as_str() {
"exp2" | "log2" | "exp" | "sqrt" | "abs" | "floor" | "ceil" | "round" => {
format!("f32::{name}({})", a[0])
}
"pow" => format!("f32::powf({}, {})", a[0], a[1]),
"min" | "max" => format!("f32::{name}({}, {})", a[0], a[1]),
"clamp" => format!("f32::clamp({}, {}, {})", a[0], a[1], a[2]),
// Spelled out rather than called: Rust has no `mix`, and the
// linear form is what WGSL's `mix` means.
"mix" => format!("({x} + ({y} - {x}) * {t})", x = a[0], y = a[1], t = a[2]),
// `expr::FUNCTIONS` is the closed list and `expr::parse`
// enforces it, so reaching here means the two fell out of step.
other => unreachable!("`{other}` passed validation but has no Rust rendering"),
}
}
}
}
/// Drop one redundant pair of enclosing parentheses.
///
/// [`render`] parenthesises every binary expression, which is what keeps
/// precedence correct under composition. At the outermost position — a
/// function argument, or the whole expression — that pair is redundant, and
/// `rustc` warns about it. Generated code is the one place a warning cannot be
/// fixed where it appears, so it is fixed here.
fn unwrap_parens(s: &str) -> String {
let inner = match s.strip_prefix('(').and_then(|s| s.strip_suffix(')')) {
Some(inner) => inner,
None => return s.to_string(),
};
// Only when the two are actually a pair: `(a) * (b)` also starts with `(`
// and ends with `)`, and stripping those would change what it means.
let mut depth = 0i32;
for c in inner.chars() {
match c {
'(' => depth += 1,
')' => {
depth -= 1;
if depth < 0 {
return s.to_string();
}
}
_ => {}
}
}
if depth == 0 {
inner.to_string()
} else {
s.to_string()
}
}
// ---------------------------------------------------------------------------
// Emission
// ---------------------------------------------------------------------------
fn emit(shared: &SharedHelpers, nodes: &[Node]) -> Result<String, String> {
let mut out = String::new();
out.push_str(
"// GENERATED FILE — DO NOT EDIT.\n\
//\n\
// Written by core/dr-pipeline/build.rs from core/dr-pipeline/ops/*.yaml.\n\
// Edits here are discarded the next time a declaration changes.\n\
// Change the node, not this.\n\n",
);
emit_helpers(&mut out, shared);
for node in nodes {
if let Node::Declared(declared) = node {
emit_node(&mut out, declared);
}
}
emit_chain(&mut out, nodes);
Ok(out)
}
fn emit_helpers(out: &mut String, shared: &SharedHelpers) {
out.push_str("pub mod helpers {\n");
if let Some(doc) = &shared.doc {
out.push_str(&comment(doc, "//!", 4));
out.push_str(" //!\n");
}
out.push_str(" //! Generated from `ops/_helpers.yaml`.\n\n");
out.push_str(" use crate::operation::Helper;\n");
for h in &shared.helpers {
out.push('\n');
if let Some(doc) = &h.doc {
out.push_str(&comment(doc, "///", 4));
}
let _ = writeln!(
out,
" pub const {}: Helper = Helper {{\n name: {:?},\n source: {},\n }};",
h.name.to_uppercase(),
h.name,
raw_string(&h.source())
);
}
// A registry, so a test can assert over every helper rather than a list
// someone has to remember to extend.
let names: Vec<String> = shared
.helpers
.iter()
.map(|h| h.name.to_uppercase())
.collect();
let _ = writeln!(
out,
"\n /// Every helper `_helpers.yaml` declares, for registry-wide tests.\n\
\x20 pub static ALL: &[Helper] = &[{}];\n}}\n",
names.join(", ")
);
}
fn emit_node(out: &mut String, node: &Declaration) {
let Declaration {
id,
label,
attributes,
doc,
params,
uniforms,
shared_helpers,
local_helpers,
active,
tests,
presentation,
camera_stage,
..
} = node;
let ty = pascal_case(id);
let _ = writeln!(out, "pub mod {id} {{");
if let Some(doc) = doc {
out.push_str(&comment(doc, "//!", 4));
out.push_str(" //!\n");
}
let _ = writeln!(out, " //! Generated from `ops/{id}.yaml`.\n");
// `Scale`, `Unit` and `Helper` are used only by some nodes — a node with
// no `kind: scalar` parameter and no helpers needs neither — so the group
// is allowed to go unused rather than being assembled per node.
out.push_str(
" #[allow(unused_imports)]\n\
\x20 use crate::descriptor::{\n\
\x20 Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId,\n\
\x20 Presentation,\n\
\x20 Scale, Unit, WidgetDemand, WidgetKind,\n\
\x20 };\n\
\x20 #[allow(unused_imports)]\n\
\x20 use crate::operation::{Helper, Operation, Uniform};\n\
\x20 use std::sync::{Arc, LazyLock};\n\n",
);
// Ids as consts, so a caller names a parameter through the type system
// rather than by retyping a string.
let _ = writeln!(out, " pub const ID: OpId = OpId({id:?});");
for p in params {
let _ = writeln!(
out,
" pub const {}: ParamId = ParamId({:?});",
p.id.to_uppercase(),
p.id
);
}
// Descriptor.
//
// Built once, behind a `LazyLock`, and handed out as an `Arc` clone. It
// was a plain `static` until descriptors became owned (FR-PLG-2): an
// `OpDescriptor` now holds `Vec`s, and an `Arc` is not const-constructible
// in any case. The cost is one lock check the first time an operation is
// asked what it is, and an atomic increment thereafter.
out.push_str(
"\n static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {\n\
\x20 Arc::new(OpDescriptor {\n",
);
let _ = writeln!(out, " id: ID,");
let _ = writeln!(out, " label: LocalizedKey({label:?}),");
out.push_str(" params: vec![\n");
for p in params {
if let Some(doc) = &p.doc {
out.push_str(&comment(doc, "//", 16));
}
let _ = writeln!(out, " {},", param_ctor(p));
}
out.push_str(" ],\n");
// What the operation is about. The panel groups by these and names no
// operation, which is what keeps FR-DEV-3a true as the set grows.
let attrs: Vec<String> = attributes
.iter()
.map(|a| {
let mut c = a.name().chars();
let head = c
.next()
.expect("attribute names are non-empty")
.to_uppercase();
format!("Attribute::{head}{}", c.as_str())
})
.collect();
let _ = writeln!(out, " attributes: vec![{}],", attrs.join(", "));
out.push_str(" })\n });\n");
// Helpers: node-local definitions first, then the assembled list.
for h in local_helpers {
let _ = writeln!(
out,
"\n const {}: Helper = Helper {{\n name: {:?},\n source: {},\n }};",
local_helper_const(&h.name),
h.name,
raw_string(&h.source())
);
}
let mut helper_refs: Vec<String> = shared_helpers
.iter()
.map(|h| format!("super::helpers::{}", h.to_uppercase()))
.collect();
helper_refs.extend(local_helpers.iter().map(|h| local_helper_const(&h.name)));
if !helper_refs.is_empty() {
let _ = writeln!(
out,
"\n static HELPERS: &[Helper] = &[\n {},\n ];",
helper_refs.join(",\n ")
);
}
// State.
let _ = writeln!(
out,
"\n #[derive(Debug, Default, Clone)]\n pub struct {ty} {{"
);
for p in params {
let _ = writeln!(out, " {}: f32,", p.id);
}
out.push_str(" }\n");
let _ = writeln!(
out,
"\n impl {ty} {{\n pub fn new() -> Self {{\n Self::default()\n }}\n }}"
);
// The trait.
let _ = writeln!(out, "\n impl Operation for {ty} {{");
out.push_str(
" fn descriptor(&self) -> Arc<OpDescriptor> {\n DESCRIPTOR.clone()\n }\n\n",
);
out.push_str(
" fn set_param(&mut self, id: ParamId, value: f32) {\n match id {\n",
);
for p in params {
let _ = writeln!(
out,
" {} => self.{} = value,",
p.id.to_uppercase(),
p.id
);
}
let _ = writeln!(
out,
" _ => log::warn!(\"{id}: unknown parameter {{id}}\"),\n }}\n }}\n"
);
out.push_str(" fn param(&self, id: ParamId) -> f32 {\n match id {\n");
for p in params {
let _ = writeln!(
out,
" {} => self.{},",
p.id.to_uppercase(),
p.id
);
}
out.push_str(" _ => 0.0,\n }\n }\n\n");
// `is_active` decides whether this node reaches the shader at all, so the
// default rule is the honest one: the operation is doing something exactly
// when a parameter has moved off its default.
let active_expr = match active {
Some(expr) => format!("({}) != 0.0", compile_expr(expr)),
None => params
.iter()
.map(|p| format!("self.{} != {}", p.id, rust_f32(p.default)))
.collect::<Vec<_>>()
.join(" || "),
};
let _ = writeln!(
out,
" fn is_active(&self) -> bool {{\n {active_expr}\n }}\n"
);
// Only a camera-stage node says anything: the trait's default is the
// scene, which is every other node (D19).
if *camera_stage {
out.push_str(
" fn stage(&self) -> crate::operation::Stage {\n \
crate::operation::Stage::Camera\n }\n\n",
);
}
let _ = writeln!(
out,
" fn wgsl_body(&self) -> String {{\n {}.into()\n }}\n",
raw_string(&node.wgsl_body())
);
out.push_str(" fn uniforms(&self) -> Vec<Uniform> {\n vec![\n");
for u in uniforms {
if let Some(doc) = &u.doc {
out.push_str(&comment(doc, "//", 16));
}
let _ = writeln!(
out,
" Uniform {{ name: {:?}, value: {} }},",
u.name,
compile_expr(&u.expr)
);
}
out.push_str(" ]\n }\n");
if !helper_refs.is_empty() {
out.push_str(
"\n fn helpers(&self) -> &[Helper] {\n HELPERS\n }\n",
);
}
// A node that asked for a widget. Omitted entirely otherwise, so the
// trait's default — one control per parameter — stands.
if let Some(p) = presentation {
let _ = writeln!(
out,
"\n fn presentation(&self) -> Option<Presentation> {{\n\
\x20 Some(Presentation {{\n\
\x20 widgets: vec![{}],\n\
\x20 demand: WidgetDemand {{\n\
\x20 two_dimensional: {},\n\
\x20 precise_pointing: {},\n\
\x20 }},\n\
\x20 params: vec![{}],\n\
\x20 }})\n\
\x20 }}",
p.widgets
.iter()
.map(|w| w.rust())
.collect::<Vec<_>>()
.join(", "),
p.two_dimensional,
p.precise_pointing,
// The generated `ParamId` constants, which are the ids uppercased.
p.params
.iter()
.map(|name| name.to_uppercase())
.collect::<Vec<_>>()
.join(", ")
);
}
out.push_str(" }\n");
emit_tests(out, &ty, params, tests);
out.push_str("}\n\n");
}
fn emit_tests(out: &mut String, ty: &str, params: &[ParamDef], tests: &[TestDef]) {
if tests.is_empty() {
return;
}
// `approx_constant`: an expected uniform value is a physical quantity, and
// one occasionally coincides with a maths constant — half a stop of white
// balance is exp2(0.5), which is √2. Writing `SQRT_2` there would name the
// arithmetic instead of the photography, and the declaration says `half a
// stop` in prose beside it.
out.push_str(
"\n #[cfg(test)]\n\
\x20 #[allow(clippy::approx_constant)]\n\
\x20 mod tests {\n\
\x20 use super::*;\n\n",
);
out.push_str(
" /// One uniform's value, by the name the node declared it under.\n\
\x20 fn uniform(op: &impl Operation, name: &str) -> f32 {\n\
\x20 op.uniforms()\n\
\x20 .into_iter()\n\
\x20 .find(|u| u.name == name)\n\
\x20 .unwrap_or_else(|| panic!(\"no uniform called {name}\"))\n\
\x20 .value\n\
\x20 }\n",
);
for t in tests {
out.push('\n');
let _ = writeln!(out, " #[test]");
let _ = writeln!(out, " fn {}() {{", t.name);
if let Some(why) = &t.why {
out.push_str(&comment(why, "//", 12));
}
// `mut` only where a value is actually set: an unused-mut warning in
// generated code is noise nobody can fix at the source.
let binding = if t.set.is_empty() { "op" } else { "mut op" };
let _ = writeln!(out, " let {binding} = {ty}::new();");
for (param, value) in &t.set {
let id = params
.iter()
.find(|p| &p.id == param)
.map(|p| p.id.to_uppercase())
.unwrap_or_default();
let _ = writeln!(out, " op.set_param({id}, {});", rust_f32(*value));
}
for (name, expected) in &t.expect {
let _ = writeln!(
out,
" let got = uniform(&op, {name:?});\n\
\x20 assert!(\n\
\x20 (got - {e}).abs() <= 1e-5,\n\
\x20 \"{name}: expected {{}}, got {{got}}\",\n\
\x20 {e}\n\
\x20 );",
e = rust_f32(*expected)
);
}
for (name, lo, hi) in &t.expect_range {
let _ = writeln!(
out,
" let got = uniform(&op, {name:?});\n\
\x20 assert!(\n\
\x20 ({lo}..={hi}).contains(&got),\n\
\x20 \"{name}: {{got}} is outside {lo}..={hi}\"\n\
\x20 );",
lo = rust_f32(*lo),
hi = rust_f32(*hi)
);
}
if let Some(active) = t.expect_active {
let (negation, expected) = if active {
("", "active")
} else {
("!", "inactive")
};
let _ = writeln!(
out,
" assert!(\n\
\x20 {negation}op.is_active(),\n\
\x20 \"the operation should be {expected} at these settings\"\n\
\x20 );"
);
}
for needle in &t.expect_wgsl {
let _ = writeln!(
out,
" assert!(\n\
\x20 op.wgsl_body().contains({needle:?}),\n\
\x20 \"the fragment no longer contains {{:?}}\",\n\
\x20 {needle:?}\n\
\x20 );"
);
}
for (helper, needles) in &t.expect_helper_wgsl {
let _ = writeln!(
out,
" let helper = op\n\
\x20 .helpers()\n\
\x20 .iter()\n\
\x20 .find(|h| h.name == {helper:?})\n\
\x20 .expect(\"declares {helper}\");"
);
for needle in needles {
let _ = writeln!(
out,
" assert!(\n\
\x20 helper.source.contains({needle:?}),\n\
\x20 \"{helper} no longer contains {{:?}}\",\n\
\x20 {needle:?}\n\
\x20 );"
);
}
}
out.push_str(" }\n");
}
out.push_str(" }\n");
}
fn emit_chain(out: &mut String, nodes: &[Node]) {
out.push_str(
"/// TRACES: FR-DEV-3a | FR-DEV-3c\n\
/// The default develop chain, in the order `ops/*.yaml` declares.\n\
///\n\
/// Order is data, not code (ARCH §3.4): each node carries an `order:`\n\
/// and this is the sorted result, so reordering the pipeline is an edit\n\
/// to one number in one declaration.\n\
pub fn chain() -> Vec<Box<dyn crate::operation::Operation>> {\n\
\x20 vec![\n",
);
for node in nodes {
let _ = writeln!(
out,
" // ---- {} (order {})",
node.id(),
node.order()
);
if let Some(placement) = node.placement() {
out.push_str(&comment(placement, "//", 8));
}
match node {
Node::Declared(declared) => {
let id = &declared.id;
let _ = writeln!(out, " Box::new({id}::{}::new()),", pascal_case(id));
}
Node::Rust { ty, why_rust, .. } => {
if let Some(why) = why_rust {
out.push_str(" // Hand-written:\n");
out.push_str(&comment(why, "//", 8));
}
let _ = writeln!(out, " Box::new({ty}::new()),");
}
}
}
out.push_str(" ]\n}\n\n");
// The ids, in order, as data — so a test can assert that what the chain
// actually builds matches what the declarations say. This is the only
// check available on a `rust:` node, whose descriptor comes from a type
// this build script cannot read.
let ids: Vec<String> = nodes.iter().map(|n| format!("{:?}", n.id())).collect();
let _ = writeln!(
out,
"/// The node ids `ops/*.yaml` declares, in chain order.\n\
pub static DECLARED_IDS: &[&str] = &[{}];\n",
ids.join(", ")
);
}
// ---------------------------------------------------------------------------
// Rendering helpers
// ---------------------------------------------------------------------------
/// The `ParamDescriptor` constructor call for a declared parameter.
///
/// The kinds are the constructors `descriptor.rs` already offers, named rather
/// than spelled out: `amount` is the −100…+100 shape nearly every photographic
/// control takes, and writing its range in every node would invite one of them
/// to drift.
///
/// The load-time reader calls the *same* constructors from the same
/// [`decl::Kind`] — see `declared/mod.rs`'s `param_descriptor` — so the two
/// produce equal descriptors by construction rather than by coincidence.
fn param_ctor(p: &ParamDef) -> String {
let (id, label) = (&p.id, &p.label);
match &p.kind {
decl::Kind::Stops { min, max } => format!(
"ParamDescriptor::stops({id:?}, {label:?}, {}, {})",
rust_f32(*min),
rust_f32(*max)
),
decl::Kind::Amount => format!("ParamDescriptor::amount({id:?}, {label:?})"),
decl::Kind::Switch => format!("ParamDescriptor::switch({id:?}, {label:?})"),
decl::Kind::Fraction { default } => format!(
"ParamDescriptor::fraction({id:?}, {label:?}, {})",
rust_f32(*default)
),
decl::Kind::Scalar {
min,
max,
default,
unit,
scale,
precision,
} => format!(
"ParamDescriptor::scalar({id:?}, {label:?}, {}, {}, {}, {}, {}, {precision})",
rust_f32(*min),
rust_f32(*max),
rust_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::Kind::Enum { variants } => {
let keys: Vec<String> = variants
.iter()
.map(|v| format!("LocalizedKey({v:?})"))
.collect();
format!(
"ParamDescriptor::choice({id:?}, {label:?}, vec![{}])",
keys.join(", ")
)
}
}
}
/// A WGSL block as a Rust raw string literal.
///
/// Raw so the WGSL reads as itself in the generated file — escaped quotes and
/// `\n` would make the one thing a reader comes to generated source to check
/// unreadable. The hash count grows if the source contains a `"` sequence.
///
/// **Emitted at column zero, deliberately.** The composer indents each line of
/// a fragment as it places it into the generated shader, so a literal indented
/// here to look tidy in `nodes.rs` would arrive in the WGSL indented twice.
/// The hand-written operations had the same shape for the same reason.
///
/// The *content* it wraps comes from the shared reader — `Declaration::
/// wgsl_body` and `HelperDef::source` — because that content is what reaches
/// the composed shader, and a run-time node has to produce the same
/// characters. All this adds is the quoting.
fn raw_string(body: &str) -> String {
let mut hashes = String::new();
while body.contains(&format!("\"{hashes}")) {
hashes.push('#');
}
format!("r{hashes}\"{body}\"{hashes}")
}
/// A f64 from YAML as a Rust `f32` literal.
///
/// Always with a decimal point: `100f32` parses, but `100` in a position
/// expecting `f32` does not, and the generated arithmetic mixes the two
/// freely.
fn rust_f32(n: f64) -> String {
let s = format!("{n:?}");
if s.contains('.') || s.contains('e') || s.contains("inf") || s.contains("NaN") {
format!("{s}f32")
} else {
format!("{s}.0f32")
}
}
fn local_helper_const(name: &str) -> String {
format!("{}_HELPER", name.to_uppercase())
}
fn pascal_case(id: &str) -> String {
id.split('_')
.filter(|s| !s.is_empty())
.map(|word| {
let mut chars = word.chars();
match chars.next() {
Some(first) => first.to_ascii_uppercase().to_string() + chars.as_str(),
None => String::new(),
}
})
.collect()
}
/// Wrap prose as comments at a given indent, keeping the author's line breaks.
fn comment(text: &str, marker: &str, indent: usize) -> String {
let pad = " ".repeat(indent);
let mut out = String::new();
for line in text.trim_end().lines() {
if line.trim().is_empty() {
let _ = writeln!(out, "{pad}{marker}");
} else {
let _ = writeln!(out, "{pad}{marker} {line}");
}
}
out
}