Skip to main content

pfx_bake/
recipe.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::f32::consts::TAU;
3use std::fs;
4use std::path::{Component, Path, PathBuf};
5
6use pfx_core::daylight::{Authored, DampedClock, Daylight, REFERENCE_HOUR, SunArc};
7use pfx_load::{Mesh, RoomSky, Sky};
8use pfx_materials::{Library, Material};
9use pfx_trace::Sun;
10use pfx_trace::bvh::Triangle;
11use serde::Deserialize;
12use sha2::{Digest, Sha256};
13
14use crate::compose::{self, Files, Palette};
15use crate::reflection::ReflectionSpec;
16use crate::{Anchor, BakeScene, GridSpec, emitter_hash, scene_hash};
17
18#[derive(Clone, Debug, Deserialize)]
19#[serde(deny_unknown_fields)]
20pub struct Volume {
21    pub name: String,
22    #[serde(flatten)]
23    pub grid: GridSpec,
24}
25
26#[derive(Clone, Debug)]
27pub struct Recipe {
28    pub scene: String,
29    pub product: String,
30    pub materials: Option<Materials>,
31    pub fallback: Option<String>,
32    pub tint: BTreeMap<String, [f32; 3]>,
33    pub anchors: Vec<f32>,
34    pub samples: u32,
35    pub seed: u32,
36    pub day: f64,
37    pub latitude: f64,
38    pub heading: f64,
39    pub reference_hour: f64,
40    pub sky: SkyRecipe,
41    pub sun: SunModel,
42    pub detail: Option<DetailRecipe>,
43    pub shadow_only_nodes: Vec<String>,
44    pub volumes: Vec<Volume>,
45    pub reflections: Vec<ReflectionSpec>,
46    pub assets_env: Option<String>,
47    pub files: Vec<FileRecipe>,
48    pub poses: Vec<PoseRecipe>,
49    pub shadow_only_materials: Vec<String>,
50    pub drop_degenerate: bool,
51    pub emitters: Vec<EmitterRecipe>,
52    pub emitter_layer: Option<EmitterLayer>,
53}
54
55#[derive(Clone, Debug, PartialEq, Eq)]
56pub enum Materials {
57    Library(String),
58}
59
60#[derive(Deserialize)]
61#[serde(deny_unknown_fields)]
62struct Raw {
63    scene: String,
64    app: Option<String>,
65    product: Option<String>,
66    materials: Option<String>,
67    fallback: Option<String>,
68    #[serde(default)]
69    tint: BTreeMap<String, [f32; 3]>,
70    anchors: Vec<f32>,
71    samples: u32,
72    #[serde(default = "default_seed")]
73    seed: u32,
74    #[serde(default = "default_day")]
75    day: f64,
76    #[serde(default = "default_latitude")]
77    latitude: f64,
78    #[serde(default = "default_heading")]
79    heading: f64,
80    #[serde(default = "default_reference_hour")]
81    reference_hour: f64,
82    #[serde(default)]
83    sky: RawSky,
84    sun: Option<RawSun>,
85    detail: Option<DetailRecipe>,
86    #[serde(default)]
87    shadow_only_nodes: Vec<String>,
88    #[serde(default)]
89    volumes: Vec<Volume>,
90    #[serde(default, rename = "reflection")]
91    reflections: Vec<ReflectionSpec>,
92    assets_env: Option<String>,
93    clock: Option<RawClock>,
94    #[serde(default, rename = "file")]
95    files: Vec<FileRecipe>,
96    #[serde(default, rename = "pose")]
97    poses: Vec<PoseRecipe>,
98    #[serde(default)]
99    shadow_only_materials: Vec<String>,
100    #[serde(default)]
101    drop_degenerate: bool,
102    #[serde(default, rename = "emitter")]
103    emitters: Vec<EmitterRecipe>,
104    emitter_layer: Option<EmitterLayer>,
105    #[serde(default, rename = "plates")]
106    _plates: Option<toml::Value>,
107}
108
109#[derive(Clone, Copy, Debug, PartialEq)]
110pub struct SunRecipe {
111    pub toward: [f64; 3],
112    pub color: [f64; 3],
113    pub irradiance: f32,
114    pub sky_fill: f64,
115}
116
117#[derive(Clone, Copy, Debug, PartialEq)]
118pub enum SunModel {
119    Solar,
120    Fixed(SunRecipe),
121    Authored(SunRecipe),
122    Clock { clock: DampedClock, base: [f64; 3] },
123}
124
125#[derive(Deserialize)]
126#[serde(deny_unknown_fields)]
127struct RawSun {
128    model: Option<String>,
129    toward: Option<[f64; 3]>,
130    color: Option<[f64; 3]>,
131    irradiance: Option<f32>,
132    sky_fill: Option<f64>,
133}
134
135#[derive(Deserialize)]
136#[serde(deny_unknown_fields)]
137struct RawClock {
138    toward: [f64; 3],
139    sun: Option<f64>,
140    tint: Option<f64>,
141    elevation: Option<f64>,
142    azimuth: Option<f64>,
143    skywarm: Option<f64>,
144    lamp: Option<f64>,
145    exposure: Option<f64>,
146    warmth: Option<f64>,
147    turn: Option<f64>,
148    arc: Option<RawArc>,
149}
150
151#[derive(Deserialize)]
152#[serde(deny_unknown_fields)]
153struct RawArc {
154    start: f64,
155    span: f64,
156    elevation: f64,
157    azimuth: f64,
158    sweep: f64,
159    reference: f64,
160}
161
162#[derive(Clone, Debug, Deserialize)]
163#[serde(deny_unknown_fields)]
164pub struct FileRecipe {
165    pub path: String,
166    pub translation: Option<[f32; 3]>,
167    pub keep_prefix: Option<String>,
168}
169
170#[derive(Clone, Debug, Deserialize)]
171#[serde(deny_unknown_fields)]
172pub struct PoseRecipe {
173    pub file: Option<String>,
174    pub node: String,
175    #[serde(default)]
176    pub translation: [f32; 3],
177    #[serde(default)]
178    pub yaw: f32,
179    #[serde(default = "unit_scale")]
180    pub scale: [f32; 3],
181    #[serde(default)]
182    pub materials: BTreeMap<String, String>,
183}
184
185fn unit_scale() -> [f32; 3] {
186    [1.0; 3]
187}
188
189#[derive(Clone, Debug, Deserialize)]
190#[serde(deny_unknown_fields)]
191pub struct EmitterRecipe {
192    pub node: Option<String>,
193    pub position: Option<[f32; 3]>,
194    pub radius: Option<f32>,
195    #[serde(alias = "colour")]
196    pub color: [f32; 3],
197    pub intensity: f32,
198    pub scales: Option<Vec<f32>>,
199    pub preset: Option<String>,
200}
201
202#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
203#[serde(deny_unknown_fields)]
204pub struct EmitterLayer {
205    #[serde(default)]
206    pub direct: bool,
207}
208
209#[derive(Clone, Debug, Deserialize)]
210#[serde(deny_unknown_fields)]
211pub struct DetailRecipe {
212    #[serde(default = "default_detail_size")]
213    pub size: u32,
214    #[serde(default = "default_detail_padding")]
215    pub padding: u32,
216    #[serde(default)]
217    pub dynamic_nodes: Vec<String>,
218    #[serde(default)]
219    pub parts: Vec<String>,
220    #[serde(default)]
221    pub sizes: BTreeMap<String, u32>,
222    #[serde(default)]
223    pub view: Option<DetailView>,
224    #[serde(default = "default_detail_page")]
225    pub page: u32,
226}
227
228fn default_detail_page() -> u32 {
229    crate::detail::atlas::PAGE
230}
231
232#[derive(Clone, Debug, Deserialize)]
233#[serde(deny_unknown_fields)]
234pub struct DetailView {
235    pub position: [f32; 3],
236    pub target: [f32; 3],
237    pub fov_y_deg: f32,
238    #[serde(default)]
239    pub shift: [f32; 2],
240    #[serde(default = "default_view_width")]
241    pub width: u32,
242    #[serde(default = "default_view_height")]
243    pub height: u32,
244}
245
246fn default_view_width() -> u32 {
247    3840
248}
249
250fn default_view_height() -> u32 {
251    2160
252}
253
254impl DetailRecipe {
255    pub fn check(&self) -> Result<(), String> {
256        let valid = |size: u32| {
257            (crate::detail::FLOOR..=crate::detail::LIMIT).contains(&size) && size.is_power_of_two()
258        };
259        if !valid(self.size) || self.padding > self.size / 4 {
260            return Err(format!(
261                "detail needs a power-of-two size from {} to {} and valid padding",
262                crate::detail::FLOOR,
263                crate::detail::LIMIT
264            ));
265        }
266        if !(crate::detail::FLOOR..=crate::detail::atlas::PAGE_LIMIT).contains(&self.page)
267            || !self.page.is_power_of_two()
268        {
269            return Err(format!(
270                "detail page needs a power of two from {} to {}",
271                crate::detail::FLOOR,
272                crate::detail::atlas::PAGE_LIMIT
273            ));
274        }
275        if self.dynamic_nodes.iter().any(|name| name.is_empty()) {
276            return Err("detail dynamic nodes need names".into());
277        }
278        if self.parts.iter().any(|name| name.is_empty()) {
279            return Err("detail parts need names".into());
280        }
281        for (name, &size) in &self.sizes {
282            if name.is_empty() || !valid(size) || self.padding > size / 4 {
283                return Err(format!(
284                    "detail size for {name:?} needs a power of two from {} to {} and valid padding",
285                    crate::detail::FLOOR,
286                    crate::detail::LIMIT
287                ));
288            }
289        }
290        if let Some(view) = &self.view
291            && (view.width == 0
292                || view.height == 0
293                || !(view.fov_y_deg > 0.0 && view.fov_y_deg < 180.0)
294                || view.position == view.target
295                || view
296                    .position
297                    .iter()
298                    .chain(&view.target)
299                    .chain(&view.shift)
300                    .any(|value| !value.is_finite()))
301        {
302            return Err("detail view needs a camera apart from its target, a field of view between 0 and 180 degrees and a size".into());
303        }
304        Ok(())
305    }
306}
307
308fn default_detail_size() -> u32 {
309    1024
310}
311
312fn default_detail_padding() -> u32 {
313    8
314}
315
316#[derive(Clone, Copy, Debug, PartialEq, Eq)]
317pub enum SkyKind {
318    Daylight,
319    Hdr,
320    Room,
321}
322
323#[derive(Clone, Copy, Debug, PartialEq, Eq)]
324pub enum Ambient {
325    Authored,
326    Clock,
327}
328
329#[derive(Clone, Copy, Debug, Default, PartialEq, Deserialize)]
330#[serde(deny_unknown_fields)]
331pub struct Prepare {
332    pub cap: Option<f32>,
333    pub balance: Option<[f32; 3]>,
334    pub mean: Option<f32>,
335}
336
337impl Prepare {
338    pub fn apply(&self, sky: Sky) -> Sky {
339        let sky = match self.cap {
340            Some(cap) => sky.capped_luminance(cap),
341            None => sky,
342        };
343        let sky = match self.balance {
344            Some(balance) => sky.balanced_to(balance),
345            None => sky,
346        };
347        match self.mean {
348            Some(mean) => sky.scaled_to_mean_luminance(mean),
349            None => sky,
350        }
351    }
352}
353
354#[derive(Clone, Debug)]
355pub struct SkyRecipe {
356    pub kind: SkyKind,
357    pub path: Option<String>,
358    pub prepare: Option<Prepare>,
359    pub rotation_deg: f32,
360    pub intensity: f32,
361    pub shade: BTreeMap<String, [f32; 3]>,
362    pub room: Option<RoomSky>,
363    pub ambient: Option<Ambient>,
364}
365
366#[derive(Deserialize)]
367#[serde(deny_unknown_fields)]
368struct RawSky {
369    kind: Option<String>,
370    path: Option<String>,
371    prepare: Option<toml::Value>,
372    balance: Option<[f32; 3]>,
373    #[serde(default)]
374    rotation_deg: f32,
375    #[serde(default = "unit_intensity")]
376    intensity: f32,
377    #[serde(default)]
378    shade: BTreeMap<String, [f32; 3]>,
379    room: Option<RoomSky>,
380    ambient: Option<String>,
381}
382
383impl Default for RawSky {
384    fn default() -> Self {
385        Self {
386            kind: None,
387            path: None,
388            prepare: None,
389            balance: None,
390            rotation_deg: 0.0,
391            intensity: 1.0,
392            shade: BTreeMap::new(),
393            room: None,
394            ambient: None,
395        }
396    }
397}
398
399fn unit_intensity() -> f32 {
400    1.0
401}
402
403fn default_seed() -> u32 {
404    7
405}
406fn default_day() -> f64 {
407    Daylight::DAY
408}
409fn default_latitude() -> f64 {
410    Daylight::LATITUDE
411}
412fn default_heading() -> f64 {
413    Daylight::HEADING
414}
415fn default_reference_hour() -> f64 {
416    REFERENCE_HOUR
417}
418
419fn slug(name: &str) -> bool {
420    !name.is_empty()
421        && name
422            .chars()
423            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
424}
425
426fn rgb_valid(rgb: &[f32; 3]) -> bool {
427    rgb.iter().all(|v| v.is_finite() && *v >= 0.0)
428}
429
430fn material_source(name: Option<&str>) -> Result<Option<Materials>, String> {
431    let Some(name) = name else {
432        return Ok(None);
433    };
434    if name.ends_with(".toml") {
435        let path = Path::new(name);
436        if path.is_absolute()
437            || path.file_stem().is_none()
438            || !path
439                .components()
440                .all(|part| matches!(part, Component::Normal(_) | Component::ParentDir))
441        {
442            return Err(
443                "materials names a library .toml file by a path relative to the recipe".into(),
444            );
445        }
446        return Ok(Some(Materials::Library(name.to_owned())));
447    }
448    Err("materials names a library .toml file relative to the recipe".into())
449}
450
451fn authored(sun: &RawSun, what: &str) -> Result<SunRecipe, String> {
452    let (Some(toward), Some(color), Some(irradiance)) = (sun.toward, sun.color, sun.irradiance)
453    else {
454        return Err(format!("{what} needs toward, color and irradiance"));
455    };
456    if toward.iter().any(|v| !v.is_finite())
457        || toward.iter().map(|v| v * v).sum::<f64>() <= 1e-12
458        || color.iter().any(|v| !v.is_finite() || *v < 0.0)
459        || !irradiance.is_finite()
460        || irradiance < 0.0
461    {
462        return Err("sun needs finite toward, color and nonnegative irradiance".into());
463    }
464    let sky_fill = sun.sky_fill.unwrap_or(0.0);
465    if !sky_fill.is_finite() || sky_fill < 0.0 {
466        return Err("sun sky_fill must be finite and nonnegative".into());
467    }
468    Ok(SunRecipe {
469        toward,
470        color,
471        irradiance,
472        sky_fill,
473    })
474}
475
476fn clock_model(clock: &RawClock) -> Result<SunModel, String> {
477    let (
478        Some(sun),
479        Some(tint),
480        Some(elevation),
481        Some(azimuth),
482        Some(skywarm),
483        Some(lamp),
484        Some(exposure),
485        Some(warmth),
486        Some(turn),
487    ) = (
488        clock.sun,
489        clock.tint,
490        clock.elevation,
491        clock.azimuth,
492        clock.skywarm,
493        clock.lamp,
494        clock.exposure,
495        clock.warmth,
496        clock.turn,
497    )
498    else {
499        return Err("[clock] for sun model = 'clock' needs toward, sun, tint, elevation, azimuth, skywarm, lamp, exposure, warmth and turn".into());
500    };
501    let Some(arc) = &clock.arc else {
502        return Err("[clock] needs a [clock.arc] table with start, span, elevation, azimuth, sweep and reference".into());
503    };
504    let arc = SunArc {
505        start: arc.start,
506        span: arc.span,
507        elevation: arc.elevation,
508        azimuth: arc.azimuth,
509        sweep: arc.sweep,
510        reference: arc.reference,
511    };
512    if [
513        arc.start,
514        arc.span,
515        arc.elevation,
516        arc.azimuth,
517        arc.sweep,
518        arc.reference,
519    ]
520    .iter()
521    .any(|v| !v.is_finite())
522        || arc.span <= 0.0
523    {
524        return Err("clock.arc needs finite values and a positive span".into());
525    }
526    let toward = clock.toward;
527    if toward.iter().any(|v| !v.is_finite())
528        || toward.iter().map(|v| v * v).sum::<f64>() <= 1e-12
529        || [sun, tint, skywarm, lamp, exposure, warmth]
530            .iter()
531            .any(|v| !v.is_finite() || *v < 0.0)
532        || [elevation, azimuth, turn].iter().any(|v| !v.is_finite())
533    {
534        return Err("clock needs a direction and finite, nonnegative values".into());
535    }
536    Ok(SunModel::Clock {
537        clock: DampedClock {
538            sun,
539            tint,
540            elevation,
541            azimuth,
542            skywarm,
543            lamp,
544            exposure,
545            warmth,
546            turn,
547            arc,
548        },
549        base: toward,
550    })
551}
552
553fn sun_model(raw: &Raw) -> Result<SunModel, String> {
554    if let Some(sun) = &raw.sun
555        && let Some(model) = sun.model.as_deref()
556    {
557        let values = sun.toward.is_some() || sun.color.is_some() || sun.irradiance.is_some();
558        return match model {
559            "solar" | "clock" if values || sun.sky_fill.is_some() => Err(format!(
560                "sun model = '{model}' takes no toward, color, irradiance or sky_fill"
561            )),
562            "fixed" if sun.sky_fill.is_some() => {
563                Err("sky_fill belongs to sun model = 'authored'".into())
564            }
565            "solar" | "fixed" | "authored" if raw.clock.is_some() => {
566                Err("clock and sun cannot both set the light; use [sun] model = 'clock'".into())
567            }
568            "solar" => Ok(SunModel::Solar),
569            "fixed" => Ok(SunModel::Fixed(authored(sun, "sun model = 'fixed'")?)),
570            "authored" => Ok(SunModel::Authored(authored(sun, "sun model = 'authored'")?)),
571            "clock" => match &raw.clock {
572                Some(clock) => clock_model(clock),
573                None => Err("sun model = 'clock' needs a [clock] table".into()),
574            },
575            other => Err(format!(
576                "sun model is 'solar', 'fixed', 'authored' or 'clock', not '{other}'"
577            )),
578        };
579    }
580    match (&raw.sun, &raw.clock) {
581        (Some(_), _) => Err(
582            "[sun] needs model = 'solar', 'fixed', 'authored' or 'clock'; values alone need model = 'authored'"
583                .into(),
584        ),
585        (None, Some(_)) => Err("[clock] needs [sun] model = 'clock'".into()),
586        (None, None) => Ok(SunModel::Solar),
587    }
588}
589
590fn sky_recipe(sky: RawSky, sun: &SunModel) -> Result<SkyRecipe, String> {
591    let kind = match sky.kind.as_deref() {
592        None | Some("daylight") | Some("analytic") => SkyKind::Daylight,
593        Some("hdr") => SkyKind::Hdr,
594        Some("room") => SkyKind::Room,
595        Some(other) => {
596            return Err(format!(
597                "sky kind is 'daylight', 'hdr' with a path, or 'room' with a [sky.room] table, not '{other}'"
598            ));
599        }
600    };
601    let spelled = sky.kind.as_deref().unwrap_or("daylight");
602    match (kind, sky.path.is_some()) {
603        (SkyKind::Hdr, false) => return Err("sky kind = 'hdr' needs a path".into()),
604        (SkyKind::Daylight | SkyKind::Room, true) => {
605            return Err("a sky path needs kind = 'hdr'".into());
606        }
607        _ => {}
608    }
609    match (kind, &sky.room) {
610        (SkyKind::Room, None) => {
611            return Err(format!("sky kind = '{spelled}' needs a [sky.room] table"));
612        }
613        (SkyKind::Room, Some(room)) => {
614            if sky.prepare.is_some() {
615                return Err(format!("sky kind = '{spelled}' takes no prepare"));
616            }
617            if sky.balance.is_some() {
618                return Err(format!("sky kind = '{spelled}' takes no balance"));
619            }
620            room.check().map_err(|e| format!("sky.room: {e}"))?;
621        }
622        (_, Some(_)) => return Err("[sky.room] needs kind = 'room'".into()),
623        _ => {}
624    }
625    if !sky.rotation_deg.is_finite()
626        || !sky.intensity.is_finite()
627        || sky.intensity < 0.0
628        || sky
629            .balance
630            .is_some_and(|balance| balance.into_iter().any(|v| !v.is_finite() || v <= 0.0))
631    {
632        return Err("sky preparation values must be finite and nonnegative".into());
633    }
634    if kind == SkyKind::Daylight
635        && (sky.balance.is_some() || sky.rotation_deg != 0.0 || sky.intensity != 1.0)
636    {
637        return Err("sky preparation needs an HDR sky".into());
638    }
639    let prepare = match &sky.prepare {
640        None => None,
641        Some(toml::Value::String(name)) => {
642            return Err(format!(
643                "sky.prepare is a [sky.prepare] table of cap, balance and mean, not '{name}'"
644            ));
645        }
646        Some(value) => {
647            let steps: Prepare = value
648                .clone()
649                .try_into()
650                .map_err(|e| format!("sky.prepare: {e}"))?;
651            if kind != SkyKind::Hdr {
652                return Err("[sky.prepare] needs kind = 'hdr'".into());
653            }
654            if sky.balance.is_some() {
655                return Err("[sky.prepare] takes balance in its own table".into());
656            }
657            if steps == Prepare::default() {
658                return Err("[sky.prepare] needs cap, balance or mean".into());
659            }
660            let positive = |v: f32| v.is_finite() && v > 0.0;
661            if steps.cap.is_some_and(|v| !positive(v))
662                || steps.mean.is_some_and(|v| !positive(v))
663                || steps
664                    .balance
665                    .is_some_and(|balance| !balance.into_iter().all(positive))
666            {
667                return Err("[sky.prepare] values must be finite and positive".into());
668            }
669            Some(steps)
670        }
671    };
672    if prepare.is_none() && sky.balance.is_some() {
673        return Err("sky balance belongs in a [sky.prepare] table".into());
674    }
675    let ambient = match sky.ambient.as_deref() {
676        None => None,
677        Some("authored") => {
678            if kind == SkyKind::Daylight || !matches!(sun, SunModel::Authored(_)) {
679                return Err(
680                    "sky.ambient = 'authored' needs an HDR or room sky and [sun] model = 'authored'"
681                        .into(),
682                );
683            }
684            Some(Ambient::Authored)
685        }
686        Some("clock") => {
687            if kind != SkyKind::Hdr || !matches!(sun, SunModel::Clock { .. }) {
688                return Err(
689                    "sky.ambient = 'clock' needs an HDR sky and [sun] model = 'clock'".into(),
690                );
691            }
692            Some(Ambient::Clock)
693        }
694        Some(other) => {
695            return Err(format!(
696                "sky.ambient is 'authored' or 'clock', not '{other}'"
697            ));
698        }
699    };
700    if ambient == Some(Ambient::Clock) {
701        if !prepare.is_some_and(|p| p.mean.is_some() && p.cap.is_none() && p.balance.is_none()) {
702            return Err("sky.ambient = 'clock' needs [sky.prepare] with mean alone".into());
703        }
704        if sky.rotation_deg != 0.0 || sky.intensity != 1.0 {
705            return Err(
706                "sky.ambient = 'clock' turns the sky by the clock, so it takes no rotation or intensity"
707                    .into(),
708            );
709        }
710    }
711    Ok(SkyRecipe {
712        kind,
713        path: sky.path,
714        prepare,
715        rotation_deg: sky.rotation_deg,
716        intensity: sky.intensity,
717        shade: sky.shade,
718        room: sky.room,
719        ambient,
720    })
721}
722
723pub struct SceneSource {
724    pub recipe: Recipe,
725    pub scene: BakeScene,
726    pub root: PathBuf,
727    pub emitters: Vec<usize>,
728    source_digest: [u8; 32],
729    prepared_sky: Option<Sky>,
730}
731
732pub(crate) fn read_inside(root: &Path, name: &str) -> Result<Vec<u8>, String> {
733    let path = Path::new(name);
734    if path.components().next().is_none()
735        || path
736            .components()
737            .any(|part| !matches!(part, Component::Normal(_)))
738    {
739        return Err(format!("{name} is outside the scene folder"));
740    }
741    let file = root
742        .join(path)
743        .canonicalize()
744        .map_err(|e| format!("{name}: {e}"))?;
745    if !file.starts_with(root) || !file.is_file() {
746        return Err(format!("{name} is outside the scene folder"));
747    }
748    fs::read(file).map_err(|e| format!("{name}: {e}"))
749}
750
751impl Recipe {
752    pub fn parse(bytes: &[u8]) -> Result<Self, String> {
753        let text = std::str::from_utf8(bytes).map_err(|e| format!("bake.toml: {e}"))?;
754        let raw: Raw = toml::from_str(text).map_err(|e| format!("bake.toml: {e}"))?;
755        let product = match (&raw.app, &raw.product) {
756            (Some(_), Some(_)) => {
757                return Err("app and product name the same thing; keep app".into());
758            }
759            (Some(app), None) | (None, Some(app)) => app.clone(),
760            (None, None) => return Err("bake.toml needs app = \"<slug>\"".into()),
761        };
762        if !slug(&product) {
763            return Err(
764                "app (or product) must be a slug of lowercase letters, digits and hyphens".into(),
765            );
766        }
767        let materials = material_source(raw.materials.as_deref())?;
768        let library = matches!(materials, Some(Materials::Library(_)));
769        if let Some(fallback) = &raw.fallback {
770            if !library {
771                return Err("fallback needs materials = \"<library.toml>\"".into());
772            }
773            if fallback.is_empty() {
774                return Err("fallback needs a material name".into());
775            }
776        }
777        if !raw.tint.is_empty() {
778            if !library {
779                return Err("[tint] needs materials = \"<library.toml>\"".into());
780            }
781            if raw
782                .tint
783                .iter()
784                .any(|(name, rgb)| name.is_empty() || !rgb_valid(rgb))
785            {
786                return Err("tint names materials with finite, nonnegative colours".into());
787            }
788        }
789        if !(0.0..=24.0).contains(&raw.reference_hour) {
790            return Err(format!(
791                "reference_hour = {} is outside 0 to 24",
792                raw.reference_hour
793            ));
794        }
795        let sun = sun_model(&raw)?;
796        let sky = sky_recipe(raw.sky, &sun)?;
797        let recipe = Self {
798            scene: raw.scene,
799            product,
800            materials,
801            fallback: raw.fallback,
802            tint: raw.tint,
803            anchors: raw.anchors,
804            samples: raw.samples,
805            seed: raw.seed,
806            day: raw.day,
807            latitude: raw.latitude,
808            heading: raw.heading,
809            reference_hour: raw.reference_hour,
810            sky,
811            sun,
812            detail: raw.detail,
813            shadow_only_nodes: raw.shadow_only_nodes,
814            volumes: raw.volumes,
815            reflections: raw.reflections,
816            assets_env: raw.assets_env,
817            files: raw.files,
818            poses: raw.poses,
819            shadow_only_materials: raw.shadow_only_materials,
820            drop_degenerate: raw.drop_degenerate,
821            emitters: raw.emitters,
822            emitter_layer: raw.emitter_layer,
823        };
824        recipe.validate()?;
825        Ok(recipe)
826    }
827
828    fn validate(&self) -> Result<(), String> {
829        if !self.scene.ends_with(".gltf") && !self.scene.ends_with(".glb") {
830            return Err("scene must name a .gltf or .glb file".into());
831        }
832        if self.samples == 0 {
833            return Err("samples must be positive".into());
834        }
835        if self.anchors.is_empty()
836            || self
837                .anchors
838                .iter()
839                .any(|v| !v.is_finite() || !(0.0..=24.0).contains(v))
840            || self.anchors.windows(2).any(|v| v[0] >= v[1])
841        {
842            return Err("anchors must be sorted, distinct hours from 0 to 24".into());
843        }
844        Daylight::parse([
845            (
846                "hour",
847                pfx_core::daylight::Reading::Number(self.anchors[0] as f64),
848            ),
849            ("day", pfx_core::daylight::Reading::Number(self.day)),
850            (
851                "latitude",
852                pfx_core::daylight::Reading::Number(self.latitude),
853            ),
854            ("heading", pfx_core::daylight::Reading::Number(self.heading)),
855        ])?;
856        if self.volumes.is_empty() && self.reflections.is_empty() && self.detail.is_none() {
857            return Err("at least one probe volume or reflection is required".into());
858        }
859        if let Some(detail) = &self.detail {
860            detail.check()?;
861        }
862        if self.shadow_only_nodes.iter().any(|name| name.is_empty()) {
863            return Err("shadow-only nodes need names".into());
864        }
865        let mut names = BTreeSet::new();
866        for volume in &self.volumes {
867            if volume.name.is_empty()
868                || !volume
869                    .name
870                    .chars()
871                    .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
872                || !names.insert(volume.name.clone())
873            {
874                return Err("volume names must be unique ASCII labels".into());
875            }
876            volume.grid.dimensions()?;
877        }
878        for (index, reflection) in self.reflections.iter().enumerate() {
879            reflection.validate()?;
880            let name = reflection.name(index);
881            if !names.insert(name) {
882                return Err("reflection names must be unique from volume names".into());
883            }
884        }
885        self.validate_composition()
886    }
887
888    fn validate_composition(&self) -> Result<(), String> {
889        let library = matches!(self.materials, Some(Materials::Library(_)));
890        for (key, shade) in &self.sky.shade {
891            let hour: f32 = key
892                .parse()
893                .map_err(|_| format!("sky.shade key {key} is not an hour"))?;
894            if !self.anchors.contains(&hour) {
895                return Err(format!("sky.shade key {key} is not one of the anchors"));
896            }
897            if !rgb_valid(shade) {
898                return Err("sky.shade values must be finite and nonnegative".into());
899            }
900        }
901        let gltf = |name: &str| name.ends_with(".gltf") || name.ends_with(".glb");
902        for file in &self.files {
903            if !gltf(&file.path) {
904                return Err("file must name a .gltf or .glb file".into());
905            }
906            if file
907                .translation
908                .is_some_and(|v| v.iter().any(|c| !c.is_finite()))
909            {
910                return Err("file translation must be finite".into());
911            }
912            if file.keep_prefix.as_deref().is_some_and(str::is_empty)
913                || (file.keep_prefix.is_some() && file.translation.is_none())
914            {
915                return Err("file keep_prefix needs a name and a translation".into());
916            }
917        }
918        if self
919            .shadow_only_materials
920            .iter()
921            .any(|name| name.is_empty())
922        {
923            return Err("shadow-only materials need names".into());
924        }
925        for pose in &self.poses {
926            if pose.node.is_empty() || pose.file.as_deref().is_some_and(|name| !gltf(name)) {
927                return Err("pose needs a node and a .gltf or .glb file".into());
928            }
929            if pose
930                .translation
931                .iter()
932                .chain(&pose.scale)
933                .chain([&pose.yaw])
934                .any(|v| !v.is_finite())
935                || pose.scale.contains(&0.0)
936            {
937                return Err("pose values must be finite and its scale nonzero".into());
938            }
939            if !pose.materials.is_empty() && !library {
940                return Err("pose materials need materials = \"<library.toml>\"".into());
941            }
942            if pose
943                .materials
944                .iter()
945                .any(|(name, spec)| name.is_empty() || spec.is_empty())
946            {
947                return Err("pose materials map glTF material names to library names".into());
948            }
949        }
950        for emitter in &self.emitters {
951            let placed = match (&emitter.node, emitter.position, emitter.radius) {
952                (Some(node), None, None) => !node.is_empty(),
953                (None, Some(position), Some(radius)) => {
954                    position.iter().all(|v| v.is_finite()) && radius.is_finite() && radius > 0.0
955                }
956                _ => false,
957            };
958            if !placed {
959                return Err("emitter needs a node, or a position and a positive radius".into());
960            }
961            if !rgb_valid(&emitter.color)
962                || !emitter.intensity.is_finite()
963                || emitter.intensity < 0.0
964            {
965                return Err("emitter color and intensity must be finite and nonnegative".into());
966            }
967            if let Some(scales) = &emitter.scales
968                && (scales.len() != self.anchors.len()
969                    || scales.iter().any(|v| !v.is_finite() || *v < 0.0))
970            {
971                return Err("emitter scales need one nonnegative value per anchor".into());
972            }
973            if !library && emitter.preset.is_some() {
974                return Err("emitter preset names a material in the recipe's library".into());
975            }
976        }
977        if self.emitter_layer.is_some() {
978            if self.emitters.is_empty() {
979                return Err("emitter_layer needs at least one emitter".into());
980            }
981            self.layer_scales()?;
982        }
983        Ok(())
984    }
985
986    fn check_library(&self, path: &str, library: &Library) -> Result<(), String> {
987        let missing = |name: &str| !library.contains(name);
988        if let Some(name) = self.fallback.as_deref().filter(|name| missing(name)) {
989            return Err(format!("fallback {name} is not in {path}"));
990        }
991        if let Some(name) = self.tint.keys().find(|name| missing(name)) {
992            return Err(format!("tint names {name}, which {path} does not hold"));
993        }
994        if let Some(spec) = self
995            .poses
996            .iter()
997            .flat_map(|pose| pose.materials.values())
998            .find(|spec| missing(spec))
999        {
1000            return Err(format!("pose material {spec} is not in {path}"));
1001        }
1002        if let Some(name) = self
1003            .emitters
1004            .iter()
1005            .filter_map(|emitter| emitter.preset.as_deref())
1006            .find(|name| missing(name))
1007        {
1008            return Err(format!("emitter preset {name} is not in {path}"));
1009        }
1010        Ok(())
1011    }
1012
1013    pub(crate) fn emitter_base(&self, name: Option<&str>, palette: Option<&Palette>) -> Material {
1014        match (palette, name) {
1015            (Some(palette), Some(name)) => palette.listed(name).unwrap_or_default(),
1016            (Some(palette), None) => palette.fallback(),
1017            (None, _) => Material::default(),
1018        }
1019    }
1020
1021    pub fn emitter_scales(&self) -> Vec<Vec<f32>> {
1022        self.emitters
1023            .iter()
1024            .map(|emitter| {
1025                emitter
1026                    .scales
1027                    .clone()
1028                    .unwrap_or_else(|| vec![1.0; self.anchors.len()])
1029            })
1030            .collect()
1031    }
1032
1033    pub fn layer_scales(&self) -> Result<Vec<f32>, String> {
1034        let scales = self.emitter_scales();
1035        let first = scales.first().ok_or("recipe has no emitters")?;
1036        if scales.iter().any(|other| other != first) {
1037            return Err("emitter_layer needs every emitter to share its scales".into());
1038        }
1039        Ok(first.clone())
1040    }
1041
1042    pub fn composed(&self) -> bool {
1043        !self.files.is_empty()
1044            || !self.poses.is_empty()
1045            || !self.emitters.is_empty()
1046            || !self.shadow_only_materials.is_empty()
1047            || self.drop_degenerate
1048    }
1049}
1050
1051fn triangles(mesh: &Mesh, shadow_only_nodes: &[String]) -> Result<Vec<Triangle>, String> {
1052    let mut result = Vec::new();
1053    let mut stack: Vec<u32> = mesh.roots.iter().rev().copied().collect();
1054    let mut seen = BTreeSet::new();
1055    while let Some(index) = stack.pop() {
1056        if !seen.insert(index) {
1057            return Err("mesh scene has repeated nodes".into());
1058        }
1059        let node = mesh
1060            .nodes
1061            .get(index as usize)
1062            .ok_or("mesh scene has a missing node")?;
1063        stack.extend(node.children.iter().rev().copied());
1064        if shadow_only_nodes.contains(&node.name) {
1065            continue;
1066        }
1067        let Some(group) = node.group else { continue };
1068        let matrix = mesh.world(index).ok_or("mesh has invalid node hierarchy")?;
1069        for primitive in &mesh.groups[group as usize].primitives {
1070            for indices in primitive.indices.chunks_exact(3) {
1071                let vertices = [indices[0], indices[1], indices[2]].map(|id| {
1072                    let point = primitive.positions[id as usize];
1073                    [0, 1, 2].map(|axis| {
1074                        matrix[3][axis] + (0..3).map(|k| matrix[k][axis] * point[k]).sum::<f32>()
1075                    })
1076                });
1077                result.push(Triangle {
1078                    vertices,
1079                    material: primitive.material.unwrap_or(mesh.materials.len() as u32),
1080                });
1081            }
1082        }
1083    }
1084    if result.is_empty() {
1085        return Err("scene has no triangles".into());
1086    }
1087    Ok(result)
1088}
1089
1090pub(crate) fn materials(bytes: &[u8], palette: Option<&Palette>) -> Result<Vec<Material>, String> {
1091    let gltf = gltf::Gltf::from_slice(bytes).map_err(|e| format!("materials: {e}"))?;
1092    let mut result = Vec::new();
1093    for item in gltf.materials() {
1094        if let Some(palette) = palette {
1095            result.push(palette.named(item.name().unwrap_or("")));
1096            continue;
1097        }
1098        let pbr = item.pbr_metallic_roughness();
1099        let base = pbr.base_color_factor();
1100        result.push(Material {
1101            base: [base[0], base[1], base[2]],
1102            roughness: pbr.roughness_factor(),
1103            metalness: pbr.metallic_factor(),
1104            emission: item.emissive_factor(),
1105            ..Material::default()
1106        });
1107    }
1108    result.push(palette.map_or_else(Material::default, Palette::fallback));
1109    Ok(result)
1110}
1111
1112fn sun_at(model: &SunModel, daylight: &Daylight, hour: f32, reference_hour: f64) -> Sun {
1113    match model {
1114        SunModel::Solar => Sun::from_daylight(*daylight),
1115        SunModel::Fixed(authored) => {
1116            let norm = authored.toward.iter().map(|v| v * v).sum::<f64>().sqrt();
1117            Sun {
1118                direction: authored.toward.map(|v| (v / norm) as f32),
1119                color: authored.color.map(|v| v as f32),
1120                intensity: authored.irradiance * daylight.light(reference_hour).intensity as f32,
1121            }
1122        }
1123        SunModel::Authored(authored) => {
1124            let light = daylight.authored(
1125                &Authored {
1126                    toward: authored.toward,
1127                    colour: authored.color,
1128                    sky_fill: authored.sky_fill,
1129                },
1130                reference_hour,
1131            );
1132            let toward = light.toward.map(|v| v as f32);
1133            let length = toward.iter().map(|v| v * v).sum::<f32>().sqrt();
1134            Sun {
1135                direction: toward.map(|v| v / length),
1136                color: light.colour.map(|v| v as f32),
1137                intensity: authored.irradiance * light.intensity as f32,
1138            }
1139        }
1140        SunModel::Clock { clock, base } => {
1141            let damped = clock.at(f64::from(hour), *base);
1142            Sun {
1143                direction: compose::unit(damped.toward.map(|v| v as f32)),
1144                color: damped.colour.map(|v| v as f32),
1145                intensity: 1.0,
1146            }
1147        }
1148    }
1149}
1150
1151fn anchors(recipe: &Recipe, sky: Option<&Sky>) -> Vec<Anchor> {
1152    recipe
1153        .anchors
1154        .iter()
1155        .map(|&hour| {
1156            let daylight = Daylight {
1157                hour: hour as f64,
1158                day: recipe.day,
1159                latitude: recipe.latitude,
1160                heading: recipe.heading,
1161            };
1162            let sun = sun_at(&recipe.sun, &daylight, hour, recipe.reference_hour);
1163            let sky = sky.cloned().unwrap_or_else(|| {
1164                let fill = daylight.light(recipe.reference_hour).sky as f32;
1165                Sky {
1166                    width: 4,
1167                    height: 2,
1168                    texels: vec![[fill * 0.5, fill * 0.7, fill, 1.0]; 8],
1169                }
1170            });
1171            let mut shade = recipe
1172                .sky
1173                .shade
1174                .iter()
1175                .find(|(key, _)| key.parse::<f32>() == Ok(hour))
1176                .map(|(_, shade)| *shade);
1177            let sky = match (recipe.sky.ambient, &recipe.sun, recipe.sky.prepare) {
1178                (Some(Ambient::Authored), SunModel::Authored(authored), _) => {
1179                    let light = daylight.authored(
1180                        &Authored {
1181                            toward: authored.toward,
1182                            colour: authored.color,
1183                            sky_fill: authored.sky_fill,
1184                        },
1185                        recipe.reference_hour,
1186                    );
1187                    sky.exposed(light.ambient as f32)
1188                }
1189                (
1190                    Some(Ambient::Clock),
1191                    SunModel::Clock { clock, base },
1192                    Some(Prepare {
1193                        mean: Some(mean), ..
1194                    }),
1195                ) => {
1196                    let damped = clock.at(f64::from(hour), *base);
1197                    shade = shade.or(Some(damped.shade.map(|v| v as f32)));
1198                    let current = sky.mean_luminance().max(1e-3);
1199                    sky.exposed(mean / current)
1200                        .rotated(-(damped.turn as f32) * TAU)
1201                }
1202                _ => sky,
1203            };
1204            let mut sky = sky;
1205            if let Some(shade) = shade {
1206                for texel in &mut sky.texels {
1207                    for (value, tint) in texel.iter_mut().zip(shade) {
1208                        *value *= tint;
1209                    }
1210                }
1211            }
1212            Anchor { hour, sky, sun }
1213        })
1214        .collect()
1215}
1216
1217fn read_library(folder: &Path, name: &str) -> Result<(Vec<u8>, Library), String> {
1218    let file = folder
1219        .join(name)
1220        .canonicalize()
1221        .map_err(|e| format!("materials {name}: {e}"))?;
1222    let bytes = fs::read(&file).map_err(|e| format!("materials {name}: {e}"))?;
1223    let text = std::str::from_utf8(&bytes).map_err(|e| format!("materials {name}: {e}"))?;
1224    let library = Library::from_toml(text).map_err(|e| format!("materials {name}: {e}"))?;
1225    Ok((bytes, library))
1226}
1227
1228impl SceneSource {
1229    pub fn with_hours(&self, hours: &[f32]) -> Result<Vec<Anchor>, String> {
1230        if hours.is_empty()
1231            || hours
1232                .iter()
1233                .any(|v| !v.is_finite() || !(0.0..=24.0).contains(v))
1234            || hours.windows(2).any(|v| v[0] >= v[1])
1235        {
1236            return Err("anchors must be sorted, distinct hours from 0 to 24".into());
1237        }
1238        if self.recipe.emitters.iter().any(|emitter| {
1239            emitter
1240                .scales
1241                .as_ref()
1242                .is_some_and(|s| s.len() != hours.len())
1243        }) {
1244            return Err(
1245                "emitter scales need one value per anchor, so --anchors cannot change their count"
1246                    .into(),
1247            );
1248        }
1249        let mut recipe = self.recipe.clone();
1250        recipe.anchors = hours.to_vec();
1251        Ok(anchors(&recipe, self.prepared_sky.as_ref()))
1252    }
1253
1254    pub fn load(folder: &Path) -> Result<Self, String> {
1255        Self::load_with(folder, None)
1256    }
1257
1258    pub fn load_with(folder: &Path, assets: Option<&Path>) -> Result<Self, String> {
1259        let folder = folder
1260            .canonicalize()
1261            .map_err(|e| format!("scene folder: {e}"))?;
1262        let recipe_bytes = read_inside(&folder, "bake.toml")?;
1263        let recipe = Recipe::parse(&recipe_bytes)?;
1264        let library = match &recipe.materials {
1265            Some(Materials::Library(name)) => {
1266                let (bytes, library) = read_library(&folder, name)?;
1267                recipe.check_library(name, &library)?;
1268                Some((name.clone(), bytes, library))
1269            }
1270            _ => None,
1271        };
1272        let root = match assets {
1273            Some(path) => path
1274                .canonicalize()
1275                .map_err(|e| format!("assets folder: {e}"))?,
1276            None => folder,
1277        };
1278        let mut files = Files::new(&root);
1279        files.load(&recipe.scene)?;
1280        if recipe.composed() {
1281            for file in &recipe.files {
1282                files.load(&file.path)?;
1283            }
1284            for pose in &recipe.poses {
1285                files.load(pose.file.as_deref().unwrap_or(&recipe.scene))?;
1286            }
1287        }
1288        let sky_bytes = recipe
1289            .sky
1290            .path
1291            .as_deref()
1292            .map(|name| read_inside(&root, name))
1293            .transpose()?;
1294        let sky = sky_bytes
1295            .as_deref()
1296            .map(Sky::parse)
1297            .transpose()?
1298            .or_else(|| recipe.sky.room.as_ref().map(Sky::room))
1299            .map(|sky| {
1300                if recipe.sky.ambient == Some(Ambient::Clock) {
1301                    return sky;
1302                }
1303                let sky = match &recipe.sky.prepare {
1304                    Some(prepare) => prepare.apply(sky),
1305                    None => sky,
1306                };
1307                sky.rotated(-recipe.sky.rotation_deg.to_radians())
1308                    .exposed(recipe.sky.intensity)
1309            });
1310        let mut hasher = Sha256::new();
1311        let scene_bytes = &files.sources[&recipe.scene];
1312        for (name, bytes) in std::iter::once(("bake.toml", &recipe_bytes))
1313            .chain(std::iter::once((recipe.scene.as_str(), scene_bytes)))
1314            .chain(
1315                files
1316                    .external
1317                    .iter()
1318                    .map(|(name, bytes)| (name.as_str(), bytes)),
1319            )
1320            .chain(
1321                files
1322                    .sources
1323                    .iter()
1324                    .filter(|(name, _)| **name != recipe.scene)
1325                    .map(|(name, bytes)| (name.as_str(), bytes)),
1326            )
1327        {
1328            hasher.update((name.len() as u64).to_le_bytes());
1329            hasher.update(name.as_bytes());
1330            hasher.update((bytes.len() as u64).to_le_bytes());
1331            hasher.update(bytes);
1332        }
1333        if let (Some(path), Some(bytes)) = (recipe.sky.path.as_deref(), sky_bytes.as_ref()) {
1334            hasher.update((path.len() as u64).to_le_bytes());
1335            hasher.update(path.as_bytes());
1336            hasher.update((bytes.len() as u64).to_le_bytes());
1337            hasher.update(bytes);
1338        }
1339        if let Some(room) = &recipe.sky.room {
1340            let bytes = serde_json::to_vec(room).map_err(|e| e.to_string())?;
1341            hasher.update(b"sky.room");
1342            hasher.update((bytes.len() as u64).to_le_bytes());
1343            hasher.update(&bytes);
1344        }
1345        if let Some((name, bytes, _)) = &library {
1346            hasher.update(b"materials");
1347            hasher.update((name.len() as u64).to_le_bytes());
1348            hasher.update(name.as_bytes());
1349            hasher.update((bytes.len() as u64).to_le_bytes());
1350            hasher.update(bytes);
1351        }
1352        hasher.update(env!("CARGO_PKG_VERSION").as_bytes());
1353        let source_digest = hasher.finalize().into();
1354        let palette = match (&recipe.materials, &library) {
1355            (Some(Materials::Library(_)), Some((_, _, library))) => Some(Palette {
1356                library,
1357                fallback: recipe.fallback.as_deref(),
1358                tint: &recipe.tint,
1359            }),
1360            _ => None,
1361        };
1362        let (triangles, materials, emitters) = if recipe.composed() {
1363            let composed = compose::compose(&recipe, &files, palette.as_ref())?;
1364            (composed.triangles, composed.materials, composed.emitters)
1365        } else {
1366            (
1367                triangles(files.mesh(&recipe.scene)?, &recipe.shadow_only_nodes)?,
1368                materials(scene_bytes, palette.as_ref())?,
1369                Vec::new(),
1370            )
1371        };
1372        let scene = BakeScene {
1373            triangles,
1374            shapes: Vec::new(),
1375            materials,
1376            anchors: anchors(&recipe, sky.as_ref()),
1377        };
1378        Ok(Self {
1379            recipe,
1380            scene,
1381            root,
1382            emitters,
1383            source_digest,
1384            prepared_sky: sky,
1385        })
1386    }
1387
1388    pub fn anchor_scene(&self, index: usize) -> Result<Option<BakeScene>, String> {
1389        if self.emitters.is_empty() {
1390            return Ok(None);
1391        }
1392        let mut materials = self.scene.materials.clone();
1393        for (slot, scales) in self.emitters.iter().zip(self.recipe.emitter_scales()) {
1394            let scale = *scales.get(index).ok_or("an anchor has no emitter scale")?;
1395            if !scale.is_finite() || scale < 0.0 {
1396                return Err("emission scale must be finite and nonnegative".into());
1397            }
1398            materials[*slot].emission = materials[*slot].emission.map(|value| value * scale);
1399        }
1400        Ok(Some(BakeScene {
1401            triangles: self.scene.triangles.clone(),
1402            shapes: self.scene.shapes.clone(),
1403            materials,
1404            anchors: self.scene.anchors.clone(),
1405        }))
1406    }
1407
1408    pub fn layer(&self) -> Result<Option<(Vec<f32>, bool)>, String> {
1409        match self.recipe.emitter_layer {
1410            Some(layer) => Ok(Some((self.recipe.layer_scales()?, layer.direct))),
1411            None => Ok(None),
1412        }
1413    }
1414
1415    pub fn layer_hash(&self, spec: GridSpec, samples: u32) -> Result<String, String> {
1416        let (scales, direct) = self.layer()?.ok_or("recipe has no emitter layer")?;
1417        emitter_hash(
1418            &self.scene,
1419            spec,
1420            samples,
1421            self.recipe.seed,
1422            &scales,
1423            direct,
1424        )
1425    }
1426
1427    pub fn hash(&self, spec: GridSpec, samples: u32) -> Result<String, String> {
1428        let mut h = Sha256::new();
1429        h.update(self.source_digest);
1430        h.update(scene_hash(&self.scene, spec, samples, self.recipe.seed)?.as_bytes());
1431        for scales in self.recipe.emitter_scales() {
1432            for scale in scales {
1433                h.update(scale.to_bits().to_le_bytes());
1434            }
1435        }
1436        Ok(hex::encode(h.finalize()))
1437    }
1438}
1439
1440#[cfg(test)]
1441mod keys;
1442
1443#[cfg(test)]
1444mod tests {
1445    use super::*;
1446    use crate::emitter_hash;
1447    use pfx_materials::{Family, Library};
1448    use std::path::PathBuf;
1449
1450    const ROOM: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/box-room");
1451
1452    #[test]
1453    fn recipe_validates_hours_volumes_and_sky() {
1454        let bytes = fs::read(Path::new(ROOM).join("bake.toml")).unwrap();
1455        assert_eq!(
1456            Recipe::parse(&bytes).unwrap().volumes[0]
1457                .grid
1458                .dimensions()
1459                .unwrap(),
1460            [1; 3]
1461        );
1462        let text = String::from_utf8(bytes).unwrap();
1463        assert!(
1464            Recipe::parse(text.replace("[12.0]", "[16.0, 8.0]").as_bytes())
1465                .unwrap_err()
1466                .contains("anchors")
1467        );
1468        assert!(
1469            Recipe::parse(text.replace("spacing = 1.0", "spacing = 0.0").as_bytes())
1470                .unwrap_err()
1471                .contains("spacing")
1472        );
1473        assert!(
1474            Recipe::parse(text.replace("\"test\"", "\"Box Room\"").as_bytes())
1475                .unwrap_err()
1476                .contains("slug")
1477        );
1478        assert!(
1479            Recipe::parse(text.replace("samples = 1", "samples = 0").as_bytes())
1480                .unwrap_err()
1481                .contains("samples")
1482        );
1483    }
1484
1485    #[test]
1486    fn prepared_sky_and_authored_sun_follow_recipe() {
1487        let original = fs::read_to_string(Path::new(ROOM).join("bake.toml")).unwrap();
1488        let recipe = original.replace("app = \"test\"", "app = \"test\"\nreference_hour = 14.0")
1489            + "\n[sky]\nkind = 'hdr'\npath = 'sky.hdr'\nrotation_deg = 169.4\nintensity = 4.2\nambient = 'authored'\n\n[sky.prepare]\ncap = 40.0\nbalance = [1.0, 0.92, 0.8]\nmean = 0.25\n\n[sun]\nmodel = 'authored'\ntoward = [0.45, 0.62, 0.64]\ncolor = [1.0, 0.9, 0.7]\nirradiance = 4.5\nsky_fill = 0.8\n";
1490        let parsed = Recipe::parse(recipe.as_bytes()).unwrap();
1491        assert_eq!(parsed.materials, None);
1492        assert_eq!(
1493            parsed.sky.prepare,
1494            Some(Prepare {
1495                cap: Some(40.0),
1496                balance: Some([1.0, 0.92, 0.8]),
1497                mean: Some(0.25),
1498            })
1499        );
1500        assert_eq!(parsed.sky.ambient, Some(Ambient::Authored));
1501        assert!(matches!(parsed.sun, SunModel::Authored(sun) if sun.irradiance == 4.5));
1502        assert!(
1503            Recipe::parse(
1504                recipe
1505                    .replace("intensity = 4.2", "intensity = -1")
1506                    .as_bytes()
1507            )
1508            .is_err()
1509        );
1510        assert!(Recipe::parse(recipe.replace("cap = 40.0", "cap = 0.0").as_bytes()).is_err());
1511        let scratch = Path::new(env!("CARGO_MANIFEST_DIR"))
1512            .join("../../tmp")
1513            .join(format!("bake-sky-test-{}", std::process::id()));
1514        fs::create_dir_all(&scratch).unwrap();
1515        fs::write(scratch.join("bake.toml"), recipe).unwrap();
1516        fs::copy(Path::new(ROOM).join("room.gltf"), scratch.join("room.gltf")).unwrap();
1517        let sky_bytes = pfx_load::fixture::SKY_HDR;
1518        fs::write(scratch.join("sky.hdr"), sky_bytes).unwrap();
1519        let source = SceneSource::load(&scratch).unwrap();
1520        let prepared = Sky::parse(sky_bytes)
1521            .unwrap()
1522            .capped_luminance(40.0)
1523            .balanced_to([1.0, 0.92, 0.8])
1524            .scaled_to_mean_luminance(0.25)
1525            .rotated(-169.4_f32.to_radians())
1526            .exposed(4.2);
1527        let daylight = Daylight {
1528            hour: 12.0,
1529            day: Daylight::DAY,
1530            latitude: Daylight::LATITUDE,
1531            heading: Daylight::HEADING,
1532        };
1533        let authored = Authored {
1534            toward: [0.45, 0.62, 0.64],
1535            colour: [1.0, 0.9, 0.7],
1536            sky_fill: 0.8,
1537        };
1538        let light = daylight.authored(&authored, 14.0);
1539        assert_eq!(
1540            source.scene.anchors[0].sky,
1541            prepared.exposed(light.ambient as f32)
1542        );
1543        assert_eq!(
1544            source.scene.anchors[0].sun.intensity,
1545            4.5 * light.intensity as f32
1546        );
1547        assert_eq!(
1548            source.with_hours(&[12.0]).unwrap()[0].sky,
1549            source.scene.anchors[0].sky
1550        );
1551    }
1552
1553    #[test]
1554    fn scene_hash_is_stable_and_changes_with_inputs() {
1555        let source = SceneSource::load(Path::new(ROOM)).unwrap();
1556        let spec = source.recipe.volumes[0].grid;
1557        let first = source.hash(spec, 1).unwrap();
1558        assert_eq!(first, source.hash(spec, 1).unwrap());
1559        assert_ne!(first, source.hash(spec, 2).unwrap());
1560        let mut changed = SceneSource::load(Path::new(ROOM)).unwrap();
1561        changed.scene.anchors = changed.with_hours(&[8.0, 12.0]).unwrap();
1562        assert_ne!(first, changed.hash(spec, 1).unwrap());
1563        let scratch = Path::new(env!("CARGO_MANIFEST_DIR"))
1564            .join("../../tmp")
1565            .join(format!("bake-hash-test-{}", std::process::id()));
1566        fs::create_dir_all(&scratch).unwrap();
1567        fs::copy(Path::new(ROOM).join("bake.toml"), scratch.join("bake.toml")).unwrap();
1568        fs::copy(Path::new(ROOM).join("room.gltf"), scratch.join("room.gltf")).unwrap();
1569        assert_eq!(
1570            SceneSource::load(&scratch).unwrap().hash(spec, 1).unwrap(),
1571            first
1572        );
1573        let recipe = fs::read(scratch.join("bake.toml")).unwrap();
1574        let mut edited = recipe.clone();
1575        edited.push(b'\n');
1576        fs::write(scratch.join("bake.toml"), edited).unwrap();
1577        assert_ne!(
1578            SceneSource::load(&scratch).unwrap().hash(spec, 1).unwrap(),
1579            first
1580        );
1581        fs::write(scratch.join("bake.toml"), recipe).unwrap();
1582        let mut gltf = fs::read(scratch.join("room.gltf")).unwrap();
1583        gltf.push(b' ');
1584        fs::write(scratch.join("room.gltf"), gltf).unwrap();
1585        assert_ne!(
1586            SceneSource::load(&scratch).unwrap().hash(spec, 1).unwrap(),
1587            first
1588        );
1589    }
1590
1591    const ROOM_SKY: &str = "\n[sky]\nkind = 'room'\nrotation_deg = 30.0\nintensity = 1.5\n\n[sky.room]\nwidth = 64\nfloor = [0.22, 0.135, 0.068]\nwall = [0.19, 0.13, 0.07]\nceiling = [0.28, 0.23, 0.16]\n\n[[sky.room.lights]]\nname = 'window'\naz = -64.0\nel = 14.0\nwidth = 37.5\nheight = 29.0\npower = 10.5\ncolor = [1.0, 0.9, 0.7]\nsoft = 0.05\nslats = 6.0\nopen = 0.7\n\n[[sky.room.lights]]\naz = 62.0\nel = 22.0\npower = 1.7\n";
1592
1593    fn room_scene(name: &str, sky: &str) -> PathBuf {
1594        let scratch = Path::new(env!("CARGO_MANIFEST_DIR"))
1595            .join("../../tmp")
1596            .join(format!("bake-{name}-{}", std::process::id()));
1597        fs::create_dir_all(&scratch).unwrap();
1598        let recipe = fs::read_to_string(Path::new(ROOM).join("bake.toml")).unwrap() + sky;
1599        fs::write(scratch.join("bake.toml"), recipe).unwrap();
1600        fs::copy(Path::new(ROOM).join("room.gltf"), scratch.join("room.gltf")).unwrap();
1601        scratch
1602    }
1603
1604    #[test]
1605    fn a_room_sky_parses_and_is_refused_without_its_room_or_with_prepare() {
1606        let base = fs::read_to_string(Path::new(ROOM).join("bake.toml")).unwrap();
1607        let recipe = base.clone() + ROOM_SKY;
1608        let parsed = Recipe::parse(recipe.as_bytes()).unwrap();
1609        assert_eq!(parsed.sky.kind, SkyKind::Room);
1610        let room = parsed.sky.room.unwrap();
1611        assert_eq!(room.width, 64);
1612        assert_eq!(room.lights.len(), 2);
1613        assert_eq!(room.lights[0].slats, 6.0);
1614        assert_eq!(room.lights[1].width, 45.0);
1615        assert_eq!(room.lights[1].color, [1.0, 1.0, 1.0]);
1616        let bare = Recipe::parse((base.clone() + "\n[sky]\nkind = 'room'\n").as_bytes());
1617        assert!(bare.unwrap_err().contains("[sky.room]"));
1618        let prepared = recipe.replace(
1619            "kind = 'room'\n",
1620            "kind = 'room'\n\n[sky.prepare]\nmean = 1.0\n",
1621        );
1622        assert!(
1623            Recipe::parse(prepared.as_bytes())
1624                .unwrap_err()
1625                .contains("prepare")
1626        );
1627        let balanced = recipe.replace(
1628            "kind = 'room'\n",
1629            "kind = 'room'\nbalance = [1.0, 1.0, 1.0]\n",
1630        );
1631        assert!(Recipe::parse(balanced.as_bytes()).is_err());
1632        let hdr = recipe.replace("kind = 'room'\n", "kind = 'hdr'\npath = 'sky.hdr'\n");
1633        assert!(
1634            Recipe::parse(hdr.as_bytes())
1635                .unwrap_err()
1636                .contains("[sky.room]")
1637        );
1638        let pathed = recipe.replace("kind = 'room'\n", "kind = 'room'\npath = 'sky.hdr'\n");
1639        assert!(Recipe::parse(pathed.as_bytes()).is_err());
1640        let typo = recipe.replace("power = 1.7", "pwoer = 1.7");
1641        assert!(Recipe::parse(typo.as_bytes()).is_err());
1642        let narrow = recipe.replace("width = 64", "width = 1");
1643        assert!(
1644            Recipe::parse(narrow.as_bytes())
1645                .unwrap_err()
1646                .contains("sky.room")
1647        );
1648    }
1649
1650    #[test]
1651    fn a_room_reaches_every_anchors_sky() {
1652        let scratch = room_scene("room-sky", ROOM_SKY);
1653        let source = SceneSource::load(&scratch).unwrap();
1654        let room = source.recipe.sky.room.clone().unwrap();
1655        let bare = Sky::room(&room);
1656        let prepared = bare.clone().rotated(-30.0_f32.to_radians()).exposed(1.5);
1657        assert_eq!(source.scene.anchors.len(), 1);
1658        let sky = &source.scene.anchors[0].sky;
1659        assert_eq!(*sky, prepared);
1660        assert_eq!((sky.width, sky.height), (64, 32));
1661        let want = bare.mean_color();
1662        let got = sky.mean_color();
1663        for k in 0..3 {
1664            assert!(
1665                (got[k] - want[k] * 1.5).abs() <= 1e-5 * want[k].max(1.0),
1666                "{got:?} against {want:?}"
1667            );
1668        }
1669        assert!(want[0] > room.wall[0]);
1670        let others = source.with_hours(&[8.0, 16.0]).unwrap();
1671        assert!(others.iter().all(|anchor| anchor.sky == *sky));
1672        fs::remove_dir_all(scratch).unwrap();
1673    }
1674
1675    #[test]
1676    fn a_room_takes_the_authored_ambient_by_the_hour() {
1677        let sky = ROOM_SKY.replace(
1678            "intensity = 1.5\n",
1679            "intensity = 1.5\nambient = 'authored'\n",
1680        );
1681        let sun = "\n[sun]\nmodel = 'authored'\ntoward = [0.4, 0.6, 0.7]\ncolor = [1.0, 0.9, 0.7]\nirradiance = 2.0\nsky_fill = 0.5\n";
1682        let base = fs::read_to_string(Path::new(ROOM).join("bake.toml")).unwrap();
1683        let refused = |recipe: &str| Recipe::parse(recipe.as_bytes()).unwrap_err();
1684        assert!(refused(&(base.clone() + &sky)).contains("[sun] model = 'authored'"));
1685        assert!(
1686            refused(
1687                &(base.clone() + sun + &sky.replace("ambient = 'authored'", "ambient = 'sunny'"))
1688            )
1689            .contains("sky.ambient")
1690        );
1691        let hdr = "\n[sky]\nkind = 'hdr'\npath = 'sky.hdr'\nambient = 'sunny'\n";
1692        assert!(refused(&(base.clone() + sun + hdr)).contains("sky.ambient"));
1693        let scratch = room_scene("room-ambient", "");
1694        fs::write(scratch.join("bake.toml"), base + sun + &sky).unwrap();
1695        let source = SceneSource::load(&scratch).unwrap();
1696        let room = source.recipe.sky.room.clone().unwrap();
1697        let prepared = Sky::room(&room)
1698            .rotated(-30.0_f32.to_radians())
1699            .exposed(1.5);
1700        let authored = Authored {
1701            toward: [0.4, 0.6, 0.7],
1702            colour: [1.0, 0.9, 0.7],
1703            sky_fill: 0.5,
1704        };
1705        let hours = [6.0, 12.0, 16.0];
1706        let anchors = source.with_hours(&hours).unwrap();
1707        for (anchor, hour) in anchors.iter().zip(hours) {
1708            let light = Daylight {
1709                hour: f64::from(hour),
1710                day: Daylight::DAY,
1711                latitude: Daylight::LATITUDE,
1712                heading: Daylight::HEADING,
1713            }
1714            .authored(&authored, REFERENCE_HOUR);
1715            assert_eq!(anchor.sky, prepared.clone().exposed(light.ambient as f32));
1716        }
1717        assert_ne!(anchors[0].sky, anchors[1].sky);
1718        fs::remove_dir_all(scratch).unwrap();
1719    }
1720
1721    #[test]
1722    fn the_older_product_spellings_are_refused_with_their_new_keys() {
1723        let base = fs::read_to_string(Path::new(ROOM).join("bake.toml")).unwrap();
1724        let refused = |extra: &str| Recipe::parse((base.clone() + extra).as_bytes()).unwrap_err();
1725        assert!(refused("\n[sky]\nkind = 'old'\n").contains("'room'"));
1726        assert!(
1727            refused("\n[sky]\nkind = 'hdr'\npath = 'sky.hdr'\nprepare = 'old'\n")
1728                .contains("[sky.prepare]")
1729        );
1730        assert!(
1731            refused("\n[sky]\nkind = 'hdr'\npath = 'sky.hdr'\nambient = 'old'\n")
1732                .contains("'authored'")
1733        );
1734        assert!(
1735            refused("\n[sky]\nkind = 'hdr'\npath = 'sky.hdr'\nbalance = [1.0, 1.0, 1.0]\n")
1736                .contains("[sky.prepare]")
1737        );
1738        assert!(
1739            refused(
1740                "\n[sun]\ntoward = [0.0, 1.0, 0.0]\ncolor = [1.0, 1.0, 1.0]\nirradiance = 1.0\n"
1741            )
1742            .contains("model = 'authored'")
1743        );
1744        let product = base.replace("app = \"test\"", "product = \"test\"");
1745        let parsed = Recipe::parse(product.as_bytes()).unwrap();
1746        assert_eq!(parsed.sun, SunModel::Solar);
1747    }
1748
1749    #[test]
1750    fn a_rooms_lamp_power_changes_the_scene_hash() {
1751        let first = room_scene("room-hash-a", ROOM_SKY);
1752        let second = room_scene(
1753            "room-hash-b",
1754            &ROOM_SKY.replace("power = 1.7", "power = 1.8"),
1755        );
1756        let a = SceneSource::load(&first).unwrap();
1757        let b = SceneSource::load(&second).unwrap();
1758        let spec = a.recipe.volumes[0].grid;
1759        assert_eq!(
1760            a.hash(spec, 1).unwrap(),
1761            SceneSource::load(&first).unwrap().hash(spec, 1).unwrap()
1762        );
1763        assert_ne!(a.hash(spec, 1).unwrap(), b.hash(spec, 1).unwrap());
1764        assert_ne!(
1765            scene_hash(&a.scene, spec, 1, 73).unwrap(),
1766            scene_hash(&b.scene, spec, 1, 73).unwrap()
1767        );
1768        assert_ne!(a.source_digest, b.source_digest);
1769        fs::remove_dir_all(first).unwrap();
1770        fs::remove_dir_all(second).unwrap();
1771    }
1772
1773    #[test]
1774    fn shadow_only_nodes_are_excluded_from_bake_geometry() {
1775        let bytes = include_str!("../tests/data/box-room/room.gltf");
1776        let named = bytes.replace("\"mesh\":0", "\"name\":\"occluder\",\"mesh\":0");
1777        let mesh = Mesh::parse_with(
1778            named.as_bytes(),
1779            |_| Err("unexpected external asset".into()),
1780        )
1781        .unwrap();
1782        assert_eq!(triangles(&mesh, &[]).unwrap().len(), 10);
1783        assert!(
1784            triangles(&mesh, &["occluder".into()])
1785                .unwrap_err()
1786                .contains("no triangles")
1787        );
1788    }
1789
1790    fn encode(bytes: &[u8]) -> String {
1791        const SET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
1792        let mut out = String::new();
1793        for chunk in bytes.chunks(3) {
1794            let n = chunk
1795                .iter()
1796                .enumerate()
1797                .fold(0u32, |n, (i, b)| n | u32::from(*b) << (16 - 8 * i));
1798            for k in 0..4 {
1799                if k <= chunk.len() {
1800                    out.push(SET[(n >> (18 - 6 * k) & 63) as usize] as char);
1801                } else {
1802                    out.push('=');
1803                }
1804            }
1805        }
1806        out
1807    }
1808
1809    fn gltf(names: &[&str], material: &str, positions: &[[f32; 3]], indices: &[u16]) -> String {
1810        let mut data: Vec<u8> = positions
1811            .iter()
1812            .flatten()
1813            .flat_map(|v| v.to_le_bytes())
1814            .collect();
1815        let split = data.len();
1816        data.extend(indices.iter().flat_map(|v| v.to_le_bytes()));
1817        let low: Vec<String> = (0..3)
1818            .map(|k| {
1819                positions
1820                    .iter()
1821                    .map(|p| p[k])
1822                    .fold(f32::MAX, f32::min)
1823                    .to_string()
1824            })
1825            .collect();
1826        let high: Vec<String> = (0..3)
1827            .map(|k| {
1828                positions
1829                    .iter()
1830                    .map(|p| p[k])
1831                    .fold(f32::MIN, f32::max)
1832                    .to_string()
1833            })
1834            .collect();
1835        let nodes: Vec<String> = names
1836            .iter()
1837            .map(|name| format!("{{\"name\":\"{name}\",\"mesh\":0}}"))
1838            .collect();
1839        let roots: Vec<String> = (0..names.len()).map(|i| i.to_string()).collect();
1840        format!(
1841            "{{\"asset\":{{\"version\":\"2.0\"}},\"scene\":0,\"scenes\":[{{\"nodes\":[{}]}}],\"nodes\":[{}],\"materials\":[{{\"name\":\"{material}\"}}],\"meshes\":[{{\"primitives\":[{{\"attributes\":{{\"POSITION\":0}},\"indices\":1,\"material\":0}}]}}],\"buffers\":[{{\"uri\":\"data:application/octet-stream;base64,{}\",\"byteLength\":{}}}],\"bufferViews\":[{{\"buffer\":0,\"byteOffset\":0,\"byteLength\":{split}}},{{\"buffer\":0,\"byteOffset\":{split},\"byteLength\":{}}}],\"accessors\":[{{\"bufferView\":0,\"componentType\":5126,\"count\":{},\"type\":\"VEC3\",\"min\":[{}],\"max\":[{}]}},{{\"bufferView\":1,\"componentType\":5123,\"count\":{},\"type\":\"SCALAR\"}}]}}",
1842            roots.join(","),
1843            nodes.join(","),
1844            encode(&data),
1845            data.len(),
1846            data.len() - split,
1847            positions.len(),
1848            low.join(","),
1849            high.join(","),
1850            indices.len()
1851        )
1852    }
1853
1854    const QUAD: [[f32; 3]; 4] = [
1855        [0.0, 0.0, 0.0],
1856        [1.0, 0.0, 0.0],
1857        [1.0, 0.0, 1.0],
1858        [0.0, 0.0, 1.0],
1859    ];
1860    const QUAD_INDICES: [u16; 6] = [0, 1, 2, 0, 2, 3];
1861
1862    fn scratch(label: &str) -> PathBuf {
1863        let path = Path::new(env!("CARGO_MANIFEST_DIR"))
1864            .join("../../tmp")
1865            .join(format!("bake-{label}-{}", std::process::id()));
1866        let _ = fs::remove_dir_all(&path);
1867        fs::create_dir_all(&path).unwrap();
1868        fs::write(path.join("lib.toml"), pfx_materials::FIXTURE).unwrap();
1869        path
1870    }
1871
1872    fn sample(top: &str, tables: &str) -> String {
1873        format!(
1874            "scene = \"scene.gltf\"\napp = \"test\"\nmaterials = \"lib.toml\"\nfallback = \"card\"\nanchors = [9.0, 22.0]\nsamples = 1\nseed = 3\n{top}\n[[volumes]]\nname = \"v\"\nmin = [0.0, 0.0, 0.0]\nmax = [0.0, 0.0, 0.0]\nspacing = 1.0\n{tables}"
1875        )
1876    }
1877
1878    fn load(label: &str, recipe: &str, files: &[(&str, String)]) -> SceneSource {
1879        let folder = scratch(label);
1880        fs::write(folder.join("bake.toml"), recipe).unwrap();
1881        fs::write(
1882            folder.join("scene.gltf"),
1883            gltf(&["floor", "lamp"], "card", &QUAD, &QUAD_INDICES),
1884        )
1885        .unwrap();
1886        for (name, text) in files {
1887            fs::write(folder.join(name), text).unwrap();
1888        }
1889        SceneSource::load(&folder).unwrap()
1890    }
1891
1892    #[test]
1893    fn composition_keys_parse_and_malformed_ones_are_refused() {
1894        let full = sample(
1895            "assets_env = \"SAMPLE_ASSETS\"\ndrop_degenerate = true\nshadow_only_materials = [\"occluder\"]\nemitter_layer = { direct = false }\n",
1896            "\n[sky.shade]\n\"9.0\" = [0.5, 0.6, 0.7]\n22 = [0.1, 0.1, 0.2]\n\n[[file]]\npath = \"plant.glb\"\ntranslation = [0.1, 0.0, 0.035]\nkeep_prefix = \"floor_\"\n\n[[pose]]\nfile = \"rolls.glb\"\nnode = \"roll\"\nyaw = 1.0\nscale = [1.0, 2.0, 1.0]\ntranslation = [0.0, 0.0, 1.0]\nmaterials = { roll_cover = \"felt\", roll_band = \"metal\" }\n\n[[emitter]]\nposition = [0.0, 1.0, 0.0]\nradius = 0.05\ncolour = [1.0, 0.7, 0.4]\nintensity = 0.22\nscales = [0.0, 1.0]\npreset = \"lamp\"\n",
1897        );
1898        let recipe = Recipe::parse(full.as_bytes()).unwrap();
1899        assert_eq!(recipe.assets_env.as_deref(), Some("SAMPLE_ASSETS"));
1900        assert!(recipe.drop_degenerate && recipe.composed());
1901        assert_eq!(recipe.shadow_only_materials, ["occluder"]);
1902        assert_eq!(recipe.sky.shade["22"], [0.1, 0.1, 0.2]);
1903        assert_eq!(recipe.files[0].keep_prefix.as_deref(), Some("floor_"));
1904        assert_eq!(recipe.poses[0].scale, [1.0, 2.0, 1.0]);
1905        assert_eq!(recipe.poses[0].materials["roll_band"], "metal");
1906        assert_eq!(recipe.emitters[0].color, [1.0, 0.7, 0.4]);
1907        assert_eq!(recipe.emitter_layer, Some(EmitterLayer { direct: false }));
1908        assert_eq!(recipe.layer_scales().unwrap(), vec![0.0, 1.0]);
1909        let refused = |from: &str, to: &str, wants: &str| {
1910            let error = Recipe::parse(full.replace(from, to).as_bytes()).unwrap_err();
1911            assert!(error.contains(wants), "{from} -> {to}: {error}");
1912        };
1913        refused("\"9.0\" = [0.5", "\"nine\" = [0.5", "not an hour");
1914        refused("\"9.0\" = [0.5", "\"10.0\" = [0.5", "anchors");
1915        refused("= [0.5, 0.6, 0.7]", "= [0.5, -0.6, 0.7]", "sky.shade");
1916        refused("plant.glb", "plant.obj", "file must name");
1917        refused(
1918            "keep_prefix = \"floor_\"",
1919            "keep_prefix = \"\"",
1920            "keep_prefix",
1921        );
1922        refused(
1923            "translation = [0.1, 0.0, 0.035]\nkeep",
1924            "keep",
1925            "keep_prefix",
1926        );
1927        refused(
1928            "scale = [1.0, 2.0, 1.0]",
1929            "scale = [1.0, 0.0, 1.0]",
1930            "scale nonzero",
1931        );
1932        refused("yaw = 1.0", "yaw = inf", "pose values");
1933        refused(
1934            "roll_band = \"metal\"",
1935            "roll_band = \"\"",
1936            "pose materials",
1937        );
1938        refused(
1939            "materials = \"lib.toml\"\nfallback = \"card\"\n",
1940            "",
1941            "pose materials need",
1942        );
1943        refused("[pose]]\nfile", "[pose]]\nstock = 1\nfile", "stock");
1944        refused("node = \"roll\"", "node = \"\"", "pose needs");
1945        refused("radius = 0.05", "radius = 0.0", "emitter needs");
1946        refused(
1947            "radius = 0.05",
1948            "node = \"lamp\"\nradius = 0.05",
1949            "emitter needs",
1950        );
1951        refused("intensity = 0.22", "intensity = -0.22", "emitter color");
1952        refused("scales = [0.0, 1.0]", "scales = [0.0]", "emitter scales");
1953        refused("direct = false", "direct = true\nlayer = 1", "layer");
1954        refused(
1955            "drop_degenerate = true\n",
1956            "drop_degenerate = true\n\n[stock]\nroughness = 0.5\n",
1957            "stock",
1958        );
1959        refused(
1960            "drop_degenerate = true\n",
1961            "drop_degenerate = true\n\n[backdrop]\nwall = [1.0, 1.0, 1.0]\n",
1962            "backdrop",
1963        );
1964        let uneven = full.replace(
1965            "[[emitter]]",
1966            "[[emitter]]\nnode = \"b\"\ncolor = [1.0, 1.0, 1.0]\nintensity = 1.0\n\n[[emitter]]",
1967        );
1968        assert!(
1969            Recipe::parse(uneven.as_bytes())
1970                .unwrap_err()
1971                .contains("share")
1972        );
1973        let none = full.replace("emitter_layer = { direct = false }\n", "");
1974        assert!(
1975            Recipe::parse(none.as_bytes())
1976                .unwrap()
1977                .emitter_layer
1978                .is_none()
1979        );
1980        assert!(
1981            Recipe::parse(sample("emitter_layer = {}\n", "").as_bytes())
1982                .unwrap_err()
1983                .contains("at least one emitter")
1984        );
1985    }
1986
1987    #[test]
1988    fn a_pose_places_a_copy_of_a_node_and_moves_one_in_the_scene() {
1989        let tables = "\n[[pose]]\nnode = \"floor\"\nyaw = 0.5\nscale = [2.0, 1.0, 3.0]\ntranslation = [4.0, 5.0, 6.0]\n";
1990        let moved = load("pose-move", &sample("", tables), &[]);
1991        assert_eq!(moved.scene.triangles.len(), 4);
1992        let (s, c) = 0.5_f32.sin_cos();
1993        let expected = |p: [f32; 3]| {
1994            [
1995                c * 2.0 * p[0] + s * 3.0 * p[2] + 4.0,
1996                p[1] + 5.0,
1997                -s * 2.0 * p[0] + c * 3.0 * p[2] + 6.0,
1998            ]
1999        };
2000        let plain = load("pose-plain", &sample("", ""), &[]);
2001        let originals: Vec<[f32; 3]> = plain.scene.triangles[..2]
2002            .iter()
2003            .flat_map(|t| t.vertices)
2004            .collect();
2005        let posed: Vec<[f32; 3]> = moved.scene.triangles[2..]
2006            .iter()
2007            .flat_map(|t| t.vertices)
2008            .collect();
2009        for (from, to) in originals.iter().zip(&posed) {
2010            for (a, b) in expected(*from).iter().zip(to) {
2011                assert!((a - b).abs() < 1e-5, "{a} {b}");
2012            }
2013        }
2014        assert_eq!(moved.scene.triangles[..2], plain.scene.triangles[2..]);
2015        let copies = "\n[[pose]]\nfile = \"other.gltf\"\nnode = \"cup\"\ntranslation = [0.0, 1.0, 0.0]\nmaterials = { card = \"felt\" }\n\n[[pose]]\nfile = \"other.gltf\"\nnode = \"cup\"\ntranslation = [0.0, 2.0, 0.0]\n";
2016        let source = load(
2017            "pose-copy",
2018            &sample("", copies),
2019            &[("other.gltf", gltf(&["cup"], "card", &QUAD, &QUAD_INDICES))],
2020        );
2021        assert_eq!(source.scene.triangles.len(), 4 + 2 + 2);
2022        assert_eq!(source.scene.triangles[4].vertices[0], [0.0, 1.0, 0.0]);
2023        assert_eq!(source.scene.triangles[6].vertices[0], [0.0, 2.0, 0.0]);
2024        let first = source.scene.materials[source.scene.triangles[4].material as usize];
2025        let second = source.scene.materials[source.scene.triangles[6].material as usize];
2026        let fixture = Library::fixture();
2027        assert_eq!(first, *fixture.get("felt").unwrap());
2028        assert_eq!(first.family, Family::Cloth);
2029        assert_eq!(second, *fixture.get("card").unwrap());
2030        let missing = "\n[[pose]]\nfile = \"other.gltf\"\nnode = \"saucer\"\n";
2031        let folder = scratch("pose-missing");
2032        fs::write(folder.join("bake.toml"), sample("", missing)).unwrap();
2033        fs::write(
2034            folder.join("scene.gltf"),
2035            gltf(&["floor"], "card", &QUAD, &QUAD_INDICES),
2036        )
2037        .unwrap();
2038        fs::write(
2039            folder.join("other.gltf"),
2040            gltf(&["cup"], "card", &QUAD, &QUAD_INDICES),
2041        )
2042        .unwrap();
2043        assert!(SceneSource::load(&folder).err().unwrap().contains("saucer"));
2044    }
2045
2046    #[test]
2047    fn extra_files_lift_their_nodes_except_the_kept_prefix() {
2048        let tables = "\n[[file]]\npath = \"plant.gltf\"\ntranslation = [10.0, 0.0, 0.0]\nkeep_prefix = \"floor_\"\n";
2049        let source = load(
2050            "files",
2051            &sample("", tables),
2052            &[(
2053                "plant.gltf",
2054                gltf(&["stem", "floor_dry"], "card", &QUAD, &QUAD_INDICES),
2055            )],
2056        );
2057        let at: Vec<f32> = source
2058            .scene
2059            .triangles
2060            .iter()
2061            .map(|t| t.vertices[0][0])
2062            .collect();
2063        assert_eq!(at, [0.0, 0.0, 0.0, 0.0, 10.0, 10.0, 0.0, 0.0]);
2064    }
2065
2066    #[test]
2067    fn degenerate_triangles_and_shadow_only_materials_stay_out_of_the_bake() {
2068        let flat = [[0.0; 3], [1.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]];
2069        let kept = load("degenerate-kept", &sample("", ""), &[]);
2070        assert_eq!(kept.scene.triangles.len(), 4);
2071        let folder = scratch("degenerate");
2072        fs::write(
2073            folder.join("bake.toml"),
2074            sample(
2075                "drop_degenerate = true\nshadow_only_materials = [\"occluder\"]\n",
2076                "\n[[file]]\npath = \"rig.gltf\"\n",
2077            ),
2078        )
2079        .unwrap();
2080        fs::write(
2081            folder.join("scene.gltf"),
2082            gltf(&["a"], "card", &flat, &[0, 1, 2, 0, 2, 3]),
2083        )
2084        .unwrap();
2085        fs::write(
2086            folder.join("rig.gltf"),
2087            gltf(&["b"], "occluder", &QUAD, &QUAD_INDICES),
2088        )
2089        .unwrap();
2090        let source = SceneSource::load(&folder).unwrap();
2091        assert_eq!(source.scene.triangles.len(), 1);
2092        assert_eq!(source.scene.materials.len(), 2);
2093    }
2094
2095    #[test]
2096    fn shade_multiplies_the_sky_of_each_anchor() {
2097        let plain = load("shade-plain", &sample("", ""), &[]);
2098        let shaded = load(
2099            "shade",
2100            &sample("", "\n[sky.shade]\n9 = [0.5, 0.25, 2.0]\n"),
2101            &[],
2102        );
2103        let (a, b) = (&plain.scene.anchors, &shaded.scene.anchors);
2104        for (x, y) in a[0].sky.texels.iter().zip(&b[0].sky.texels) {
2105            assert_eq!(y[0], x[0] * 0.5);
2106            assert_eq!(y[1], x[1] * 0.25);
2107            assert_eq!(y[2], x[2] * 2.0);
2108            assert_eq!(y[3], x[3]);
2109        }
2110        assert_eq!(a[1].sky, b[1].sky);
2111        assert_eq!(shaded.with_hours(&[9.0]).unwrap()[0].sky, b[0].sky);
2112        assert_eq!(shaded.with_hours(&[22.0]).unwrap()[0].sky, a[1].sky);
2113    }
2114
2115    #[test]
2116    fn a_clock_sun_lights_turns_and_shades_the_hdr_by_the_hour() {
2117        let sky = pfx_load::fixture::SKY_HDR;
2118        let tables = "\n[sun]\nmodel = \"clock\"\n\n[clock]\ntoward = [0.2332867, 0.8678002, 0.43874848]\nsun = 2.0\ntint = 0.5\nelevation = 1.0\nazimuth = -2.0\nskywarm = 0.2\nlamp = 3.0\nexposure = 1.0\nwarmth = 0.5\nturn = 0.6\n\n[clock.arc]\nstart = 5.0\nspan = 15.0\nelevation = 50.0\nazimuth = -70.0\nsweep = 140.0\nreference = 10.0\n\n[sky]\nkind = \"hdr\"\npath = \"sky.hdr\"\nambient = \"clock\"\n\n[sky.prepare]\nmean = 0.44\n\n[sky.shade]\n\"22.0\" = [0.5, 0.5, 0.5]\n";
2119        let folder = scratch("clock-sky");
2120        fs::write(folder.join("bake.toml"), sample("", tables)).unwrap();
2121        fs::write(
2122            folder.join("scene.gltf"),
2123            gltf(&["a"], "card", &QUAD, &QUAD_INDICES),
2124        )
2125        .unwrap();
2126        fs::write(folder.join("sky.hdr"), sky).unwrap();
2127        let source = SceneSource::load(&folder).unwrap();
2128        let clock = DampedClock {
2129            sun: 2.0,
2130            tint: 0.5,
2131            elevation: 1.0,
2132            azimuth: -2.0,
2133            skywarm: 0.2,
2134            lamp: 3.0,
2135            exposure: 1.0,
2136            warmth: 0.5,
2137            turn: 0.6,
2138            arc: SunArc {
2139                start: 5.0,
2140                span: 15.0,
2141                elevation: 50.0,
2142                azimuth: -70.0,
2143                sweep: 140.0,
2144                reference: 10.0,
2145            },
2146        };
2147        let base = [0.2332867, 0.8678002, 0.43874848];
2148        let raw = Sky::parse(sky).unwrap();
2149        for anchor in &source.scene.anchors {
2150            let damped = clock.at(f64::from(anchor.hour), base);
2151            let mut expected = raw
2152                .clone()
2153                .exposed(0.44 / raw.mean_luminance().max(1e-3))
2154                .rotated(-(damped.turn as f32) * TAU);
2155            let shade = if anchor.hour == 22.0 {
2156                [0.5; 3]
2157            } else {
2158                damped.shade.map(|v| v as f32)
2159            };
2160            for texel in &mut expected.texels {
2161                for (value, tint) in texel.iter_mut().zip(shade) {
2162                    *value *= tint;
2163                }
2164            }
2165            assert_eq!(anchor.sky, expected, "sky at {} h", anchor.hour);
2166            assert_eq!(
2167                anchor.sun.direction,
2168                compose::unit(damped.toward.map(|v| v as f32))
2169            );
2170            assert_eq!(anchor.sun.color, damped.colour.map(|v| v as f32));
2171            assert_eq!(anchor.sun.intensity, 1.0);
2172        }
2173        assert_ne!(source.scene.anchors[0].sky, source.scene.anchors[1].sky);
2174    }
2175
2176    #[test]
2177    fn emitters_become_spheres_or_nodes_and_scale_per_anchor() {
2178        let tables = "\n[[emitter]]\nposition = [0.0, 2.0, 0.0]\nradius = 0.1\ncolor = [1.0, 0.5, 0.25]\nintensity = 0.2\nscales = [0.0, 2.0]\n\n[[emitter]]\nnode = \"lamp\"\ncolor = [1.0, 1.0, 1.0]\nintensity = 3.0\nscales = [0.0, 2.0]\n";
2179        let source = load(
2180            "emitters",
2181            &sample("emitter_layer = { direct = false }\n", tables),
2182            &[],
2183        );
2184        let radiance = 0.2 / (std::f32::consts::PI * 0.1 * 0.1);
2185        assert_eq!(source.emitters.len(), 2);
2186        let sphere = source.scene.materials[source.emitters[0]];
2187        assert_eq!(sphere.emission, [radiance, radiance * 0.5, radiance * 0.25]);
2188        assert_eq!(sphere.base, [0.0; 3]);
2189        assert_eq!(sphere.roughness, 1.0);
2190        assert_eq!(
2191            source.scene.materials[source.emitters[1]].emission,
2192            [3.0; 3]
2193        );
2194        let lamp_triangles = source
2195            .scene
2196            .triangles
2197            .iter()
2198            .filter(|t| t.material as usize == source.emitters[1])
2199            .count();
2200        assert_eq!(lamp_triangles, 2);
2201        let sphere_triangles = source
2202            .scene
2203            .triangles
2204            .iter()
2205            .filter(|t| t.material as usize == source.emitters[0])
2206            .count();
2207        assert_eq!(sphere_triangles, 528);
2208        assert_eq!(source.scene.triangles.len(), 2 + 2 + 528);
2209        let dark = source.anchor_scene(0).unwrap().unwrap();
2210        assert_eq!(dark.materials[source.emitters[0]].emission, [0.0; 3]);
2211        assert_eq!(dark.materials[source.emitters[1]].emission, [0.0; 3]);
2212        let bright = source.anchor_scene(1).unwrap().unwrap();
2213        assert_eq!(
2214            bright.materials[source.emitters[0]].emission,
2215            sphere.emission.map(|v| v * 2.0)
2216        );
2217        assert_eq!(bright.materials[source.emitters[1]].emission, [6.0; 3]);
2218        assert_eq!(bright.triangles.len(), source.scene.triangles.len());
2219        assert!(source.anchor_scene(2).is_err());
2220        assert!(
2221            source
2222                .with_hours(&[9.0])
2223                .err()
2224                .unwrap()
2225                .contains("emitter scales")
2226        );
2227        assert_eq!(source.with_hours(&[8.0, 9.0]).unwrap().len(), 2);
2228        let none = load("emitters-none", &sample("", ""), &[]);
2229        assert!(none.anchor_scene(0).unwrap().is_none());
2230        assert!(none.layer().unwrap().is_none());
2231    }
2232
2233    #[test]
2234    fn the_emitter_layer_is_hashed_with_its_scales_and_written_beside_the_probes() {
2235        let tables = "\n[[emitter]]\nposition = [0.0, 2.0, 0.0]\nradius = 0.1\ncolor = [1.0, 0.5, 0.25]\nintensity = 0.2\nscales = [0.0, 0.0]\n";
2236        let source = load(
2237            "layer",
2238            &sample("emitter_layer = { direct = false }\n", tables),
2239            &[],
2240        );
2241        let spec = source.recipe.volumes[0].grid;
2242        let (scales, direct) = source.layer().unwrap().unwrap();
2243        assert_eq!((scales.clone(), direct), (vec![0.0, 0.0], false));
2244        let hash = source.layer_hash(spec, 1).unwrap();
2245        assert_eq!(
2246            hash,
2247            emitter_hash(&source.scene, spec, 1, 3, &[0.0, 0.0], false).unwrap()
2248        );
2249        assert_ne!(hash, source.hash(spec, 1).unwrap());
2250        assert_ne!(hash, source.layer_hash(spec, 2).unwrap());
2251        let brighter = load(
2252            "layer-lit",
2253            &sample(
2254                "emitter_layer = { direct = false }\n",
2255                &tables.replace("[0.0, 0.0]", "[0.0, 1.0]"),
2256            ),
2257            &[],
2258        );
2259        assert_ne!(hash, brighter.layer_hash(spec, 1).unwrap());
2260        assert_ne!(
2261            source.hash(spec, 1).unwrap(),
2262            brighter.hash(spec, 1).unwrap()
2263        );
2264        let lit = load(
2265            "layer-direct",
2266            &sample("emitter_layer = { direct = true }\n", tables),
2267            &[],
2268        );
2269        assert_ne!(hash, lit.layer_hash(spec, 1).unwrap());
2270        let probe = crate::Probe::default();
2271        let grids: Vec<crate::Grid> = (0..2)
2272            .map(|_| crate::Grid::new(spec, vec![probe.clone()]).unwrap())
2273            .collect();
2274        let tmp = scratch("layer-out").join("tmp");
2275        fs::create_dir_all(&tmp).unwrap();
2276        let manifest = crate::write_artifact_with_emitters(
2277            &tmp,
2278            Path::new("probes"),
2279            &source.scene,
2280            &grids,
2281            &crate::Emitters {
2282                grid: grids[0].clone(),
2283                scales,
2284                direct,
2285            },
2286            1,
2287            3,
2288        )
2289        .unwrap();
2290        assert_eq!(manifest.scene_hash, hash);
2291        let read = crate::read_artifact(&tmp.join("probes")).unwrap();
2292        assert!(read.emitters.is_some());
2293        assert_eq!(read.manifest.emitters.unwrap().scales, vec![0.0, 0.0]);
2294    }
2295
2296    #[test]
2297    fn the_scene_can_live_in_an_assets_folder_apart_from_the_recipe() {
2298        let recipe = scratch("assets-recipe");
2299        let assets = scratch("assets-files");
2300        fs::write(recipe.join("bake.toml"), sample("", "")).unwrap();
2301        assert!(SceneSource::load(&recipe).is_err());
2302        fs::write(
2303            assets.join("scene.gltf"),
2304            gltf(&["a"], "card", &QUAD, &QUAD_INDICES),
2305        )
2306        .unwrap();
2307        let source = SceneSource::load_with(&recipe, Some(&assets)).unwrap();
2308        assert_eq!(source.root, assets.canonicalize().unwrap());
2309        assert_eq!(source.scene.triangles.len(), 2);
2310        let spec = source.recipe.volumes[0].grid;
2311        let hash = source.hash(spec, 1).unwrap();
2312        assert_eq!(
2313            SceneSource::load_with(&recipe, Some(&assets))
2314                .unwrap()
2315                .hash(spec, 1)
2316                .unwrap(),
2317            hash
2318        );
2319        fs::write(
2320            assets.join("scene.gltf"),
2321            gltf(&["a"], "card", &QUAD, &[0, 2, 1, 0, 3, 2]),
2322        )
2323        .unwrap();
2324        assert_ne!(
2325            SceneSource::load_with(&recipe, Some(&assets))
2326                .unwrap()
2327                .hash(spec, 1)
2328                .unwrap(),
2329            hash
2330        );
2331        fs::write(
2332            recipe.join("bake.toml"),
2333            sample("", "").replace("scene.gltf", "../scene.gltf"),
2334        )
2335        .unwrap();
2336        assert!(
2337            SceneSource::load_with(&recipe, Some(&assets))
2338                .err()
2339                .unwrap()
2340                .contains("outside")
2341        );
2342        assert!(SceneSource::load_with(&recipe, Some(&assets.join("missing"))).is_err());
2343    }
2344}