Files
DarkRoom/core/dr-pipeline/src/declared/mod.rs
T
dtourolleandClaude Opus 5 8e7b1350bf Name the frame's category for the decision, not the maths
`Attribute::Geometry` becomes `Attribute::Compose`, and `film_sim` moves
from `[tone, colour]` to `[effect]`.

Two categories were doing the wrong job. "Geometry" describes what crop,
straighten and the quarter turns do to coordinates — but it describes lens
distortion correction exactly as well, and that is not a compositional
choice at all. Naming the attribute for the photographer's decision is what
separates it from `Optics`: one is what the lens did, the other is what
they chose. The maths the two have in common is not the thing worth
filing them under.

A film stock declared both `tone` and `colour`, so "Kodachrome" appeared in
the Light group beside exposure and again in Colour beside white balance —
two places, neither of which is where anyone looks for it. It is neither:
`Effect` is defined in this same file as "applied rather than corrected — a
look, not a fix", which is what a stock is. That it moves tone and colour
is true of every look, and is not what the attribute is for.

`from_name` still accepts "geometry" on the way in. That string is
persisted in `develop.copy_attributes`, and an entry it fails to parse is
not an error — `presets::scope_for` logs it and drops it — so without the
alias an existing settings file would have quietly narrowed what a paste
carries. `name` writes the current spelling, so the file migrates itself
the first time it is saved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 14:34:03 +02:00

488 lines
18 KiB
Rust

