rlx_core/preset/schema/load.rs
1//! Load: raw TOML in, compiled [`Preset`] out.
2//!
3//! [`Preset::from_toml_str`] is the whole entry point. Everything else here is a
4//! step of it -- the latch table, the per-vertex table, the `[layer]` sub-preset
5//! and the structural `[generator]`/`[curve]`/`[particles]` config -- and each
6//! rejects rather than panicking, so one malformed key never takes down a show.
7
8// A continuation of one module split across several files, so it needs the
9// names `preset/schema/mod.rs` has in scope.
10use super::*;
11use crate::render::scenes::declares;
12
13impl Preset {
14 /// Parse and compile a preset from a TOML source string.
15 pub fn from_toml_str(src: &str) -> Result<Self, PresetError> {
16 let raw: RawPreset = toml::from_str(src).map_err(PresetError::Toml)?;
17 let system = SystemKind::from_name(&raw.system)
18 .ok_or_else(|| PresetError::UnknownSystem(raw.system.clone()))?;
19 let name = raw.name.unwrap_or_else(|| raw.system.clone());
20
21 // The `[latch]` table (ADR-0137), resolved **before** the bindings that
22 // may name a latch — the params below compile against these names, so
23 // there is no other order. A preset declaring no table gets an empty
24 // list and every expression below compiles exactly as it did before
25 // latches existed.
26 let latches = build_latches(&raw.latch)?;
27 let latch_names: Vec<String> = latches.iter().map(|l| l.name.clone()).collect();
28
29 let mut warnings = Vec::new();
30 warn_unknown_keys(src, &mut warnings);
31 let mut params = compile_bindings(
32 system,
33 raw.params,
34 &latch_names,
35 Surface::Preset,
36 &mut warnings,
37 )?;
38
39 // The salt the grammar's `hash()`/`noise()` mix in (ADR-0051), read from
40 // the long-reserved `[generator] seed`. Read **before** `build_config`
41 // consumes the table, and read for every system: only the L-system and
42 // the star pattern care about the rest of `[generator]`, but any preset
43 // may declare a seed, so a fragment or swarm preset can carry one table
44 // holding nothing else.
45 //
46 // Entropy is drawn here, once per load, and only for `seed = "random"` —
47 // never per frame, never from a clock inside evaluation (ADR-0051
48 // Alternative B). The pinned twin is what the capture paths read.
49 let (salt, pinned_salt) = match raw.generator.as_ref().and_then(|g| g.seed) {
50 Some(RawSeed::Random) => (entropy_salt(), 0),
51 declared => {
52 let salt = salt_from_seed(declared.map_or(0, RawSeed::numeric));
53 (salt, salt)
54 }
55 };
56
57 // Structural config: validated once here (a bad family/grammar -> load
58 // error, the caller keeps the last good preset), then trusted by the
59 // scene. Built per system so each reads the right table.
60 let config = build_config(
61 system,
62 raw.curve,
63 raw.generator,
64 raw.particles,
65 raw.path,
66 raw.spectrum,
67 raw.mesh,
68 raw.field,
69 raw.cellular,
70 raw.plexus,
71 raw.waterfall,
72 raw.voxel,
73 raw.milk,
74 pinned_salt,
75 )?;
76
77 // The `[feedback]` table (ADR-0048): two closed rosters, validated here so
78 // an unknown warp or blend is a surfaced load error rather than a preset
79 // that quietly renders unwarped. Absent means both defaults.
80 let feedback = raw.feedback.unwrap_or_default().into_config()?;
81
82 // Easing time constants (ADR-0019, ADR-0035): validated non-negative +
83 // finite at the load boundary, then folded into the bindings so the frame
84 // loop reads the constants off the binding instead of hashing its name
85 // into a `BTreeMap` once per binding per frame (Plan 0031 Phase 3). A bad
86 // value is a surfaced load error, never a panic.
87 fold_smoothing(&mut params, &raw.smoothing, Surface::Preset, &mut warnings)?;
88
89 // The `[hold]` table (ADR-0180 rule 2), folded at the same boundary and
90 // for the same reason: the edge is a fact about the preset, resolved
91 // once, and the preset does not change while it renders. After the
92 // easing fold, so a preset carrying a bad entry in each table reports
93 // the smoothing one first, as it did before holds existed.
94 fold_hold(params.iter_mut(), &raw.hold, Surface::Preset, &mut warnings)?;
95
96 // A `thickness` resting inside the stroke floor's dead zone (Plan 0087
97 // Phase 1b, design-backlog 0098). Every value below
98 // `MIN_USEFUL_THICKNESS` clamps to the same half-width, so the whole
99 // range renders identically and re-tuning inside it changes nothing —
100 // which is what makes it expensive: the obvious experiment *disproves
101 // the correct hypothesis*. `fragment_vitrail` shipped at 0.016, two
102 // orders below the 1.5-3.2 every other line preset uses, and its Maurer
103 // rose read as scattered dots for its whole shipped life while the
104 // content lane swept chord count and sample count first.
105 //
106 // A warning rather than an error, in ADR-0020's shape and on its
107 // surface: the value is in range and the preset is otherwise good.
108 // Only a binding that *rests* at such a value is reported — see
109 // `Expr::as_const`.
110 if declares(system.param_specs(), "thickness") {
111 for binding in ¶ms {
112 if binding.name != "thickness" {
113 continue;
114 }
115 let Some(value) = binding.expr.as_const() else {
116 continue;
117 };
118 if value < crate::render::scenes::lines::MIN_USEFUL_THICKNESS {
119 warnings.push(PresetWarning::about(
120 "thickness",
121 format!(
122 "parameter 'thickness' rests at {value}, inside the stroke floor's \
123 dead zone: every value below {:.3} renders the identical hairline \
124 (about 0.27 px at 1080p), so tuning within that range changes \
125 nothing. Line presets ship between 1.5 and 3.2",
126 crate::render::scenes::lines::MIN_USEFUL_THICKNESS
127 ),
128 ));
129 }
130 }
131 }
132
133 // The scaled-copy coordinate was asked for on a figure it has no single
134 // value on (Plan 0098 Phase 4 for the `ring`, ADR-0111's one open
135 // behavioural choice; ADR-0179 for the authored contour).
136 //
137 // **One condition, tested on the figure that will be drawn.** `r /
138 // r_boundary` needs a figure every ray from its centre leaves exactly
139 // once. A `ring` is the single arm of the roster that is not — its
140 // centre is in the hole — and an authored contour replaces the roster
141 // arm entirely, so there the question is about the contour's own
142 // geometry rather than about a name. No name betrays it: a figure with
143 // fins, a crescent, or a silhouette whose sinuses put one lobe across
144 // the ray into another all fail, and on every one of them the shader
145 // takes the outermost crossing and the figure collapses to a dot inside
146 // a few huge rays.
147 //
148 // Announcing it is the whole point. On the `ring` the three defensible
149 // answers were rendered before one was chosen, and the outer-edge
150 // definition came out BYTE-IDENTICAL to a `disc`: the coordinate
151 // collapses to `length(p)` and the hole stops existing. A preset would
152 // name one roster entry and be shown another. The silent fallback
153 // renders exactly what this does and only differs in whether anyone is
154 // told.
155 //
156 // A warning rather than an error, in ADR-0020's shape and on the
157 // `thickness` dead-zone surface above: both values are legal, the
158 // preset is otherwise good, and only a binding that *rests* on the
159 // combination can be seen from here.
160 if declares(system.param_specs(), "coord_mode") {
161 let resting = |name: &str| -> Option<f32> {
162 params
163 .iter()
164 .find(|b| b.name == name)
165 .and_then(|b| b.expr.as_const())
166 };
167 let mode = resting("coord_mode");
168 // The ceiling is `shape_field`'s own roster, not a literal `1.0`: a
169 // third coordinate would leave a hardcoded bound quietly testing the
170 // wrong thing, and this quantizes the way the scene does.
171 let max_mode = (crate::render::scenes::shape_field::COORD_MODES.len() - 1) as f32;
172 // The contour, when the preset authored one — and BOTH endpoints of
173 // a morph pair, because both are drawn. The scene decides the
174 // fallback on exactly this, so the warning and the picture cannot
175 // disagree.
176 let contour = match &config {
177 Some(GeneratorConfig::Path { shape, morph_to }) => shape.as_ref().map(|from| {
178 from.star_shaped()
179 && morph_to
180 .as_ref()
181 .is_none_or(crate::preset::path::PathShape::star_shaped)
182 }),
183 _ => None,
184 };
185 let figure = match contour {
186 Some(false) => Some(
187 "an authored contour that is not star-shaped about its centre: a ray from \
188 there crosses the outline more than once",
189 ),
190 Some(true) => None,
191 None => resting("shape")
192 .map(crate::render::scenes::marks::mark_shape)
193 .is_some_and(crate::render::scenes::marks::shape_touches_ring)
194 .then_some(
195 "a `ring`: an annulus's centre lies in its hole, so a ray from there \
196 crosses the outline twice",
197 ),
198 };
199 if let Some(figure) = figure
200 && mode.is_some_and(|m| m.is_finite() && m.clamp(0.0, max_mode).round() >= 1.0)
201 {
202 warnings.push(PresetWarning::about(
203 "coord_mode",
204 format!(
205 "parameter 'coord_mode' is ignored on {figure} and the scaled-copy \
206 coordinate has no single value there. The figure is drawn with the \
207 distance instead — a band of constant distance rather than a scaled \
208 copy of the outline"
209 ),
210 ));
211 }
212 }
213
214 // A `shape_field` `color_span` narrow enough to starve the gradient
215 // (design-backlog 0099). The scene hands the palette a FIGURE
216 // coordinate whose `0..1` is the interior, so `color_span` is literally
217 // the share of the 256-texel LUT the figure is drawn through: at 0.037
218 // that is nine texels for the whole of it, linear-filtered across
219 // however much of the frame the figure covers, which is an upscaled
220 // gradient and reads as one.
221 //
222 // The reason this is worth a warning and not a note in a document is
223 // that **the symptom names the wrong subsystem**: a soft, crawling
224 // figure reads as a bad silhouette or bad shading, and the value is in
225 // range and the preset is otherwise good. It cost one user look verdict
226 // two misattributions.
227 //
228 // A warning rather than an error, on ADR-0020's surface, and only for a
229 // binding that *rests* — a `color_span` sweeping through the range is a
230 // different claim, and `Expr::as_const` is what separates them.
231 if system == SystemKind::ShapeField {
232 let resting = |name: &str| -> Option<f32> {
233 params
234 .iter()
235 .find(|b| b.name == name)
236 .and_then(|b| b.expr.as_const())
237 };
238 // `palette_steps` snaps the coordinate to a band centre before the
239 // LUT read, so every pixel samples one exact texel and nothing is
240 // interpolated — the trap does not exist there. A band count that
241 // is bound rather than resting is the author working in bands too,
242 // so only a count resting BELOW the quantizer's own activation
243 // threshold leaves this live.
244 let banded = match params.iter().find(|b| b.name == "palette_steps") {
245 Some(binding) => binding.expr.as_const().is_none_or(|steps| {
246 crate::render::palette::band_steps(steps)
247 > crate::render::palette::MIN_ACTIVE_STEPS
248 }),
249 None => false,
250 };
251 if let Some(span) = resting("color_span")
252 && !banded
253 {
254 let texels = crate::render::scenes::shape_field::interior_texels(span);
255 if span.is_finite()
256 && texels < crate::render::scenes::shape_field::MIN_INTERIOR_TEXELS
257 {
258 warnings.push(PresetWarning::about(
259 "color_span",
260 format!(
261 "parameter 'color_span' rests at {span}, which draws the figure's \
262 whole interior through about {texels:.0} of the palette's {} \
263 texels. Below roughly {:.0} the LUT's linear filtering is \
264 interpolating more than it is reading and the figure comes back \
265 looking upscaled rather than shaded — the estimate is exact in the \
266 coordinate and approximate on screen, since how much of the frame \
267 the figure covers depends on its shape and framing. Bind or set \
268 `palette_steps` to remove the interpolation entirely",
269 crate::render::palette::LUT_SIZE,
270 crate::render::scenes::shape_field::MIN_INTERIOR_TEXELS,
271 ),
272 ));
273 }
274 }
275 }
276
277 // Palette selection (ADR-0021): validated at this boundary into a
278 // baked-ready `PaletteConfig`; a bad name/stop list is a surfaced load
279 // error, never a panic. `None` -> the default `spectrum`. `[palette_b]`
280 // (the crossfade target) validates the same way.
281 let palette = raw.palette.map(RawPalette::into_config).transpose()?;
282 let palette_b = raw.palette_b.map(RawPalette::into_config).transpose()?;
283
284 // Saturation exemptions (ADR-0062). A name this preset does not bind is
285 // a warning for the same reason a `[smoothing]` entry naming one is: it
286 // silences nothing, so a typo here would leave the author believing a
287 // gate was exempted while the gate goes on failing on the real name.
288 let mut occupancy_exempt = raw.occupancy.unwrap_or_default().exempt;
289 occupancy_exempt.sort();
290 occupancy_exempt.dedup();
291 for name in &occupancy_exempt {
292 if !params.iter().any(|b| &b.name == name) {
293 // Unanchored: the name is one this preset does not bind, so there
294 // is no binding line to point at.
295 warnings.push(PresetWarning::unanchored(format!(
296 "[occupancy] exempt entry '{name}' is inert: this preset binds no such \
297 parameter"
298 )));
299 }
300 }
301
302 // The `[per_vertex]` table (Plan 0100 Phase 1): the warp mesh's
303 // per-vertex program, compiled like any binding and never eased.
304 let per_vertex = build_per_vertex(
305 system,
306 raw.per_vertex,
307 &raw.smoothing,
308 &raw.hold,
309 &latch_names,
310 Surface::Preset,
311 &mut warnings,
312 )?;
313 // A `[params]` binding reaching for a vertex variable reads a flat zero
314 // there — the names are crate-wide because expression slots are
315 // positional, and only a `[per_vertex]` table ever binds them.
316 warn_vertex_use(¶ms, Surface::Preset, &mut warnings);
317
318 // A latch nothing reads is inert, and inert-and-silent is what this file
319 // exists to prevent — the same warning an `[occupancy] exempt` entry
320 // naming an unbound param gets, for the same reason: the author believes
321 // an event is wired up while nothing consumes it. Every surface that can
322 // name a latch is searched, including the layer's, which is why this sits
323 // after the layer is built.
324 let layer = raw
325 .layer
326 .map(|l| build_layer(l, &latch_names, &mut warnings))
327 .transpose()?;
328 for (slot, latch) in latches.iter().enumerate() {
329 let mut read = params
330 .iter()
331 .chain(&per_vertex)
332 .any(|b| b.expr.uses_latch(slot));
333 if let Some(l) = layer.as_ref() {
334 read = read
335 || l.params
336 .iter()
337 .chain(&l.per_vertex)
338 .chain(l.mix.as_ref())
339 .any(|b| b.expr.uses_latch(slot));
340 }
341 if !read {
342 warnings.push(PresetWarning::unanchored(format!(
343 "[latch] '{}' is inert: no binding in this preset names it",
344 latch.name
345 )));
346 }
347 }
348
349 Ok(Preset {
350 name,
351 system,
352 // The text this was compiled from does not say where it came from;
353 // `load_dir` fills this in for the presets it read off disk.
354 source: None,
355 params,
356 per_vertex,
357 latches,
358 config,
359 feedback,
360 palette,
361 palette_b,
362 salt,
363 pinned_salt,
364 occupancy_exempt,
365 layer,
366 warnings,
367 representative: raw.representative,
368 })
369 }
370}
371
372/// Warn on every key the document carries that no descriptor row names, at the
373/// top level and inside `[layer]` -- a warning, not an error (ADR-0020), so a
374/// misspelled `[smothing]` still loads and `--strict` is what fails on it.
375///
376/// The check reads the [`raw::PRESET`] and [`raw::LAYER`] descriptors rather
377/// than a `#[serde(flatten)]` catch-all on the raw structs: `schema::tests`
378/// already holds those descriptors equal to the fields serde derives, and a
379/// flattened struct would hide that roster from the probe that reads it and
380/// route every type error through serde's buffered content, losing its span.
381/// The source is parsed a second time for it, once per load, off the hot path.
382fn warn_unknown_keys(src: &str, warnings: &mut Vec<PresetWarning>) {
383 let Ok(doc) = src.parse::<toml::Table>() else {
384 return;
385 };
386 warn_unknown_in(&doc, &raw::PRESET, "", "top-level ", warnings);
387 if let Some(toml::Value::Table(layer)) = doc.get("layer") {
388 warn_unknown_in(layer, &raw::LAYER, "layer.", "[layer] ", warnings);
389 }
390}
391
392/// One table's half of [`warn_unknown_keys`]. `prefix` is the dotted path a
393/// sub-table's name is written under, `place` the words naming where it sits.
394fn warn_unknown_in(
395 table: &toml::Table,
396 desc: &TableDesc,
397 prefix: &str,
398 place: &str,
399 warnings: &mut Vec<PresetWarning>,
400) {
401 let spell = |name: &str, value: &toml::Value| match value {
402 toml::Value::Table(_) => format!("table '[{prefix}{name}]'"),
403 _ => format!("key '{name}'"),
404 };
405 for (name, value) in table {
406 if desc.keys.iter().any(|key| key.name == name) {
407 continue;
408 }
409 let hint = desc
410 .keys
411 .iter()
412 .map(|key| (edit_distance(name, key.name), key.name))
413 .filter(|&(distance, _)| distance <= 2)
414 .min_by_key(|&(distance, _)| distance)
415 .map(|(_, known)| format!("; did you mean {}?", spell(known, value)))
416 .unwrap_or_default();
417 warnings.push(PresetWarning::unanchored(format!(
418 "unknown {place}{}: nothing reads it{hint}",
419 spell(name, value)
420 )));
421 }
422}
423
424/// The Levenshtein distance between two names, by character.
425fn edit_distance(a: &str, b: &str) -> usize {
426 let b: Vec<char> = b.chars().collect();
427 let mut row: Vec<usize> = (0..=b.len()).collect();
428 for (i, ca) in a.chars().enumerate() {
429 let mut diagonal = row[0];
430 row[0] = i + 1;
431 for (j, &cb) in b.iter().enumerate() {
432 let substitute = diagonal + usize::from(ca != cb);
433 diagonal = row[j + 1];
434 row[j + 1] = substitute.min(row[j] + 1).min(diagonal + 1);
435 }
436 }
437 row[b.len()]
438}
439
440/// Compile and validate the `[latch]` table (ADR-0137).
441///
442/// Slot order is the `BTreeMap`'s, so it is the entries' **name order** and not
443/// their order in the file: a preset's latch-to-slot mapping is then a function
444/// of the set of names alone, and re-ordering the table in the TOML cannot move
445/// a latch onto a different slot.
446///
447/// Everything here is a load-time check, in the shape the rest of this file
448/// uses: a bad expression is a [`PresetError::Expr`] naming which key of which
449/// latch, and every other failure is a [`PresetError::Config`]. The two
450/// expressions compile with **no** latch names in scope — see [`Latch`] for why
451/// that is the design rather than an omission.
452pub(super) fn build_latches(raw: &BTreeMap<String, RawLatch>) -> Result<Vec<Latch>, PresetError> {
453 if raw.len() > expr::LATCH_CAP {
454 return Err(PresetError::Config(format!(
455 "[latch] declares {} entries; a preset may hold at most {} (ADR-0137: the \
456 reserved variable block is a fixed size, so this is a wall rather than a \
457 slower path)",
458 raw.len(),
459 expr::LATCH_CAP,
460 )));
461 }
462 let mut out = Vec::with_capacity(raw.len());
463 for (name, entry) in raw {
464 if !expr::is_identifier(name) {
465 return Err(PresetError::Config(format!(
466 "[latch] name '{name}' is not an identifier: a latch is referenced from \
467 an expression, so its name must start with a letter or underscore and \
468 hold only letters, digits and underscores"
469 )));
470 }
471 if expr::is_reserved_ident(name) {
472 return Err(PresetError::Config(format!(
473 "[latch] name '{name}' is already a variable, constant or function in the \
474 expression grammar; a binding naming it would read that instead of the \
475 latch"
476 )));
477 }
478 check_hold(name, entry.hold)?;
479 let compile = |key: &str, source: &str| {
480 expr::compile(source).map_err(|err| PresetError::Expr {
481 param: format!("[latch] {name}.{key}"),
482 err,
483 })
484 };
485 out.push(Latch {
486 name: name.clone(),
487 arm: compile("arm", &entry.arm)?,
488 fire: compile("fire", &entry.fire)?,
489 hold: entry.hold,
490 });
491 }
492 Ok(out)
493}
494
495/// Validate one `[latch] hold`, in `check_tau`'s shape and at the same boundary:
496/// a non-negative, finite number of seconds, checked once here and trusted by
497/// the render layer's countdown.
498pub(super) fn check_hold(name: &str, seconds: f32) -> Result<(), PresetError> {
499 if seconds.is_finite() && seconds >= 0.0 {
500 return Ok(());
501 }
502 Err(PresetError::Config(format!(
503 "[latch] '{name}' hold must be a non-negative number of seconds, got {seconds}"
504 )))
505}
506
507/// Compile a `[per_vertex]` table into bindings (Plan 0100 Phase 1).
508///
509/// `surface` prefixes the error and warning text and names the tables they
510/// cite, and `smoothing` / `hold` are that surface's own easing and hold
511/// tables — consulted only to reject or warn about an entry naming a
512/// per-vertex binding, which is never eased and never held.
513///
514/// Unknown names warn and keep the binding, exactly like `[params]` (ADR-0020):
515/// one typo must not discard an otherwise-good mesh program. A binding here for
516/// a system that has no per-vertex surface warns too — the table is inert there,
517/// and silently inert is the thing this file exists to prevent.
518pub(super) fn build_per_vertex(
519 system: SystemKind,
520 raw: BTreeMap<String, String>,
521 smoothing: &BTreeMap<String, RawSmoothing>,
522 hold: &BTreeMap<String, RawHold>,
523 latch_names: &[String],
524 surface: Surface,
525 warnings: &mut Vec<PresetWarning>,
526) -> Result<Vec<Binding>, PresetError> {
527 let label = surface.prefix();
528 if raw.is_empty() {
529 return Ok(Vec::new());
530 }
531 if system != SystemKind::WarpMesh {
532 warnings.push(PresetWarning::unanchored(format!(
533 "{label}[per_vertex] is inert for system '{}': only `warp_mesh` evaluates a \
534 per-vertex program (bindings kept, but nothing reads them)",
535 system.as_str()
536 )));
537 }
538 let mut out = Vec::with_capacity(raw.len());
539 for (param, source) in raw {
540 // The label an expression error on this binding carries, and so the one
541 // a warning about it carries too.
542 let labelled = format!("{label}[per_vertex] {param}");
543 let expr =
544 expr::compile_with_latches(&source, latch_names).map_err(|err| PresetError::Expr {
545 param: labelled.clone(),
546 err,
547 })?;
548 if !declares(
549 crate::render::scenes::warp_mesh::PER_VERTEX_PARAMS,
550 param.as_str(),
551 ) {
552 warnings.push(PresetWarning::about(
553 labelled.clone(),
554 format!(
555 "unknown {label}[per_vertex] parameter '{param}' (expected one of: {}) \
556 (binding kept, but nothing reads it)",
557 crate::render::scenes::spec_names(
558 crate::render::scenes::warp_mesh::PER_VERTEX_PARAMS
559 )
560 .join(", ")
561 ),
562 ));
563 }
564 if smoothing.contains_key(¶m) {
565 warnings.push(PresetWarning::about(
566 labelled,
567 format!(
568 "{label}[smoothing] entry '{param}' is ignored: it names a [per_vertex] \
569 binding, which is evaluated once per mesh vertex and has no single \
570 value to ease"
571 ),
572 ));
573 }
574 // A load error where the smoothing entry above is a warning: an easing
575 // constant has a degraded form to fall back to and a hold has none.
576 // See `fold_hold`.
577 if hold.contains_key(¶m) {
578 return Err(PresetError::Config(format!(
579 "{} entry '{param}' names a {} binding, which is evaluated once per mesh \
580 vertex and has no single value to hold",
581 surface.table("hold"),
582 surface.table("per_vertex"),
583 )));
584 }
585 out.push(Binding {
586 // Never quantized either: a per-vertex name is drawn from the warp
587 // mesh's own roster, every entry of which is continuous.
588 kind: ParamKind::Modal,
589 name: param,
590 expr,
591 // Never eased and never held — see `Preset::per_vertex`.
592 tau: Easing::INSTANT,
593 hold: None,
594 });
595 }
596 Ok(out)
597}
598
599/// Validate a `[layer]` table (ADR-0090 / Plan 0076). Structural keys —
600/// `system`, `join`, `blend` — follow `[curve] family`'s rule: an unknown value
601/// rejects the preset, because it selects a code path and a silent default
602/// would render a look the author never asked for. Unknown *param* names warn
603/// and keep the binding, exactly like the top level (ADR-0020).
604pub(super) fn build_layer(
605 raw: RawLayer,
606 latch_names: &[String],
607 warnings: &mut Vec<PresetWarning>,
608) -> Result<Layer, PresetError> {
609 let system = SystemKind::from_name(&raw.system)
610 .ok_or_else(|| PresetError::UnknownSystem(raw.system.clone()))?;
611 // Any pair of systems is legal — including the same system twice, and two
612 // line-family systems. The layer's scene is constructed **for the preset**
613 // (`scenes::create_layer_scene`, ADR-0090 point 4 / Plan 0076 Phase 2), so
614 // it shares no GPU state with the roster's instance of the same kind.
615
616 let join = match raw.join.as_deref() {
617 None => LayerJoin::default(),
618 Some(name) => LayerJoin::from_name(name).ok_or_else(|| {
619 PresetError::Config(format!(
620 "unknown [layer] join '{name}' (expected one of: under, over)"
621 ))
622 })?,
623 };
624 let blend = match raw.blend.as_deref() {
625 None => LayerBlend::default(),
626 Some(name) => LayerBlend::from_name(name).ok_or_else(|| {
627 PresetError::Config(format!(
628 "unknown [layer] blend '{name}' (expected one of: {})",
629 LayerBlend::ALL
630 .iter()
631 .map(|b| b.as_str())
632 .collect::<Vec<_>>()
633 .join(", ")
634 ))
635 })?,
636 };
637 if raw.blend.is_some() && join == LayerJoin::Under {
638 warnings.push(PresetWarning::unanchored(format!(
639 "[layer] blend = '{}' is ignored on an under join: the layer shares the \
640 main scene's composite, so there is no junction for a blend mode to \
641 apply at (blend belongs to join = \"over\")",
642 blend.as_str()
643 )));
644 }
645
646 // The layer's bindings, name-sorted off the BTreeMap like the preset's own.
647 let mut params = compile_bindings(system, raw.params, latch_names, Surface::Layer, warnings)?;
648
649 // `[layer.smoothing]`: the same vocabulary, validation and fold as the top
650 // level (ADR-0019 / ADR-0035), against the layer's own bindings.
651 fold_smoothing(&mut params, &raw.smoothing, Surface::Layer, warnings)?;
652
653 // The bindable mix (ADR-0090): compiled like any binding, eased through
654 // `[layer.smoothing] mix`. Parsed now; the `over` blend consumes it in
655 // Plan 0076 Phase 3.
656 let mut mix = raw
657 .mix
658 .as_deref()
659 .map(|source| {
660 let expr = expr::compile_with_latches(source, latch_names).map_err(|err| {
661 PresetError::Expr {
662 param: "[layer] mix".into(),
663 err,
664 }
665 })?;
666 Ok(Binding {
667 name: "mix".into(),
668 expr,
669 tau: raw
670 .smoothing
671 .get("mix")
672 .map_or(Easing::INSTANT, |entry| entry.to_easing()),
673 // Folded below with the layer's own params, which is what lets
674 // `[layer.hold] mix` reach the one layer binding that lives
675 // outside `params`.
676 hold: None,
677 // The junction amount is a fraction, and no scene roster
678 // declares it — it is the composite's, not a scene's.
679 kind: ParamKind::Modal,
680 })
681 })
682 .transpose()?;
683
684 // `[layer.hold]`: the same vocabulary and validation as the top level
685 // (ADR-0180 rule 2), against the layer's own bindings — which are indexed
686 // within the layer, so a layer's hold state cannot collide with the main
687 // preset's. `mix` rides the same walk so an entry naming it is folded
688 // rather than reported inert.
689 fold_hold(
690 params.iter_mut().chain(mix.as_mut()),
691 &raw.hold,
692 Surface::Layer,
693 warnings,
694 )?;
695
696 // `[layer.per_vertex]` — the same surface as the top level's, against the
697 // layer's own system and its own smoothing table.
698 let per_vertex = build_per_vertex(
699 system,
700 raw.per_vertex,
701 &raw.smoothing,
702 &raw.hold,
703 latch_names,
704 Surface::Layer,
705 warnings,
706 )?;
707 warn_vertex_use(¶ms, Surface::Layer, warnings);
708
709 // The layer's structural config, by the same per-system rules as the top
710 // level (ADR-0007) — a layer L-system still requires its `[layer.generator]`
711 // table, a layer attractor still defaults to De Jong.
712 let config = build_config(
713 system,
714 raw.curve,
715 raw.generator,
716 raw.particles,
717 raw.path,
718 raw.spectrum,
719 raw.mesh,
720 raw.field,
721 raw.cellular,
722 raw.plexus,
723 raw.waterfall,
724 raw.voxel,
725 // A `[layer]` carries no `[milk]` table: a converted preset is a whole
726 // preset, and layering one under another is a composition nothing in the
727 // corpus asks for. A layer warp mesh drives its mesh from `[layer.params]`
728 // and `[layer.per_vertex]` like any hand-authored one.
729 None,
730 0,
731 )?;
732
733 Ok(Layer {
734 system,
735 join,
736 blend,
737 mix,
738 params,
739 per_vertex,
740 config,
741 })
742}
743
744/// Fold a declared 64-bit `[generator] seed` into the 32-bit salt the grammar's
745/// `hash()`/`noise()` mix in (ADR-0051).
746///
747/// XOR-folded rather than truncated, so two seeds differing only in their high
748/// half still salt differently — `seed` is a `u64` in the schema and always has
749/// been, and silently ignoring half of what an author typed is the kind of
750/// surprise this file exists to prevent.
751pub(super) fn salt_from_seed(seed: u64) -> u32 {
752 (seed as u32) ^ ((seed >> 32) as u32)
753}
754
755/// A salt drawn from OS entropy — the `seed = "random"` path (ADR-0051). Called
756/// **once per preset load**, in the live app only: no capture path reaches it,
757/// because a capture reads [`Preset::pinned_salt`] instead.
758///
759/// `RandomState` is the standard library's own entropy: its keys are seeded from
760/// the OS once per process and advance on every `new()`, so two loads of the same
761/// preset — in one run or across two — draw different salts. Using it is what
762/// keeps "sometimes crazy" from costing a dependency (`lightweight is a feature`).
763pub(super) fn entropy_salt() -> u32 {
764 use std::collections::hash_map::RandomState;
765 use std::hash::BuildHasher;
766 salt_from_seed(RandomState::new().hash_one(0u64))
767}
768
769/// Assemble the optional structural config for `system` from the raw tables,
770/// validating at this boundary (ADR-0007). Non-line systems have no config.
771#[allow(clippy::too_many_arguments)]
772pub(super) fn build_config(
773 system: SystemKind,
774 curve: Option<RawCurve>,
775 generator: Option<RawGenerator>,
776 particles: Option<RawParticles>,
777 path: Option<RawPath>,
778 spectrum: Option<RawSpectrum>,
779 mesh: Option<RawMesh>,
780 field: Option<RawField>,
781 cellular: Option<RawCellular>,
782 plexus: Option<RawPlexus>,
783 waterfall: Option<RawWaterfall>,
784 voxel: Option<RawVoxel>,
785 milk: Option<RawMilk>,
786 salt: u32,
787) -> Result<Option<GeneratorConfig>, PresetError> {
788 match system {
789 // A curve preset without a `[curve]` table accepts the family default.
790 SystemKind::ParametricCurve => curve.map(RawCurve::into_config).transpose(),
791 // A generator preset must declare its `[generator]` table.
792 SystemKind::LSystem => {
793 let g = generator.ok_or_else(|| {
794 PresetError::Config("lsystem requires a [generator] table".into())
795 })?;
796 Ok(Some(g.into_lsystem()?))
797 }
798 SystemKind::StarPattern => {
799 let g = generator.ok_or_else(|| {
800 PresetError::Config("star_pattern requires a [generator] table".into())
801 })?;
802 Ok(Some(g.into_star()?))
803 }
804 // The attractor scene selects its map via an optional `[particles]` table;
805 // absent, it defaults to De Jong. Config is always `Some` so `configure`
806 // runs on every preset switch (resetting the family — never stale).
807 SystemKind::Attractor => {
808 let (family, density, morph_to, tuple_path) = match particles {
809 Some(p) => {
810 let family = AttractorFamily::from_name(&p.family).ok_or_else(|| {
811 PresetError::Config(format!("unknown attractor family '{}'", p.family))
812 })?;
813 (
814 family,
815 p.density()?,
816 p.morph_to(family)?,
817 p.tuple_path(family)?,
818 )
819 }
820 None => (AttractorFamily::DeJong, 1.0, None, None),
821 };
822 Ok(Some(GeneratorConfig::Particles {
823 family,
824 density,
825 morph_to,
826 tuple_path,
827 }))
828 }
829 // The spectrum readout selects its element count, layout and per-element
830 // easing through an optional `[spectrum]` table; absent, it takes the
831 // defaults. Config is always `Some` so `configure` runs on every preset
832 // switch (resizing the element buffer — never stale).
833 SystemKind::Spectrum => Ok(Some(match spectrum {
834 Some(s) => s.into_config()?,
835 None => RawSpectrum::default().into_config()?,
836 })),
837 // The warp mesh's grid is structural for `[curve] family`'s reason: the
838 // vertex and index buffers are built from it, and an eased grid would
839 // rebuild them mid-frame. Config is always `Some` so `configure` runs on
840 // every preset switch (resizing the mesh — never stale).
841 SystemKind::WarpMesh => {
842 let bundle = milk.map(|raw| raw.into_bundle()).transpose()?;
843 Ok(Some(
844 mesh.unwrap_or_default()
845 .into_config(bundle.map(Box::new), salt)?,
846 ))
847 }
848 // The shape field takes an OPTIONAL `[path]` table (ADR-0107): the
849 // roster is still a closed list selected by the numeric `shape` param
850 // (ADR-0084/ADR-0105), and a preset naming no path draws it exactly as
851 // it did before — the table is an alternative source of a silhouette,
852 // not a replacement for the roster.
853 //
854 // Config is always `Some` so `configure` runs on every preset switch
855 // (clearing the contour — never stale), which is why the absent table is
856 // a `None` INSIDE the variant rather than an absent config.
857 SystemKind::ShapeField => {
858 let parsed = path.map(RawPath::into_parsed).transpose()?;
859 Ok(Some(GeneratorConfig::Path {
860 shape: parsed.as_ref().map(|p| p.shape.clone()),
861 morph_to: parsed.and_then(|p| p.morph_to),
862 }))
863 }
864 // The analytic field's family is structural for `[curve] family`'s
865 // reason — it selects a code path — and an absent table is the default
866 // family. Config is always `Some` so `configure` runs on every preset
867 // switch (resetting the family — never stale).
868 SystemKind::AnalyticField => Ok(Some(GeneratorConfig::Field(match field {
869 Some(f) => f.into_config()?,
870 None => FieldConfig::default(),
871 }))),
872 // The automaton's family, grid and edge rule are structural: the family
873 // selects a rule, and the grid sizes the state textures, which an eased
874 // value would rebuild mid-frame. Config is always `Some` so `configure`
875 // runs on every preset switch and reseeds the field — never stale. The
876 // salt is the pinned one, as the warp mesh's is: the automaton is a pure
877 // function of the preset in the live app as well as in a capture.
878 SystemKind::Cellular => Ok(Some(GeneratorConfig::Cellular(
879 cellular.unwrap_or_default().into_config(salt)?,
880 ))),
881 // The layout, point count and seed are structural: the count sizes the
882 // point set and the seed places it, and an eased value would re-seed it
883 // mid-frame. Config is always `Some` so `configure` runs on every preset
884 // switch and re-seeds the points — never stale. An absent `[plexus]
885 // seed` takes the pinned salt, so a network is a pure function of the
886 // preset in the live app as well as in a capture.
887 SystemKind::Plexus => Ok(Some(GeneratorConfig::Plexus(
888 plexus.unwrap_or_default().into_config(salt)?,
889 ))),
890 // The band count, row count and push period are structural: they size
891 // the row ring, which an eased value would resize mid-frame. Config is
892 // always `Some` so `configure` runs on every preset switch and clears
893 // the ring — the outgoing preset's landscape never scrolls on under the
894 // incoming one.
895 SystemKind::Waterfall => Ok(Some(waterfall.unwrap_or_default().into_config()?)),
896 // The grid sizes the state textures and the rules select code paths, so
897 // both are structural. Config is always `Some` so `configure` runs on
898 // every preset switch and reseeds the volume — never stale. The salt is
899 // the pinned one, as the cellular system's is.
900 SystemKind::Voxel => Ok(Some(GeneratorConfig::Voxel(
901 voxel.unwrap_or_default().into_config(salt)?,
902 ))),
903 // Reaction-diffusion drives its regime through named params (feed/kill/
904 // flow), not a declarative structural table. `shape_collage`'s structure
905 // is an authored element list compiled into the scene, and its seeded
906 // layout grammar is selected by named params too, so that arm is
907 // expected to stay where it is.
908 SystemKind::FragmentField
909 | SystemKind::Swarm
910 | SystemKind::ReactionDiffusion
911 | SystemKind::Emitter
912 | SystemKind::ShapeCollage => Ok(None),
913 }
914}
915
916/// Which surface a binding table belongs to: the preset itself, or its
917/// `[layer]`.
918///
919/// The two compile bindings, validate `[smoothing]` and warn about a stray
920/// vertex variable by identical rules, and differ in exactly three ways -- how a
921/// message names the parameter, which TOML table it names, and whether a
922/// compositing parameter counts as known there. One value carries all three
923/// rather than three flags at every call.
924#[derive(Clone, Copy, PartialEq, Eq)]
925pub(super) enum Surface {
926 Preset,
927 Layer,
928}
929
930impl Surface {
931 /// What every message on this surface prefixes a parameter name with.
932 fn prefix(self) -> &'static str {
933 match self {
934 Surface::Preset => "",
935 Surface::Layer => "[layer] ",
936 }
937 }
938
939 /// `table("smoothing")` is `[smoothing]` at the top level and
940 /// `[layer.smoothing]` inside a layer.
941 fn table(self, name: &str) -> String {
942 match self {
943 Surface::Preset => format!("[{name}]"),
944 Surface::Layer => format!("[layer.{name}]"),
945 }
946 }
947}
948
949/// Parameters a system declared once and retired, each with what replaced it.
950///
951/// A binding to one is a **load error**, where any other undeclared name is a
952/// warning: a retired name is not a typo, and a preset that kept it would load
953/// and quietly render without what it asked for — on the attractor, without
954/// its depth (ADR-0260).
955const RETIRED_PARAMS: &[(SystemKind, &str, &str)] = &[(
956 SystemKind::Attractor,
957 "perspective",
958 "the attractor's 3D figures project through the shared camera now (ADR-0260): \
959 bind `distance` and `fov` instead, with `distance = E / perspective` and \
960 `tan(fov / 2) = perspective / (E * footprint)` reproducing the old picture",
961)];
962
963/// Compile a `[params]` table into bindings, warning about every name the
964/// surface does not consume.
965///
966/// A name the system does not consume is a warning, not an error: one typo must
967/// not discard the rest of an otherwise-good preset (ADR-0020 / NFR 10). The
968/// binding is kept -- an unconsumed param is harmless at apply time, and
969/// dropping it would turn a surfaced warning back into a silent loss.
970///
971/// `tau` is left [`Easing::INSTANT`] here and filled by [`fold_smoothing`] once
972/// the `[smoothing]` table has been validated, which is what preserves which
973/// error a preset with several problems reports first.
974pub(super) fn compile_bindings(
975 system: SystemKind,
976 params: BTreeMap<String, String>,
977 latch_names: &[String],
978 surface: Surface,
979 warnings: &mut Vec<PresetWarning>,
980) -> Result<Vec<Binding>, PresetError> {
981 let prefix = surface.prefix();
982 // The raw params come from a BTreeMap, so bindings land name-sorted:
983 // evaluation is order-independent, but determinism is cheap to keep.
984 let mut out = Vec::with_capacity(params.len());
985 for (param, source) in params {
986 let labelled = format!("{prefix}{param}");
987 if let Some((_, _, instead)) = RETIRED_PARAMS
988 .iter()
989 .find(|(owner, name, _)| *owner == system && *name == param)
990 {
991 return Err(PresetError::Config(format!(
992 "{prefix}parameter '{param}' was retired from system '{}': {instead}",
993 system.as_str()
994 )));
995 }
996 let expr =
997 expr::compile_with_latches(&source, latch_names).map_err(|err| PresetError::Expr {
998 param: labelled.clone(),
999 err,
1000 })?;
1001 let known = match surface {
1002 Surface::Preset => is_known_param(system, ¶m),
1003 // A layer binds its own scene's params only, never the compositing
1004 // stages -- so a global here is a *different* mistake from a typo
1005 // and says so.
1006 Surface::Layer => declares(system.param_specs(), param.as_str()),
1007 };
1008 if !known {
1009 if surface == Surface::Layer
1010 && GLOBAL_PARAMS
1011 .iter()
1012 .any(|stage| declares(stage, param.as_str()))
1013 {
1014 warnings.push(PresetWarning::about(
1015 labelled,
1016 format!(
1017 "[layer] parameter '{param}' is a compositing parameter; a layer \
1018 binds only its own scene's params — bind it at the top level, \
1019 where it drives the whole preset (binding kept, but nothing \
1020 reads it here)"
1021 ),
1022 ));
1023 } else {
1024 warnings.push(PresetWarning::about(
1025 labelled,
1026 format!(
1027 "unknown {prefix}parameter '{param}' for system '{}' (binding kept, but nothing reads it)",
1028 system.as_str()
1029 ),
1030 ));
1031 }
1032 }
1033 out.push(Binding {
1034 // Read off the engine's declaration once, here, so nothing per
1035 // frame searches a roster by name (ADR-0180 rule 2). At the layer
1036 // surface the search still walks the global stages and finds
1037 // nothing there, because `known` above already rejected them.
1038 kind: kind_of_param(system, ¶m),
1039 name: param,
1040 expr,
1041 tau: Easing::INSTANT,
1042 hold: None,
1043 });
1044 }
1045 Ok(out)
1046}
1047
1048/// Validate the `[smoothing]` table, then fold it into the bindings' `tau`.
1049///
1050/// Validation runs over the whole table first so a preset with two bad entries
1051/// reports the same one it always did. An entry naming a per-element binding is
1052/// inert -- a series has no single value to ease -- and says so rather than
1053/// silently doing nothing.
1054pub(super) fn fold_smoothing(
1055 params: &mut [Binding],
1056 smoothing: &BTreeMap<String, RawSmoothing>,
1057 surface: Surface,
1058 warnings: &mut Vec<PresetWarning>,
1059) -> Result<(), PresetError> {
1060 let prefix = surface.prefix();
1061 for (param, entry) in smoothing {
1062 let named = format!("{prefix}{param}");
1063 match *entry {
1064 RawSmoothing::Symmetric(seconds) => check_tau(&named, None, seconds)?,
1065 RawSmoothing::Asymmetric { attack, release } => {
1066 check_tau(&named, Some("attack"), attack)?;
1067 check_tau(&named, Some("release"), release)?;
1068 }
1069 }
1070 }
1071 for binding in params {
1072 binding.tau = smoothing
1073 .get(&binding.name)
1074 .map_or(Easing::INSTANT, |entry| entry.to_easing());
1075 if binding.expr.uses_index() && smoothing.contains_key(&binding.name) {
1076 warnings.push(PresetWarning::about(
1077 format!("{prefix}{}", binding.name),
1078 format!(
1079 "{} entry '{}' is ignored: the binding names `index`, so it is \
1080 evaluated per element and cannot be eased as one value \
1081 (use {} smoothing for the element levels)",
1082 surface.table("smoothing"),
1083 binding.name,
1084 surface.table("spectrum"),
1085 ),
1086 ));
1087 binding.tau = Easing::INSTANT;
1088 }
1089 }
1090 Ok(())
1091}
1092
1093/// Validate the `[hold]` table, then fold it into the bindings' `hold`
1094/// (ADR-0180 rule 2).
1095///
1096/// [`fold_smoothing`]'s shape and its boundary, with one deliberate difference:
1097/// a `[hold]` entry naming a **per-element** binding is a load **error**, not a
1098/// warning. `[smoothing]` can degrade to instant and still render the preset
1099/// the author wrote; a hold cannot degrade to anything -- the binding it names
1100/// keeps re-picking its figure every frame, which is the exact defect the table
1101/// exists to remove, and a warning would leave that looking like the engine
1102/// ignoring a table it accepted. A per-vertex binding is rejected by the same
1103/// rule, from `build_per_vertex`, where the per-vertex table is in scope.
1104///
1105/// An entry naming a parameter this surface does not bind is a **warning**, in
1106/// `[occupancy] exempt`'s shape and for its reason: it silences nothing and
1107/// holds nothing, and a typo must not discard the rest of a good preset.
1108/// `bindings` is every binding this table may reach: a surface's `[params]`,
1109/// plus the layer's bindable `mix`, which is a binding living outside `params`
1110/// and is eased through `[layer.smoothing] mix` by the same reasoning.
1111pub(super) fn fold_hold<'b>(
1112 bindings: impl Iterator<Item = &'b mut Binding>,
1113 hold: &BTreeMap<String, RawHold>,
1114 surface: Surface,
1115 warnings: &mut Vec<PresetWarning>,
1116) -> Result<(), PresetError> {
1117 if hold.is_empty() {
1118 return Ok(());
1119 }
1120 let prefix = surface.prefix();
1121 // Validated over the whole table first, so a preset with two bad entries
1122 // reports the name-ordered first of them whatever its bindings look like.
1123 let mut edges: BTreeMap<&str, HoldEdge> = BTreeMap::new();
1124 for (param, entry) in hold {
1125 edges.insert(param, entry.to_edge(&format!("{prefix}{param}"))?);
1126 }
1127 let mut folded: Vec<&str> = Vec::new();
1128 for binding in bindings {
1129 let Some((¶m, &edge)) = edges.get_key_value(binding.name.as_str()) else {
1130 continue;
1131 };
1132 if binding.expr.uses_index() {
1133 return Err(PresetError::Config(format!(
1134 "{} entry '{param}' names a binding that reads `index`, so it is evaluated \
1135 once per element and has no single value to hold",
1136 surface.table("hold"),
1137 )));
1138 }
1139 binding.hold = Some(edge);
1140 folded.push(param);
1141 }
1142 for param in edges.keys() {
1143 if !folded.contains(param) {
1144 // Unanchored: the entry names a binding this surface does not have.
1145 warnings.push(PresetWarning::unanchored(format!(
1146 "{} entry '{param}' is inert: this preset binds no such parameter",
1147 surface.table("hold"),
1148 )));
1149 }
1150 }
1151 Ok(())
1152}
1153
1154/// Warn for every binding that reaches for a vertex variable outside a
1155/// `[per_vertex]` table, where it reads a flat zero.
1156///
1157/// The names are crate-wide because expression slots are positional, so nothing
1158/// stops a `[params]` binding from naming one; only a `[per_vertex]` table ever
1159/// binds them.
1160pub(super) fn warn_vertex_use(
1161 params: &[Binding],
1162 surface: Surface,
1163 warnings: &mut Vec<PresetWarning>,
1164) {
1165 let prefix = surface.prefix();
1166 for binding in params {
1167 if binding.expr.uses_vertex() {
1168 warnings.push(PresetWarning::about(
1169 format!("{prefix}{}", binding.name),
1170 format!(
1171 "{prefix}parameter '{}' names a per-vertex variable (x/y/rad/ang), \
1172 which reads 0 outside a {} table",
1173 binding.name,
1174 surface.table("per_vertex"),
1175 ),
1176 ));
1177 }
1178 }
1179}