`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>
488 lines
18 KiB
Rust
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}");
|
|
}
|
|
}
|