//! TRACES: FR-PLG-2 | FR-PLG-2d
//! Running a node declaration without compiling it.
//!
//! # The format already existed
//!
//! `ops/*.yaml` plus `build.rs` has been the class-1 plugin format since the
//! declarative nodes landed — it was simply resolved at build time:
//!
//! ```text
//! ops/exposure.yaml ──build.rs──▶ generated impl Operation ──▶ fused shader
//! ```
//!
//! Nothing about that requires the declaration to be present when the compiler
//! runs. Everything a declaration produces is *data plus a WGSL string*, and
//! the composer already assembles WGSL at run time from whichever operations
//! are active. So this module is not a new mechanism; it is the existing one,
//! loaded later.
//!
//! [`DeclaredOp`] is **one interpreter over many declarations**, where
//! `build.rs` emits generated code per node. It implements [`Operation`] from
//! an owned [`Declaration`], which is only possible because descriptors became
//! owned — see [`crate::descriptor::OpDescriptor`] for why a `&'static`
//! descriptor made a run-time node impossible.
//!
//! # Both paths stay
//!
//! The generated path is not removed and should not be. FR-PLG-2 says so, and
//! the reasons are good ones: a generated `match` is faster than an
//! interpreted one, the built-ins' declared tests have to run under `cargo
//! test`, and generated source is *inspectable* in a way an interpreter's
//! internal state is not.
//!
//! What matters is that the two are **indistinguishable downstream**, and that
//! is a test rather than an intention: `tests/declared_parity.rs` parses every
//! built-in `ops/*.yaml` at run time and asserts the composed WGSL is
//! byte-for-byte identical to what the generated implementation produces, for
//! the same parameter values. If the two ever disagree, a plugin is not the
//! same kind of thing as a built-in and the premise of the whole plugin plan
//! has failed quietly.
//!
//! # What this is not, yet
//!
//! Not load-time WGSL validation (FR-PLG-11), not id namespacing (FR-PLG-2's
//! `author.name`), and not a plugin directory read at startup. Those are
//! separate work and are deliberately absent — a declaration reaching
//! [`DeclaredOp`] here is one that ships in this repository, so its WGSL has
//! already been compiled by the build and its id has already been checked for
//! collisions.
pub mod decl;
pub mod expr;
use std::sync::{Arc, LazyLock};
pub use decl::{Declaration, Node, SharedHelpers};
use crate::descriptor::{
intern, Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Presentation,
Scale, Unit, WidgetDemand, WidgetKind,
};
use crate::operation::{Helper, Operation, Uniform};
use expr::{as_f32, Expr};
/// The shared WGSL helper library, as the built-in nodes see it.
///
/// The very same `_helpers.yaml` `build.rs` reads, embedded rather than read
/// from disk: there is no file path an installed application could look it up
/// at, and embedding is what makes it impossible for the compiled helpers and
/// the interpreted ones to be two different files.
///
/// Panics if the bundled file does not parse, which is a build-time fact about
/// this repository rather than anything a user can cause — `build.rs` reads the
/// same text on the same build and fails first.
pub fn builtin_helpers() -> &'static SharedHelpers {
static LIBRARY: LazyLock<SharedHelpers> = LazyLock::new(|| {
decl::read_helpers(include_str!("../../ops/_helpers.yaml"))
.unwrap_or_else(|e| panic!("the bundled {}: {e}", decl::HELPERS_FILE))
});
&LIBRARY
}
/// TRACES: FR-PLG-2
/// An operation built from a declaration at run time.
///
/// Holds everything the trait has to answer with, resolved once when the
/// declaration is read: the descriptor, the uniform expressions, the helper
/// list and the fragment text. Nothing is re-parsed per call, so the per-frame
/// cost of an interpreted node is the arithmetic in [`Expr::eval`] and nothing
/// else — the same arithmetic the generated node does, simply walked rather
/// than inlined.
#[derive(Debug, Clone)]
pub struct DeclaredOp {
descriptor: Arc<OpDescriptor>,
/// Parameter ids in declaration order, parallel to `defaults` and
/// `values`.
///
/// Three parallel `Vec`s rather than one of triples because the hot read
/// is `values` alone, and because `set_param` writes exactly one of them.
/// They are built together and never resized.
params: Vec<ParamId>,
defaults: Vec<f32>,
values: Vec<f32>,
uniforms: Vec<DeclaredUniform>,
/// The declared `active:` rule, or `None` for the default one.
active: Option<Expr>,
wgsl: String,
helpers: Vec<Helper>,
presentation: Option<Presentation>,
order: i64,
}
/// One uniform: the name the fragment reads it by, and how to compute it.
#[derive(Debug, Clone)]
struct DeclaredUniform {
/// Interned when the declaration was read, not on every `uniforms()` call.
/// See [`crate::operation::Uniform::name`].
name: &'static str,
expr: Expr,
}
impl DeclaredOp {
/// Read one `ops/<id>.yaml` and build the operation it declares.
///
/// `ctx` names the source in error messages — a path, usually.
///
/// A `rust:` node is an error here rather than a silent `None`: it names a
/// hand-written type in this crate, which is by definition not something a
/// declaration can produce, and a caller that got one back as "nothing to
/// do" would drop an operation out of the chain without saying so.
pub fn from_yaml(text: &str, ctx: &str, library: &SharedHelpers) -> Result<Self, String> {
let names = library.names();
match decl::read_node(text, ctx, &names)? {
Node::Declared(d) => Self::new(&d, library),
Node::Rust { id, ty, .. } => Err(format!(
"`{id}` declares `rust: {ty}`, which names a hand-written type \
rather than describing an operation. Only a full declaration \
can be read at run time."
)),
}
}
/// Build the operation a parsed declaration describes.
pub fn new(declaration: &Declaration, library: &SharedHelpers) -> Result<Self, String> {
let params: Vec<ParamId> = declaration
.params
.iter()
.map(|p| ParamId::interned(&p.id))
.collect();
let defaults: Vec<f32> = declaration
.params
.iter()
.map(|p| as_f32(p.default))
.collect();
// Helpers in the order the composer will see them: the shared ones the
// node asked for, in the order it asked, then its own definitions.
// Order decides emission order in the generated shader, so it is part
// of the output rather than an implementation detail.
let mut helpers =
Vec::with_capacity(declaration.shared_helpers.len() + declaration.local_helpers.len());
for name in &declaration.shared_helpers {
// `decl::read_node` has already rejected a name the library does
// not define, so this is a library that changed underneath a
// declaration rather than a declaration with a typo in it.
let helper = library.get(name).ok_or_else(|| {
format!(
"`{}` asks for the shared helper `{name}`, which this \
helper library does not define",
declaration.id
)
})?;
helpers.push(Helper {
name: intern(&helper.name),
source: intern(&helper.source()),
});
}
for helper in &declaration.local_helpers {
helpers.push(Helper {
name: intern(&helper.name),
source: intern(&helper.source()),
});
}
Ok(Self {
descriptor: Arc::new(OpDescriptor {
id: OpId::interned(&declaration.id),
label: LocalizedKey::interned(&declaration.label),
params: declaration.params.iter().map(param_descriptor).collect(),
attributes: declaration
.attributes
.iter()
.copied()
.map(attribute)
.collect(),
}),
values: defaults.clone(),
params,
defaults,
uniforms: declaration
.uniforms
.iter()
.map(|u| DeclaredUniform {
name: intern(&u.name),
expr: u.expr.clone(),
})
.collect(),
active: declaration.active.clone(),
wgsl: declaration.wgsl_body(),
helpers,
presentation: declaration.presentation.as_deref().map(presentation),
order: declaration.order,
})
}
/// Where this node sits in the chain, from its `order:`.
///
/// Not part of [`Operation`] — the graph holds operations in a `Vec` and
/// order *is* that position (ARCH §3.4). Exposed so whoever assembles a
/// chain out of declarations can sort them, which is what `build.rs` does
/// at the other end.
pub fn order(&self) -> i64 {
self.order
}
fn index_of(&self, id: ParamId) -> Option<usize> {
self.params.iter().position(|p| *p == id)
}
/// One parameter's current value, by the name an expression calls it.
///
/// Returns 0.0 for a name that is not a parameter, matching what the
/// generated `param()` does with an unknown id. It cannot happen —
/// [`expr::parse`] rejects a name that is not declared — but a silent zero
/// is a better failure here than a panic inside a render.
fn value_named(&self, name: &str) -> f32 {
self.params
.iter()
.position(|p| p.0 == name)
.map_or(0.0, |i| self.values[i])
}
}
impl Operation for DeclaredOp {
fn descriptor(&self) -> Arc<OpDescriptor> {
self.descriptor.clone()
}
fn set_param(&mut self, id: ParamId, value: f32) {
match self.index_of(id) {
Some(i) => self.values[i] = value,
// The same complaint the generated `set_param` makes, for the same
// reason: a parameter that does not exist is a sidecar or a UI
// naming something this build does not have, and dropping it
// silently is how an edit comes to be half-applied.
None => log::warn!("{}: unknown parameter {id}", self.descriptor.id),
}
}
fn param(&self, id: ParamId) -> f32 {
self.index_of(id).map_or(0.0, |i| self.values[i])
}
fn is_active(&self) -> bool {
match &self.active {
Some(expr) => expr.eval(&|name| self.value_named(name)) != 0.0,
// The default rule, and the honest one: the operation is doing
// something exactly when a parameter has moved off its default.
// Short-circuiting in declaration order, which is what the
// generated `a != d || b != d` does.
None => self.values.iter().zip(&self.defaults).any(|(v, d)| v != d),
}
}
fn wgsl_body(&self) -> String {
self.wgsl.clone()
}
fn uniforms(&self) -> Vec<Uniform> {
self.uniforms
.iter()
.map(|u| Uniform {
name: u.name,
value: u.expr.eval(&|name| self.value_named(name)),
})
.collect()
}
fn helpers(&self) -> &[Helper] {
&self.helpers
}
fn presentation(&self) -> Option<Presentation> {
self.presentation.clone()
}
}
/// A declared parameter as the descriptor the panel reads.
///
/// Every arm calls the constructor `build.rs` renders a call to, so the two
/// produce the same `ParamDescriptor` by construction rather than by
/// coincidence.
fn param_descriptor(p: &decl::ParamDef) -> ParamDescriptor {
let id = intern(&p.id);
let label = intern(&p.label);
match &p.kind {
decl::Kind::Stops { min, max } => {
ParamDescriptor::stops(id, label, as_f32(*min), as_f32(*max))
}
decl::Kind::Amount => ParamDescriptor::amount(id, label),
decl::Kind::Switch => ParamDescriptor::switch(id, label),
decl::Kind::Fraction { default } => ParamDescriptor::fraction(id, label, as_f32(*default)),
decl::Kind::Scalar {
min,
max,
default,
unit,
scale,
precision,
} => ParamDescriptor::scalar(
id,
label,
as_f32(*min),
as_f32(*max),
as_f32(*default),
match unit {
decl::Unit::None => Unit::None,
decl::Unit::Stops => Unit::Stops,
decl::Unit::Kelvin => Unit::Kelvin,
decl::Unit::Percent => Unit::Percent,
},
match scale {
decl::Scale::Linear => Scale::Linear,
decl::Scale::Perceptual => Scale::Perceptual,
},
// `decl::read_kind` refuses a precision that does not fit, so this
// cast cannot lose anything.
*precision as u8,
),
decl::Kind::Enum { variants } => ParamDescriptor::choice(
id,
label,
variants.iter().map(|v| LocalizedKey::interned(v)).collect(),
),
}
}
fn attribute(a: decl::Attr) -> Attribute {
match a {
decl::Attr::Tone => Attribute::Tone,
decl::Attr::Colour => Attribute::Colour,
decl::Attr::Detail => Attribute::Detail,
decl::Attr::Optics => Attribute::Optics,
decl::Attr::Compose => Attribute::Compose,
decl::Attr::Effect => Attribute::Effect,
}
}
fn widget(w: decl::Widget) -> WidgetKind {
match w {
decl::Widget::ToneCurve => WidgetKind::ToneCurve,
decl::Widget::ColourWheel => WidgetKind::ColourWheel,
decl::Widget::CropOverlay => WidgetKind::CropOverlay,
decl::Widget::GradientHandle => WidgetKind::GradientHandle,
decl::Widget::BrushMask => WidgetKind::BrushMask,
decl::Widget::WhitePoint => WidgetKind::WhitePoint,
}
}
fn presentation(p: &decl::PresentationDef) -> Presentation {
Presentation {
widgets: p.widgets.iter().copied().map(widget).collect(),
demand: WidgetDemand {
two_dimensional: p.two_dimensional,
precise_pointing: p.precise_pointing,
},
params: p.params.iter().map(|n| ParamId::interned(n)).collect(),
}
}
#[cfg(test)]
mod tests {
use super::*;
/// TRACES: FR-PLG-2d
/// The two spellings of the attribute vocabulary are one vocabulary.
///
/// `decl::Attr` exists because the reader is compiled by `build.rs`, which
/// cannot see `crate::descriptor`. That is a duplicated closed list, and a
/// duplicated closed list is exactly the thing FR-PLG-2d warns about: an
/// attribute added to one and not the other would put an operation in a
/// category the panel does not know it has.
#[test]
fn the_attribute_vocabulary_is_the_same_on_both_sides() {
assert_eq!(decl::Attr::ALL.len(), Attribute::ALL.len());
for (a, b) in decl::Attr::ALL.iter().zip(Attribute::ALL) {
// Same order, so an index into one indexes the other.
assert_eq!(attribute(*a), b);
// And the name a declaration writes resolves to the same variant.
assert_eq!(Attribute::from_name(a.name()), Some(b), "{}", a.name());
}
}
/// TRACES: FR-PLG-2d
/// Every `WidgetKind` is nameable from a declaration.
///
/// A widget the core can ask for but a declaration cannot name is a widget
/// only a hand-written operation may have, which would make the two kinds
/// of node unequal in exactly the way FR-PLG-2 forbids.
#[test]
fn every_widget_kind_can_be_declared() {
assert_eq!(decl::Widget::ALL.len(), 6);
let named: Vec<WidgetKind> = decl::Widget::ALL.iter().copied().map(widget).collect();
for kind in [
WidgetKind::ToneCurve,
WidgetKind::ColourWheel,
WidgetKind::CropOverlay,
WidgetKind::GradientHandle,
WidgetKind::BrushMask,
WidgetKind::WhitePoint,
] {
assert!(named.contains(&kind), "{kind:?} cannot be declared");
}
}
#[test]
fn the_bundled_helper_library_parses() {
// It is `include_str!`'d, so a syntax error in it is a panic at first
// use rather than a build failure. This is the first use.
assert!(!builtin_helpers().helpers.is_empty());
}
fn exposure() -> DeclaredOp {
DeclaredOp::from_yaml(
include_str!("../../ops/exposure.yaml"),
"ops/exposure.yaml",
builtin_helpers(),
)
.expect("exposure declares an operation")
}
#[test]
fn a_declaration_becomes_an_operation_with_its_declared_descriptor() {
let op = exposure();
let d = op.descriptor();
assert_eq!(d.id, OpId("exposure"));
assert_eq!(d.label, LocalizedKey("op.exposure"));
assert_eq!(d.params.len(), 1);
assert_eq!(d.params[0].id, ParamId("exposure"));
assert_eq!(d.attributes, vec![Attribute::Tone]);
}
#[test]
fn a_declared_operation_is_neutral_until_a_parameter_moves() {
let mut op = exposure();
assert!(!op.is_active());
assert_eq!(op.uniforms()[0].value, 1.0);
op.set_param(ParamId("exposure"), 1.0);
assert!(op.is_active());
// A stop is a doubling — the same assertion `exposure.yaml`'s own
// declared test makes against the generated implementation.
assert_eq!(op.uniforms()[0].value, 2.0);
}
#[test]
fn an_interned_id_matches_a_literal_one() {
// The property that lets a declared operation be addressed by the same
// `ParamId` constants the generated code matches on. If interning ever
// stopped deduplicating, this would still pass — `ParamId` compares
// string contents — but the point is that the two are interchangeable
// at every call site.
let mut op = exposure();
op.set_param(ParamId::interned("exposure"), 2.0);
assert_eq!(op.param(ParamId("exposure")), 2.0);
}
#[test]
fn a_rust_node_is_refused_rather_than_silently_dropped() {
let err = DeclaredOp::from_yaml(
include_str!("../../ops/tone_curve.yaml"),
"ops/tone_curve.yaml",
builtin_helpers(),
)
.expect_err("a `rust:` node is not a declaration");
assert!(err.contains("hand-written type"), "{err}");
}
}