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

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

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

1224 lines
43 KiB
Rust
Raw Permalink 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.
//! TRACES: FR-PLG-2 | FR-PLG-2d
//! Reading a node declaration — the one reader, used at build time and at load
//! time.
//!
//! # Why this is not in `build.rs`
//!
//! It was, and everything below is that code. FR-PLG-2 requires the schema
//! documented in `ops/README.md` to be "readable at **load time** as well as
//! build time … with no change to what a declaration means", and the sharpest
//! way to guarantee "no change" is for there to be nothing that could change:
//! one grammar, one set of validations, one set of error messages.
//!
//! So this module produces a **neutral** [`Declaration`] — owned data that
//! names no Rust type and no crate item — and the two backends work from it:
//!
//! - `build.rs` `#[path]`-includes this file and renders a `Declaration` as
//! Rust source, so the operations that ship with the application stay
//! compiled and inspectable.
//! - [`super::DeclaredOp`] converts a `Declaration` into descriptors and
//! evaluates it, so an operation found in a file at startup is the same kind
//! of thing as one found at compile time.
//!
//! `tests/declared_parity.rs` then asserts the two agree byte for byte on
//! every node in `ops/`.
//!
//! **Nothing here may refer to the rest of the crate.** `build.rs` compiles it
//! as a standalone module of a different crate, so `crate::` paths would not
//! resolve. The mapping from these neutral types onto `crate::descriptor`'s is
//! in `declared/mod.rs`, which is compiled only as part of the library.
use std::collections::BTreeSet;
use serde_norway::{Mapping, Value};
use super::expr::{self, Expr};
/// The file shared WGSL helpers are declared in.
pub const HELPERS_FILE: &str = "_helpers.yaml";
// ---------------------------------------------------------------------------
// The declaration model
// ---------------------------------------------------------------------------
/// A WGSL helper function, either shared or declared by one node.
pub struct HelperDef {
pub name: String,
pub doc: Option<String>,
pub wgsl: String,
}
impl HelperDef {
/// The helper's source exactly as it reaches the composed shader.
///
/// 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.
///
/// Shared between the backends because it is *content*, not rendering:
/// `build.rs` wraps this in a Rust raw-string literal and a declared
/// operation interns it, and the two have to produce the same characters
/// or the composed shader differs.
pub fn source(&self) -> String {
literal_body(&self.wgsl, self.doc.as_deref())
}
}
/// TRACES: FR-PLG-2d
/// What a parameter *is*, from the closed list of kinds a declaration may name.
///
/// Closed on purpose, and the reason `ops/README.md` gives strengthens here: a
/// typo that invented a kind would produce a control nobody asked for, and a
/// control that silently does not exist is worse than a build that stops.
pub enum Kind {
Stops {
min: f64,
max: f64,
},
Amount,
Switch,
Fraction {
default: f64,
},
Scalar {
min: f64,
max: f64,
default: f64,
unit: Unit,
scale: Scale,
precision: u64,
},
Enum {
variants: Vec<String>,
},
}
/// The unit a value carries, mirroring `crate::descriptor::Unit`.
#[derive(Clone, Copy)]
pub enum Unit {
None,
Stops,
Kelvin,
Percent,
}
/// What a slider's travel means, mirroring `crate::descriptor::Scale`.
#[derive(Clone, Copy)]
pub enum Scale {
Linear,
Perceptual,
}
/// TRACES: FR-PLG-2d
/// What an operation is about, mirroring `crate::descriptor::Attribute`.
///
/// A closed vocabulary. `declared/mod.rs` asserts that this list and the
/// crate's own agree, so the two spellings of one idea cannot drift.
#[derive(Clone, Copy, PartialEq)]
pub enum Attr {
Tone,
Colour,
Detail,
Optics,
Compose,
Effect,
}
impl Attr {
/// The name a declaration spells this with.
pub const fn name(self) -> &'static str {
match self {
Attr::Tone => "tone",
Attr::Colour => "colour",
Attr::Detail => "detail",
Attr::Optics => "optics",
Attr::Compose => "compose",
Attr::Effect => "effect",
}
}
/// Every attribute, in the order `crate::descriptor::Attribute::ALL`
/// lists them — the reasoning for the sequence lives there, and the
/// agreement test in `declared/mod.rs` zips the two, so a reorder there
/// that is not mirrored here is a failure rather than a drift.
pub const ALL: [Attr; 6] = [
Attr::Optics,
Attr::Compose,
Attr::Tone,
Attr::Colour,
Attr::Effect,
Attr::Detail,
];
}
/// TRACES: FR-PLG-2d
/// A widget a node may ask for, mirroring `crate::descriptor::WidgetKind`.
///
/// 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.
#[derive(Clone, Copy, PartialEq)]
pub enum Widget {
ToneCurve,
ColourWheel,
CropOverlay,
GradientHandle,
BrushMask,
WhitePoint,
}
impl Widget {
pub const fn name(self) -> &'static str {
match self {
Widget::ToneCurve => "tone_curve",
Widget::ColourWheel => "colour_wheel",
Widget::CropOverlay => "crop_overlay",
Widget::GradientHandle => "gradient_handle",
Widget::BrushMask => "brush_mask",
Widget::WhitePoint => "white_point",
}
}
/// The `WidgetKind` variant this is, as Rust source. For `build.rs`.
pub const fn rust(self) -> &'static str {
match self {
Widget::ToneCurve => "WidgetKind::ToneCurve",
Widget::ColourWheel => "WidgetKind::ColourWheel",
Widget::CropOverlay => "WidgetKind::CropOverlay",
Widget::GradientHandle => "WidgetKind::GradientHandle",
Widget::BrushMask => "WidgetKind::BrushMask",
Widget::WhitePoint => "WidgetKind::WhitePoint",
}
}
pub const ALL: [Widget; 6] = [
Widget::ToneCurve,
Widget::ColourWheel,
Widget::CropOverlay,
Widget::GradientHandle,
Widget::BrushMask,
Widget::WhitePoint,
];
}
/// One parameter of a declared node.
pub struct ParamDef {
pub id: String,
pub label: String,
pub doc: Option<String>,
pub kind: Kind,
/// Needed to generate `is_active` and to range-check test values.
pub default: f64,
pub min: f64,
pub max: f64,
}
/// A uniform the node's fragment reads, and the expression computing it.
pub struct UniformDef {
pub name: String,
pub doc: Option<String>,
pub expr: Expr,
}
/// A node's `presentation:` block.
pub struct PresentationDef {
/// Most preferred first.
pub widgets: Vec<Widget>,
/// The owned parameters' ids, in widget order.
pub params: Vec<String>,
pub two_dimensional: bool,
pub precise_pointing: bool,
}
/// One declared test.
///
/// Read and validated here so that a nonsense assertion is rejected the same
/// way whoever wrote it, though only `build.rs` emits anything from it: a
/// declared node's tests run under `cargo test` against the *generated*
/// implementation, which is where FR-PLG-2 says they belong.
pub struct TestDef {
pub name: String,
pub why: Option<String>,
pub set: Vec<(String, f64)>,
pub expect: Vec<(String, f64)>,
pub expect_range: Vec<(String, f64, f64)>,
pub expect_active: Option<bool>,
pub expect_wgsl: Vec<String>,
pub expect_helper_wgsl: Vec<(String, Vec<String>)>,
}
/// A node declared in full.
pub struct Declaration {
pub id: String,
pub label: String,
pub order: i64,
/// What the operation is about (ARCH §4.3a). Never empty — see
/// [`read_attributes`].
pub attributes: Vec<Attr>,
pub doc: Option<String>,
pub placement: Option<String>,
pub params: Vec<ParamDef>,
pub uniforms: Vec<UniformDef>,
/// Names of shared helpers, in declaration order.
pub shared_helpers: Vec<String>,
/// Helpers this node defines for itself.
pub local_helpers: Vec<HelperDef>,
pub wgsl: String,
/// `None` means the default rule: active when any parameter has moved.
pub active: Option<Expr>,
pub tests: Vec<TestDef>,
/// `None` — the usual case — means one control per parameter.
///
/// Boxed so the rare node that declares one does not widen every
/// declaration by the size of a presentation it does not have.
pub presentation: Option<Box<PresentationDef>>,
/// Whether the node runs in camera RGB, ahead of the camera matrix —
/// `stage: camera`. See [`read_stage`].
pub camera_stage: bool,
}
/// TRACES: FR-DEV-3e | FR-DEV-2
/// Where in the chain a declared node's colour comes from: `stage: camera` or
/// `stage: scene`, the default.
///
/// Camera RGB is where white balance's multipliers are defined, and it is the
/// only thing that belongs there (D19): every other operation is handed
/// working-space colour, so that a hue or a luminance weight means the same
/// thing whichever body took the frame. `view` is not offered. The view
/// transform is hand-written, and a declared node that clipped into a display
/// range would be exactly what ARCH §6.14 forbids of every node before it.
fn read_stage(root: &Mapping) -> Result<bool, String> {
match root.get("stage") {
None => Ok(false),
Some(v) => match as_str(v, "stage")? {
"camera" => Ok(true),
"scene" => Ok(false),
other => Err(format!(
"unknown stage {other:?}; expected \"camera\" or \"scene\""
)),
},
}
}
impl Declaration {
/// The fragment body exactly as it reaches [`crate::Operation::wgsl_body`].
pub fn wgsl_body(&self) -> String {
literal_body(&self.wgsl, None)
}
}
/// A node: either declared in full, or a pointer to a hand-written type.
///
/// The declared arm is boxed because it is an order of magnitude larger than
/// the other: a `Declaration` carries every parameter, uniform, helper and
/// test a node has, while a `rust:` node is four strings. Both readers move
/// these by value out of the parser, and an unboxed enum would copy the larger
/// shape every time it moved either.
pub enum Node {
Declared(Box<Declaration>),
Rust {
id: String,
order: i64,
/// The type in `crate::ops` implementing `Operation`.
ty: String,
why_rust: Option<String>,
placement: Option<String>,
},
}
impl Node {
pub fn id(&self) -> &str {
match self {
Node::Declared(d) => &d.id,
Node::Rust { id, .. } => id,
}
}
pub fn order(&self) -> i64 {
match self {
Node::Declared(d) => d.order,
Node::Rust { order, .. } => *order,
}
}
pub fn placement(&self) -> Option<&str> {
match self {
Node::Declared(d) => d.placement.as_deref(),
Node::Rust { placement, .. } => placement.as_deref(),
}
}
}
/// The WGSL a declaration contributes, with its prose folded in as comments.
///
/// The single definition of "what text does this declaration produce", so that
/// the generated raw-string literal and the interpreted `String` cannot come
/// to hold different characters.
///
/// The trailing `trim` pair is load-bearing rather than tidiness: it is what
/// `build.rs` used to do inline when emitting the literal, and the composed
/// shader would differ by a newline without it.
fn literal_body(wgsl: &str, doc: Option<&str>) -> String {
let mut body = String::new();
if let Some(doc) = doc {
for line in doc.trim_end().lines() {
if line.trim().is_empty() {
body.push_str("//\n");
} else {
body.push_str("// ");
body.push_str(line);
body.push('\n');
}
}
}
body.push_str(wgsl.trim_matches('\n').trim_end());
body
}
// ---------------------------------------------------------------------------
// Reading: shared helpers
// ---------------------------------------------------------------------------
pub struct SharedHelpers {
pub doc: Option<String>,
pub helpers: Vec<HelperDef>,
}
impl SharedHelpers {
/// The names, for validating a node's `helpers:` list against.
pub fn names(&self) -> BTreeSet<&str> {
self.helpers.iter().map(|h| h.name.as_str()).collect()
}
pub fn get(&self, name: &str) -> Option<&HelperDef> {
self.helpers.iter().find(|h| h.name == name)
}
}
/// Parse `_helpers.yaml`.
pub fn read_helpers(text: &str) -> Result<SharedHelpers, String> {
let doc = parse_yaml(text, HELPERS_FILE)?;
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
// ---------------------------------------------------------------------------
/// TRACES: FR-PLG-2d
/// 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<Attr>, String> {
let known: Vec<&str> = Attr::ALL.iter().map(|a| a.name()).collect();
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<Attr> = Vec::new();
for entry in list {
let name = as_str(entry, "attributes")?;
let Some(attr) = Attr::ALL.iter().copied().find(|a| a.name() == name) else {
return Err(format!(
"unknown attribute {name:?}; expected one of {known:?}"
));
};
if out.contains(&attr) {
return Err(format!("attribute {name:?} is listed twice"));
}
out.push(attr);
}
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)
}
/// Parse one `ops/<id>.yaml`.
///
/// `shared` is the set of helper names `_helpers.yaml` defines, so that a node
/// naming one that does not exist is rejected where it is written rather than
/// producing a shader that fails to compile.
pub fn read_node(text: &str, ctx: &str, shared: &BTreeSet<&str>) -> Result<Node, String> {
let doc = parse_yaml(text, ctx)?;
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)?;
// `attributes` is in this list, and was not until a declaration that
// said `[effect]` sat above a type that said `[tone, colour]` for a
// whole commit without anything noticing. The line parsed, validated
// against the vocabulary, and was then dropped on the floor — so the
// file read as though it had moved the operation and the panel went on
// filing it under two groups it did not belong to. A key that is
// *ignored* is worse than one that is rejected, because it looks like
// it worked.
for key in [
"params",
"uniforms",
"wgsl",
"helpers",
"define",
"label",
"attributes",
"stage",
] {
if root.contains_key(key) {
return Err(format!(
"`{key}` is meaningless on a `rust:` node — {ty} publishes \
its own descriptor, and this file would be ignored. Say it \
in the type instead."
));
}
}
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 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 uniforms = read_uniforms(root, &param_names)?;
let active = match root.get("active") {
None => None,
Some(v) => {
let src = as_str(v, "active")?;
Some(expr::parse(src, &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)?;
let camera_stage = read_stage(root)?;
Ok(Node::Declared(Box::new(Declaration {
id,
label,
order,
doc: opt_prose(root, "doc", "doc")?,
attributes,
placement,
params,
uniforms,
shared_helpers,
local_helpers,
wgsl,
active,
tests,
presentation,
camera_stage,
})))
}
/// 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}]"))?;
Widget::ALL
.iter()
.copied()
.find(|k| k.name() == name)
.ok_or_else(|| {
format!(
"`presentation.widgets[{i}]` is `{name}`; 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_string())
})
.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,
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_name = as_str(
spec.get("kind")
.ok_or_else(|| format!("`{ctx}` has no `kind:`"))?,
&format!("{ctx}.kind"),
)?;
let (kind, default, min, max) = read_kind(kind_name, spec, &ctx)?;
out.push(ParamDef {
id,
label,
doc: opt_prose(spec, "doc", &ctx)?,
kind,
default,
min,
max,
});
}
if out.is_empty() {
return Err("`params:` is empty".into());
}
Ok(out)
}
/// Read a declared parameter kind, with its default and its bounds.
///
/// 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 read_kind(kind: &str, spec: &Mapping, ctx: &str) -> Result<(Kind, 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)
};
let (kind, default, min, max) = match kind {
"stops" => {
let (min, max) = (need("min")?, need("max")?);
(Kind::Stops { min, max }, 0.0, min, max)
}
"amount" => (Kind::Amount, 0.0, -100.0, 100.0),
"switch" => (Kind::Switch, 0.0, 0.0, 1.0),
"fraction" => {
let default = opt("default", 0.0);
(Kind::Fraction { 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:`"))?;
(
Kind::Scalar {
min,
max,
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(str::to_string))
.collect::<Result<Vec<_>, _>>()?;
let last = (keys.len() - 1) as f64;
(Kind::Enum { variants: keys }, 0.0, 0.0, last)
}
other => {
return Err(format!(
"`{ctx}.kind` is `{other}`; expected stops, amount, switch, \
fraction, scalar or enum"
))
}
};
// 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((kind, 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, src) = 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 expr = expr::parse(&src, params).map_err(|e| format!("`{ctx}`: {e}"))?;
out.push(UniformDef { name, doc, expr });
}
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)
}
// ---------------------------------------------------------------------------
// YAML access
// ---------------------------------------------------------------------------
fn parse_yaml(text: &str, ctx: &str) -> Result<Value, String> {
serde_norway::from_str(text).map_err(|e| format!("{ctx}: not valid YAML: {e}"))
}
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`.
///
/// Enforced at load time as well as build time even though a declaration read
/// at run time never becomes Rust: the two paths must agree on what a valid
/// declaration *is*, or a plugin could be accepted by one and rejected by the
/// other, and the built-ins would stop being a fair test of the format.
pub 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(())
}