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).
945 lines
35 KiB
Rust
945 lines
35 KiB
Rust
//! 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
|
||
}
|