Files
DarkRoom/core/dr-pipeline/build.rs
T
dtourolleandClaude Opus 5 0c3b8cb1c4 Hand out descriptors a declaration could produce
`Operation::descriptor()` returned `&'static OpDescriptor`, and that lifetime
is the whole reason a build-time node is free and a run-time node is
impossible: only a compile-time literal can satisfy it, so no amount of
reading `ops/*.yaml` at startup could ever produce a descriptor the rest of
the application would accept. FR-PLG-2 says a bundled operation and a
third-party plugin are the same kind of thing, differing only in where the
file was found — and a lifetime outsiders cannot meet is exactly the second,
weaker format that requirement forbids.

So a descriptor is now owned and handed out as `Arc<OpDescriptor>`, with `Vec`
where it held `&'static` slices. `Arc` rather than a `&self`-borrowed
reference because the callers want to *keep* it: the develop panel collects
descriptors and then mutates the graph, and a borrow would tie the
descriptor's lifetime to a borrow of the operation it came from, which is the
one thing `&'static` was doing right.

The identifier newtypes deliberately did not follow. `ParamId` is `Copy`, is
compared in `match` arms against generated constants, is a map key in the
sidecar and history, and reaches Slint model rows; an `Arc<str>` there would
cost a refcount on every one of those and would take `match id { EXPOSURE =>
.. }` away from the generated code. They gain an interner instead, which is
honest about its lifetime rather than pretending to one — the set of ids is
bounded by deduplication and is process-lifetime by construction, because the
sidecar on disk names its parameters and an id has to stay resolvable for as
long as any edit naming it can be opened.

No behaviour changes. Every descriptor that was a `static` is a `LazyLock`
initialiser now, `Operation::helpers` borrows from `self` instead of being
`'static` so a future run-time node can own its list, and `Warp` and `Framing`
follow `Operation` so there is one shape rather than two.

The one place a descriptor is read per frame is `compose_full`, which takes
`descriptor().id` to prefix each active operation's uniforms, and `dr-ui`
composes on every frame it draws. That is a dozen atomic increments beside a
composition that is already building several kilobytes of WGSL on the same
call; it is noted at the trait method rather than left for a profiler to find.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 19:02:25 +02:00

