//! 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 = 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, /// 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, defaults: Vec, values: Vec, uniforms: Vec, /// The declared `active:` rule, or `None` for the default one. active: Option, wgsl: String, helpers: Vec, presentation: Option, order: i64, /// See `decl::read_stage`. camera_stage: bool, } /// 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/.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 { 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 { let params: Vec = declaration .params .iter() .map(|p| ParamId::interned(&p.id)) .collect(); let defaults: Vec = 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), camera_stage: declaration.camera_stage, 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 { 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 { 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 { 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 { self.presentation.clone() } fn stage(&self) -> crate::operation::Stage { if self.camera_stage { crate::operation::Stage::Camera } else { crate::operation::Stage::Scene } } } /// 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 = 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}"); } }