2021 lines
71 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.
//!
//! 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};
use serde_norway::{Mapping, Value};
/// 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 = "_helpers.yaml";
const GENERATED: &str = "nodes.rs";
fn main() {
println!("cargo:rerun-if-changed={OPS_DIR}");
println!("cargo:rerun-if-changed=build.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);
}
// ---------------------------------------------------------------------------
// The declaration model
// ---------------------------------------------------------------------------
/// A WGSL helper function, either shared or declared by one node.
struct HelperDef {
name: String,
doc: Option<String>,
wgsl: String,
}
/// One parameter of a declared node.
struct ParamDef {
id: String,
doc: Option<String>,
/// The `ParamDescriptor` constructor call, already rendered.
ctor: String,
/// Needed to generate `is_active` and to range-check test values.
default: f64,
min: f64,
max: f64,
}
/// A uniform the node's fragment reads, and the expression computing it.
struct UniformDef {
name: String,
doc: Option<String>,
/// The Rust expression, compiled from the declared one.
rust: String,
}
/// A node's `presentation:` block, ready to render as a `Presentation`.
struct PresentationDef {
/// Rendered `WidgetKind` paths, most preferred first.
widgets: Vec<String>,
/// The owned parameters' generated `ParamId` constants, in widget order.
params: Vec<String>,
two_dimensional: bool,
precise_pointing: bool,
}
/// One generated `#[test]`.
struct TestDef {
name: String,
why: Option<String>,
set: Vec<(String, f64)>,
expect: Vec<(String, f64)>,
expect_range: Vec<(String, f64, f64)>,
expect_active: Option<bool>,
expect_wgsl: Vec<String>,
expect_helper_wgsl: Vec<(String, Vec<String>)>,
}
/// The attributes an operation declares, validated against the vocabulary.
///
/// **Required, and non-empty.** An operation with no attribute is invisible to
/// a frontend that filters by them, and a control that silently does not exist
/// is a far worse failure than a build that stops — especially when the cause
/// is one missing line in a YAML file nobody had reason to open. Failing here
/// costs whoever adds an operation ten seconds; failing at runtime costs a
/// photographer a control they cannot find and cannot know is missing.
///
/// The vocabulary is closed on purpose. A typo would otherwise invent a
/// category containing exactly one operation, which is indistinguishable from
/// a deliberate new one until somebody notices the tab with a single control
/// in it.
fn read_attributes(root: &Mapping) -> Result<Vec<String>, String> {
const KNOWN: [&str; 6] = ["tone", "colour", "detail", "optics", "geometry", "effect"];
let value = root.get("attributes").ok_or_else(|| {
format!(
"missing `attributes:`; every operation must say what it is about, \
one or more of {KNOWN:?}. It is what lets the panel group \
operations without naming any of them (ARCH §4.3a)."
)
})?;
let list = value
.as_sequence()
.ok_or("`attributes:` must be a list, even with one entry")?;
let mut out = Vec::new();
for entry in list {
let name = as_str(entry, "attributes")?;
if !KNOWN.contains(&name) {
return Err(format!(
"unknown attribute {name:?}; expected one of {KNOWN:?}"
));
}
if out.contains(&name.to_string()) {
return Err(format!("attribute {name:?} is listed twice"));
}
out.push(name.to_string());
}
if out.is_empty() {
return Err("`attributes:` is empty; an operation with no attribute \
would not appear in a panel that groups by them"
.into());
}
Ok(out)
}
/// A node: either declared in full, or a pointer to a hand-written type.
enum Node {
Declared {
id: String,
label: String,
order: i64,
/// What the operation is about (ARCH §4.3a). Never empty — see
/// `read_attributes`.
attributes: Vec<String>,
doc: Option<String>,
placement: Option<String>,
params: Vec<ParamDef>,
uniforms: Vec<UniformDef>,
/// Names of shared helpers, in declaration order.
shared_helpers: Vec<String>,
/// Helpers this node defines for itself.
local_helpers: Vec<HelperDef>,
wgsl: String,
/// `None` means the default rule: active when any parameter has moved.
active: Option<String>,
tests: Vec<TestDef>,
/// `None` — the usual case — means one control per parameter.
///
/// Boxed so the rare node that declares one does not widen every
/// `Node` value by the size of a presentation it does not have.
presentation: Option<Box<PresentationDef>>,
},
Rust {
id: String,
order: i64,
/// The type in `crate::ops` implementing `Operation`.
ty: String,
why_rust: Option<String>,
placement: Option<String>,
},
}
impl Node {
fn id(&self) -> &str {
match self {
Node::Declared { id, .. } | Node::Rust { id, .. } => id,
}
}
fn order(&self) -> i64 {
match self {
Node::Declared { order, .. } | Node::Rust { order, .. } => *order,
}
}
fn placement(&self) -> Option<&str> {
match self {
Node::Declared { placement, .. } | Node::Rust { placement, .. } => placement.as_deref(),
}
}
}
// ---------------------------------------------------------------------------
// Driver
// ---------------------------------------------------------------------------
fn generate(ops_dir: &Path, src_ops: &Path) -> Result<String, String> {
let shared = read_helpers(&ops_dir.join(HELPERS_FILE))?;
let shared_names: BTreeSet<&str> = shared.helpers.iter().map(|h| h.name.as_str()).collect();
let mut nodes = Vec::new();
for path in node_files(ops_dir)? {
let name = file_stem(&path);
let node = read_node(&path, &shared_names)
.map_err(|e| format!("{}/{}.yaml: {e}", OPS_DIR, name))?;
// 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 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 { id, .. } = node else {
continue;
};
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(())
}
// ---------------------------------------------------------------------------
// Reading: shared helpers
// ---------------------------------------------------------------------------
struct SharedHelpers {
doc: Option<String>,
helpers: Vec<HelperDef>,
}
fn read_helpers(path: &Path) -> Result<SharedHelpers, String> {
let doc = read_yaml(path)?;
let root = as_mapping(&doc, HELPERS_FILE)?;
let module_doc = opt_prose(root, "doc", HELPERS_FILE)?;
let helpers = root
.get("helpers")
.ok_or_else(|| format!("{HELPERS_FILE}: missing `helpers:` map"))?;
let helpers = as_mapping(helpers, "helpers")?;
let mut out = Vec::new();
for (name, spec) in helpers {
let name = as_str(name, "a helper name")?.to_string();
let ctx = format!("helpers.{name}");
let spec = as_mapping(spec, &ctx)?;
let wgsl = spec
.get("wgsl")
.ok_or_else(|| format!("{HELPERS_FILE}: `{ctx}` has no `wgsl:`"))?;
let wgsl = as_str(wgsl, &format!("{ctx}.wgsl"))?.trim_end().to_string();
// The composer deduplicates by name, so a helper whose declared name
// is not the function it defines would be emitted under one name and
// called under another.
if !wgsl.contains(&format!("fn {name}(")) {
return Err(format!(
"{HELPERS_FILE}: `{ctx}` does not define `fn {name}(`. The key \
is the name the composer deduplicates on, so it has to be the \
function actually declared."
));
}
out.push(HelperDef {
doc: opt_prose(spec, "doc", &ctx)?,
name,
wgsl,
});
}
if out.is_empty() {
return Err(format!("{HELPERS_FILE}: `helpers:` is empty"));
}
Ok(SharedHelpers {
doc: module_doc,
helpers: out,
})
}
// ---------------------------------------------------------------------------
// Reading: a node
// ---------------------------------------------------------------------------
fn read_node(path: &Path, shared: &BTreeSet<&str>) -> Result<Node, String> {
let doc = read_yaml(path)?;
let root = as_mapping(&doc, "the document")?;
let id = as_str(root.get("id").ok_or("missing `id:`")?, "id")?.to_string();
check_ident(&id, "id")?;
let order = root
.get("order")
.ok_or("missing `order:`; it is what places this node in the chain")?
.as_i64()
.ok_or("`order` must be a whole number")?;
let placement = opt_prose(root, "placement", "placement")?;
// A `rust:` node describes where a hand-written type sits, and nothing
// else — its descriptor comes from the type. Mixing the two forms would
// mean two sources for one node's parameters.
if let Some(ty) = root.get("rust") {
let ty = as_str(ty, "rust")?.to_string();
check_type_name(&ty)?;
for key in ["params", "uniforms", "wgsl", "helpers", "define", "label"] {
if root.contains_key(key) {
return Err(format!(
"`{key}` is meaningless on a `rust:` node — {ty} publishes \
its own descriptor. Remove one or the other."
));
}
}
return Ok(Node::Rust {
id,
order,
ty,
why_rust: opt_prose(root, "why_rust", "why_rust")?,
placement,
});
}
let label = as_str(root.get("label").ok_or("missing `label:`")?, "label")?.to_string();
let attributes = read_attributes(root)?;
let params = read_params(root)?;
let param_names: BTreeSet<&str> = params.iter().map(|p| p.id.as_str()).collect();
let local_helpers = read_local_helpers(root)?;
let shared_helpers = read_shared_refs(root, shared, &local_helpers)?;
let uniforms = read_uniforms(root, &param_names)?;
let wgsl = as_str(root.get("wgsl").ok_or("missing `wgsl:`")?, "wgsl")?
.trim_end()
.to_string();
if wgsl.trim().is_empty() {
return Err("`wgsl:` is empty; a node that changes nothing is not a node".into());
}
let active = match root.get("active") {
None => None,
Some(v) => {
let expr = as_str(v, "active")?;
Some(compile_expr(expr, &param_names).map_err(|e| format!("`active`: {e}"))?)
}
};
let uniform_names: BTreeSet<&str> = uniforms.iter().map(|u| u.name.as_str()).collect();
let helper_names: BTreeSet<&str> = shared_helpers
.iter()
.map(String::as_str)
.chain(local_helpers.iter().map(|h| h.name.as_str()))
.collect();
let tests = read_tests(root, &params, &uniform_names, &helper_names)?;
let presentation = read_presentation(root, &param_names)?;
Ok(Node::Declared {
id,
label,
order,
doc: opt_prose(root, "doc", "doc")?,
attributes,
placement,
params,
uniforms,
shared_helpers,
local_helpers,
wgsl,
active,
tests,
presentation,
})
}
/// The widgets a node would like, in descending order of preference.
///
/// Optional, and absent on nearly every node — one control per parameter is
/// the right answer for a list of unrelated sliders, which is what most
/// operations are. Declaring this says that several parameters form *one*
/// conceptual control.
///
/// It is a hint and nothing more (ARCH §4.3a). A frontend implementing none of
/// the named widgets renders the parameters as ordinary sliders and the edit
/// still works, which is why the list can safely name widgets that do not
/// exist yet.
fn read_presentation(
root: &Mapping,
params: &BTreeSet<&str>,
) -> Result<Option<Box<PresentationDef>>, String> {
let Some(value) = root.get("presentation") else {
return Ok(None);
};
let m = as_mapping(value, "presentation")?;
let widgets = m
.get("widgets")
.and_then(Value::as_sequence)
.ok_or("`presentation` needs a `widgets:` list, most preferred first")?;
if widgets.is_empty() {
return Err("`presentation.widgets` is empty; omit `presentation:` instead".into());
}
let widgets = widgets
.iter()
.enumerate()
.map(|(i, w)| {
let name = as_str(w, &format!("presentation.widgets[{i}]"))?;
// Spelled in the YAML the way the enum spells it, so a node
// declaration and `descriptor.rs` cannot drift into two
// vocabularies for one idea.
match name {
"tone_curve" => Ok("WidgetKind::ToneCurve"),
"colour_wheel" => Ok("WidgetKind::ColourWheel"),
"crop_overlay" => Ok("WidgetKind::CropOverlay"),
"gradient_handle" => Ok("WidgetKind::GradientHandle"),
"brush_mask" => Ok("WidgetKind::BrushMask"),
"white_point" => Ok("WidgetKind::WhitePoint"),
other => Err(format!(
"`presentation.widgets[{i}]` is `{other}`; expected tone_curve, \
colour_wheel, crop_overlay, gradient_handle, brush_mask or white_point"
)),
}
})
.collect::<Result<Vec<_>, _>>()?;
// The parameters the widget owns, in the order it expects them. Checked
// against the node's own list, because a typo here would silently leave a
// parameter out of the widget *and* out of the panel — the widget claims
// it, and the generic path skips what the widget claimed.
let owned = m
.get("params")
.and_then(Value::as_sequence)
.ok_or("`presentation` needs a `params:` list naming what the widget owns")?;
let owned = owned
.iter()
.enumerate()
.map(|(i, p)| {
let name = as_str(p, &format!("presentation.params[{i}]"))?;
if !params.contains(name) {
return Err(format!(
"`presentation.params[{i}]` is `{name}`, which this node does not declare"
));
}
Ok(name.to_uppercase())
})
.collect::<Result<Vec<_>, _>>()?;
let demand = match m.get("demand") {
None => (false, false),
Some(d) => {
let d = as_mapping(d, "presentation.demand")?;
let flag = |key: &str| -> Result<bool, String> {
match d.get(key) {
None => Ok(false),
Some(v) => v.as_bool().ok_or_else(|| {
format!("`presentation.demand.{key}` must be true or false")
}),
}
};
// Deliberately only these two. ARCH §4.3a forbids a demand
// carrying pixels, breakpoints or a platform name — those are the
// frontend's to decide — so there is no key here to write one in.
(flag("two_dimensional")?, flag("precise_pointing")?)
}
};
Ok(Some(Box::new(PresentationDef {
widgets: widgets.into_iter().map(str::to_string).collect(),
params: owned,
two_dimensional: demand.0,
precise_pointing: demand.1,
})))
}
fn read_params(root: &Mapping) -> Result<Vec<ParamDef>, String> {
let params = root
.get("params")
.ok_or("missing `params:`; an operation with no parameters has nothing to control")?;
let params = as_mapping(params, "params")?;
let mut out = Vec::new();
for (id, spec) in params {
let id = as_str(id, "a parameter name")?.to_string();
check_ident(&id, &format!("params.{id}"))?;
let ctx = format!("params.{id}");
let spec = as_mapping(spec, &ctx)?;
let label = as_str(
spec.get("label")
.ok_or_else(|| format!("`{ctx}` has no `label:`"))?,
&format!("{ctx}.label"),
)?
.to_string();
let kind = as_str(
spec.get("kind")
.ok_or_else(|| format!("`{ctx}` has no `kind:`"))?,
&format!("{ctx}.kind"),
)?;
let (ctor, default, min, max) = param_ctor(&id, &label, kind, spec, &ctx)?;
out.push(ParamDef {
id,
doc: opt_prose(spec, "doc", &ctx)?,
ctor,
default,
min,
max,
});
}
if out.is_empty() {
return Err("`params:` is empty".into());
}
Ok(out)
}
/// Render the `ParamDescriptor` constructor for a declared parameter kind.
///
/// 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.
fn param_ctor(
id: &str,
label: &str,
kind: &str,
spec: &Mapping,
ctx: &str,
) -> Result<(String, f64, f64, f64), String> {
let need = |key: &str| -> Result<f64, String> {
spec.get(key)
.and_then(Value::as_f64)
.ok_or_else(|| format!("`{ctx}` is `kind: {kind}` and needs a numeric `{key}:`"))
};
let opt = |key: &str, fallback: f64| -> f64 {
spec.get(key).and_then(Value::as_f64).unwrap_or(fallback)
};
Ok(match kind {
"stops" => {
let (min, max) = (need("min")?, need("max")?);
(
format!(
"ParamDescriptor::stops({:?}, {:?}, {}, {})",
id,
label,
rust_f32(min),
rust_f32(max)
),
0.0,
min,
max,
)
}
"amount" => (
format!("ParamDescriptor::amount({id:?}, {label:?})"),
0.0,
-100.0,
100.0,
),
"switch" => (
format!("ParamDescriptor::switch({id:?}, {label:?})"),
0.0,
0.0,
1.0,
),
"fraction" => {
let default = opt("default", 0.0);
(
format!(
"ParamDescriptor::fraction({:?}, {:?}, {})",
id,
label,
rust_f32(default)
),
default,
0.0,
1.0,
)
}
"scalar" => {
let (min, max) = (need("min")?, need("max")?);
let default = opt("default", 0.0);
let unit = match spec.get("unit").and_then(Value::as_str).unwrap_or("none") {
"none" => "Unit::None",
"stops" => "Unit::Stops",
"kelvin" => "Unit::Kelvin",
"percent" => "Unit::Percent",
other => {
return Err(format!(
"`{ctx}.unit` is `{other}`; expected none, stops, kelvin or percent"
))
}
};
let scale = match spec
.get("scale")
.and_then(Value::as_str)
.unwrap_or("linear")
{
"linear" => "Scale::Linear",
"perceptual" => "Scale::Perceptual",
other => {
return Err(format!(
"`{ctx}.scale` is `{other}`; expected linear or perceptual"
))
}
};
let precision = spec
.get("precision")
.and_then(Value::as_u64)
.ok_or_else(|| format!("`{ctx}` is `kind: scalar` and needs `precision:`"))?;
(
format!(
"ParamDescriptor::scalar({:?}, {:?}, {}, {}, {}, {}, {}, {})",
id,
label,
rust_f32(min),
rust_f32(max),
rust_f32(default),
unit,
scale,
precision
),
default,
min,
max,
)
}
// A fixed list of named alternatives. The value is the chosen index,
// so the range is the list's own bounds and a node declaring one needs
// no `min:`/`max:` of its own.
//
// Variants are localisation keys, like every other label in a node —
// the core never holds a display string (NFR-A11Y-1). The default is
// always the first, so `reset` means the same thing here as everywhere
// else; a node whose neutral choice is not first has listed them in
// the wrong order.
"enum" => {
let variants = spec
.get("variants")
.and_then(Value::as_sequence)
.ok_or_else(|| format!("`{ctx}` is `kind: enum` and needs a `variants:` list"))?;
if variants.len() < 2 {
return Err(format!(
"`{ctx}.variants` lists {} choice(s); a control the user \
cannot change is not a control",
variants.len()
));
}
let keys = variants
.iter()
.enumerate()
.map(|(i, v)| {
as_str(v, &format!("{ctx}.variants[{i}]"))
.map(|k| format!("LocalizedKey({k:?})"))
})
.collect::<Result<Vec<_>, _>>()?;
(
format!(
"ParamDescriptor::choice({:?}, {:?}, vec![{}])",
id,
label,
keys.join(", ")
),
0.0,
0.0,
(variants.len() - 1) as f64,
)
}
other => {
return Err(format!(
"`{ctx}.kind` is `{other}`; expected stops, amount, switch, \
fraction, scalar or enum"
))
}
})
.and_then(|(ctor, default, min, max): (String, f64, f64, f64)| {
// Caught at build time rather than surfacing as a control that opens
// where it cannot be dragged back to.
if min >= max {
return Err(format!(
"`{ctx}` has an empty range: min {min} >= max {max}"
));
}
if default < min || default > max {
return Err(format!(
"`{ctx}` has default {default} outside its range {min}..{max}"
));
}
Ok((ctor, default, min, max))
})
}
fn read_local_helpers(root: &Mapping) -> Result<Vec<HelperDef>, String> {
let Some(define) = root.get("define") else {
return Ok(Vec::new());
};
let define = as_mapping(define, "define")?;
let mut out = Vec::new();
for (name, spec) in define {
let name = as_str(name, "a helper name under `define`")?.to_string();
let ctx = format!("define.{name}");
// Shorthand: the value may be the WGSL directly, or a mapping with
// prose beside it. Most node-local helpers carry their explanation in
// the WGSL itself, so the shorthand is the common case.
let (doc, wgsl) = match spec {
Value::String(s) => (None, s.trim_end().to_string()),
other => {
let m = as_mapping(other, &ctx)?;
let wgsl = as_str(
m.get("wgsl")
.ok_or_else(|| format!("`{ctx}` has no `wgsl:`"))?,
&format!("{ctx}.wgsl"),
)?
.trim_end()
.to_string();
(opt_prose(m, "doc", &ctx)?, wgsl)
}
};
if !wgsl.contains(&format!("fn {name}(")) {
return Err(format!("`{ctx}` does not define `fn {name}(`"));
}
out.push(HelperDef { name, doc, wgsl });
}
Ok(out)
}
fn read_shared_refs(
root: &Mapping,
shared: &BTreeSet<&str>,
local: &[HelperDef],
) -> Result<Vec<String>, String> {
let Some(list) = root.get("helpers") else {
return Ok(Vec::new());
};
let list = list
.as_sequence()
.ok_or("`helpers` must be a list of helper names")?;
let mut out = Vec::new();
let mut seen = BTreeSet::new();
for item in list {
let name = as_str(item, "a helper name")?.to_string();
if !shared.contains(name.as_str()) {
let known: Vec<&str> = shared.iter().copied().collect();
return Err(format!(
"`helpers` names `{name}`, which {HELPERS_FILE} does not \
define. Known helpers: {}",
known.join(", ")
));
}
if local.iter().any(|h| h.name == name) {
return Err(format!(
"`{name}` is both requested from {HELPERS_FILE} and redefined \
under `define`. The composer deduplicates by name, so one of \
the two definitions would silently win."
));
}
if !seen.insert(name.clone()) {
return Err(format!("`helpers` lists `{name}` twice"));
}
out.push(name);
}
Ok(out)
}
fn read_uniforms(root: &Mapping, params: &BTreeSet<&str>) -> Result<Vec<UniformDef>, String> {
let uniforms = root
.get("uniforms")
.ok_or("missing `uniforms:`; the fragment has nothing to read otherwise")?;
let uniforms = as_mapping(uniforms, "uniforms")?;
let mut out = Vec::new();
for (name, spec) in uniforms {
let name = as_str(name, "a uniform name")?.to_string();
check_ident(&name, &format!("uniforms.{name}"))?;
let ctx = format!("uniforms.{name}");
// Shorthand: `gain: exp2(exposure)`, or a mapping carrying prose.
let (doc, expr) = match spec {
Value::String(s) => (None, s.clone()),
other => {
let m = as_mapping(other, &ctx)?;
let value = m
.get("value")
.ok_or_else(|| format!("`{ctx}` has neither a bare expression nor `value:`"))?;
(
opt_prose(m, "doc", &ctx)?,
as_str(value, &format!("{ctx}.value"))?.to_string(),
)
}
};
let rust = compile_expr(&expr, params).map_err(|e| format!("`{ctx}`: {e}"))?;
out.push(UniformDef { name, doc, rust });
}
if out.is_empty() {
return Err("`uniforms:` is empty".into());
}
Ok(out)
}
fn read_tests(
root: &Mapping,
params: &[ParamDef],
uniforms: &BTreeSet<&str>,
helpers: &BTreeSet<&str>,
) -> Result<Vec<TestDef>, String> {
let Some(list) = root.get("tests") else {
return Ok(Vec::new());
};
let list = list.as_sequence().ok_or("`tests` must be a list")?;
let mut out = Vec::new();
let mut seen = BTreeSet::new();
for item in list {
let m = as_mapping(item, "a test")?;
let name = as_str(
m.get("name").ok_or("a test has no `name:`")?,
"tests[].name",
)?
.to_string();
check_ident(&name, &format!("tests.{name}.name"))?;
if !seen.insert(name.clone()) {
return Err(format!("two tests are both called `{name}`"));
}
let ctx = format!("tests.{name}");
let mut set = Vec::new();
if let Some(v) = m.get("set") {
for (k, val) in as_mapping(v, &format!("{ctx}.set"))? {
let key = as_str(k, &format!("{ctx}.set key"))?.to_string();
let Some(param) = params.iter().find(|p| p.id == key) else {
return Err(format!(
"`{ctx}.set` names `{key}`, which is not a parameter"
));
};
let value = val
.as_f64()
.ok_or_else(|| format!("`{ctx}.set.{key}` must be a number"))?;
// Values reach `set_param` already clamped by the graph, so a
// test setting an out-of-range value would be asserting
// against something that cannot happen.
if value < param.min || value > param.max {
return Err(format!(
"`{ctx}.set.{key}` is {value}, outside the parameter's \
range {}..{}. The graph clamps before an operation \
sees a value, so this test could never run as written.",
param.min, param.max
));
}
set.push((key, value));
}
}
let mut expect = Vec::new();
if let Some(v) = m.get("expect") {
for (k, val) in as_mapping(v, &format!("{ctx}.expect"))? {
let key = as_str(k, &format!("{ctx}.expect key"))?.to_string();
if !uniforms.contains(key.as_str()) {
return Err(format!(
"`{ctx}.expect` names `{key}`, which is not a uniform of this node"
));
}
let value = val
.as_f64()
.ok_or_else(|| format!("`{ctx}.expect.{key}` must be a number"))?;
expect.push((key, value));
}
}
let mut expect_range = Vec::new();
if let Some(v) = m.get("expect_range") {
for (k, val) in as_mapping(v, &format!("{ctx}.expect_range"))? {
let key = as_str(k, &format!("{ctx}.expect_range key"))?.to_string();
if !uniforms.contains(key.as_str()) {
return Err(format!(
"`{ctx}.expect_range` names `{key}`, which is not a uniform"
));
}
let pair = val
.as_sequence()
.filter(|s| s.len() == 2)
.ok_or_else(|| format!("`{ctx}.expect_range.{key}` must be [low, high]"))?;
let lo = pair[0]
.as_f64()
.ok_or_else(|| format!("`{ctx}.expect_range.{key}` low must be a number"))?;
let hi = pair[1]
.as_f64()
.ok_or_else(|| format!("`{ctx}.expect_range.{key}` high must be a number"))?;
expect_range.push((key, lo, hi));
}
}
let mut expect_wgsl = Vec::new();
if let Some(v) = m.get("expect_wgsl") {
for item in v
.as_sequence()
.ok_or_else(|| format!("`{ctx}.expect_wgsl` must be a list of strings"))?
{
expect_wgsl.push(as_str(item, &format!("{ctx}.expect_wgsl[]"))?.to_string());
}
}
let mut expect_helper_wgsl = Vec::new();
if let Some(v) = m.get("expect_helper_wgsl") {
for (k, val) in as_mapping(v, &format!("{ctx}.expect_helper_wgsl"))? {
let key = as_str(k, &format!("{ctx}.expect_helper_wgsl key"))?.to_string();
if !helpers.contains(key.as_str()) {
return Err(format!(
"`{ctx}.expect_helper_wgsl` names `{key}`, which this node does not use"
));
}
let mut needles = Vec::new();
for item in val
.as_sequence()
.ok_or_else(|| format!("`{ctx}.expect_helper_wgsl.{key}` must be a list"))?
{
needles.push(
as_str(item, &format!("{ctx}.expect_helper_wgsl.{key}[]"))?.to_string(),
);
}
expect_helper_wgsl.push((key, needles));
}
}
let expect_active = match m.get("expect_active") {
None => None,
Some(v) => Some(
v.as_bool()
.ok_or_else(|| format!("`{ctx}.expect_active` must be true or false"))?,
),
};
if expect.is_empty()
&& expect_range.is_empty()
&& expect_wgsl.is_empty()
&& expect_helper_wgsl.is_empty()
&& expect_active.is_none()
{
return Err(format!("`{ctx}` asserts nothing"));
}
out.push(TestDef {
name,
why: opt_prose(m, "why", &ctx)?,
set,
expect,
expect_range,
expect_active,
expect_wgsl,
expect_helper_wgsl,
});
}
Ok(out)
}
// ---------------------------------------------------------------------------
// The expression language
// ---------------------------------------------------------------------------
//
// Uniforms are derived from parameters — `exp2(exposure)`, `blacks / 100 *
// 0.02` — and that derivation is the one piece of a node that is genuinely
// computation rather than description. It is kept to arithmetic over the
// node's own parameters and 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.
//
// Compiled to Rust rather than interpreted, so an unknown name or a wrong
// arity is a build error naming the file, and the arithmetic itself costs
// nothing at runtime.
#[derive(Debug)]
enum Expr {
Num(f64),
Param(String),
Neg(Box<Expr>),
Bin(char, Box<Expr>, Box<Expr>),
Call(String, Vec<Expr>),
}
/// Compile a declared expression to a Rust `f32` expression.
fn compile_expr(src: &str, params: &BTreeSet<&str>) -> Result<String, 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]
));
}
render(&expr, params).map(|s| unwrap_parens(&s))
}
/// 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()
}
}
#[derive(Debug, Clone, PartialEq)]
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()),
}
}
}
/// The maths functions a node may call, and the Rust each becomes.
///
/// 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.
fn render(expr: &Expr, params: &BTreeSet<&str>) -> Result<String, String> {
Ok(match expr {
Expr::Num(n) => rust_f32(*n),
Expr::Param(name) => {
if !params.contains(name.as_str()) {
let known: Vec<&str> = params.iter().copied().collect();
return Err(format!(
"`{name}` is not a parameter of this node. Its parameters \
are: {}",
known.join(", ")
));
}
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, params)?)),
Expr::Bin(op, l, r) => format!("({} {op} {})", render(l, params)?, render(r, params)?),
Expr::Call(name, args) => {
let rendered: Vec<String> = args
.iter()
.map(|a| render(a, params).map(|s| unwrap_parens(&s)))
.collect::<Result<_, _>>()?;
let arity = |n: usize| -> Result<(), String> {
if rendered.len() != n {
return Err(format!(
"`{name}` takes {n} argument(s), given {}",
rendered.len()
));
}
Ok(())
};
match name.as_str() {
"exp2" | "log2" | "exp" | "sqrt" | "abs" | "floor" | "ceil" | "round" => {
arity(1)?;
format!("f32::{name}({})", rendered[0])
}
"pow" => {
arity(2)?;
format!("f32::powf({}, {})", rendered[0], rendered[1])
}
"min" | "max" => {
arity(2)?;
format!("f32::{name}({}, {})", rendered[0], rendered[1])
}
"clamp" => {
arity(3)?;
format!(
"f32::clamp({}, {}, {})",
rendered[0], rendered[1], rendered[2]
)
}
"mix" => {
arity(3)?;
// Spelled out rather than called: Rust has no `mix`, and
// the linear form is what WGSL's `mix` means.
format!(
"({a} + ({b} - {a}) * {t})",
a = rendered[0],
b = rendered[1],
t = rendered[2]
)
}
other => {
return Err(format!(
"`{other}` is not one of the maths functions a node may \
call. Available: exp2, log2, exp, sqrt, abs, floor, \
ceil, round, pow, min, max, clamp, mix"
))
}
}
}
})
}
// ---------------------------------------------------------------------------
// 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 { .. } = node {
emit_node(&mut out, node)?;
}
}
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,
wgsl_literal(&h.wgsl, h.doc.as_deref())
);
}
// 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: &Node) -> Result<(), String> {
let Node::Declared {
id,
label,
attributes,
doc,
params,
uniforms,
shared_helpers,
local_helpers,
wgsl,
active,
tests,
presentation,
..
} = node
else {
return Ok(());
};
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, " {},", p.ctor);
}
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.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,
wgsl_literal(&h.wgsl, h.doc.as_deref())
);
}
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!("({expr}) != 0.0"),
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"
);
let _ = writeln!(
out,
" fn wgsl_body(&self) -> String {{\n {}.into()\n }}\n",
wgsl_literal(wgsl, None)
);
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, u.rust
);
}
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.join(", "),
p.two_dimensional,
p.precise_pointing,
p.params.join(", ")
);
}
out.push_str(" }\n");
emit_tests(out, &ty, params, tests);
out.push_str("}\n\n");
Ok(())
}
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 { 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
// ---------------------------------------------------------------------------
/// 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.
fn wgsl_literal(wgsl: &str, doc: Option<&str>) -> String {
let mut body = String::new();
// Prose from the declaration becomes a WGSL comment above the function,
// which is where it is useful — the generated shader is what gets read
// when a compile fails.
if let Some(doc) = doc {
for line in doc.trim_end().lines() {
if line.trim().is_empty() {
body.push_str("//\n");
} else {
let _ = writeln!(body, "// {line}");
}
}
}
body.push_str(wgsl.trim_matches('\n').trim_end());
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
}
// ---------------------------------------------------------------------------
// YAML access
// ---------------------------------------------------------------------------
fn read_yaml(path: &Path) -> Result<Value, String> {
let text = std::fs::read_to_string(path)
.map_err(|e| format!("cannot read {}: {e}", path.display()))?;
serde_norway::from_str(&text).map_err(|e| format!("{}: not valid YAML: {e}", path.display()))
}
fn as_mapping<'a>(value: &'a Value, ctx: &str) -> Result<&'a Mapping, String> {
value
.as_mapping()
.ok_or_else(|| format!("`{ctx}` must be a mapping"))
}
fn as_str<'a>(value: &'a Value, ctx: &str) -> Result<&'a str, String> {
value
.as_str()
.ok_or_else(|| format!("`{ctx}` must be a string"))
}
fn opt_prose(map: &Mapping, key: &str, ctx: &str) -> Result<Option<String>, String> {
match map.get(key) {
None => Ok(None),
Some(v) => v
.as_str()
.map(|s| Some(s.trim_end().to_string()))
.ok_or_else(|| format!("`{ctx}.{key}` must be a string")),
}
}
/// Names reaching generated Rust have to be identifiers, and must not be
/// keywords — `ParamId("type")` would generate a struct field called `type`.
fn check_ident(name: &str, ctx: &str) -> Result<(), String> {
if name.is_empty() {
return Err(format!("`{ctx}` is empty"));
}
let head_ok = name
.chars()
.next()
.is_some_and(|c| c.is_ascii_lowercase() || c == '_');
let rest_ok = name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_');
if !head_ok || !rest_ok {
return Err(format!(
"`{ctx}` is `{name}`; names must be lower_snake_case so they can \
become Rust identifiers"
));
}
const KEYWORDS: &[&str] = &[
"as", "break", "const", "continue", "crate", "dyn", "else", "enum", "extern", "false",
"fn", "for", "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub",
"ref", "return", "self", "static", "struct", "super", "trait", "true", "type", "unsafe",
"use", "where", "while", "async", "await", "box", "final", "macro", "override", "priv",
"try", "typeof", "unsized", "virtual", "yield",
];
if KEYWORDS.contains(&name) {
return Err(format!("`{ctx}` is `{name}`, which is a Rust keyword"));
}
Ok(())
}
/// `rust:` names a type in `crate::ops`, so it is PascalCase rather than an
/// id. Checked only for shape — whether the type exists, and whether it
/// implements `Operation`, is for the compiler to say.
fn check_type_name(name: &str) -> Result<(), String> {
let ok = name.chars().next().is_some_and(|c| c.is_ascii_uppercase())
&& name.chars().all(|c| c.is_ascii_alphanumeric());
if !ok {
return Err(format!(
"`rust: {name}` must be the PascalCase name of a type in \
`crate::ops`, such as `ToneCurve`"
));
}
Ok(())
}