Skip to main content

pfx_bake/
artifact.rs

1use std::fs;
2use std::path::{Component, Path};
3
4use serde::{Deserialize, Serialize};
5use sha2::{Digest, Sha256};
6
7use crate::{BakeScene, Grid, GridSpec, Rounds};
8use pfx_trace::shapes::Shape;
9
10#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
11pub struct FileEntry {
12    pub path: String,
13    pub bytes: u64,
14    pub sha256: String,
15}
16
17#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
18pub struct Manifest {
19    pub schema_version: u32,
20    pub scene_hash: String,
21    pub anchors: Vec<f32>,
22    pub sample_count: u32,
23    pub seed: u32,
24    pub grid: GridSpec,
25    pub dimensions: [u32; 3],
26    pub format: String,
27    pub color_space: String,
28    pub coordinates: String,
29    pub files: Vec<FileEntry>,
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub emitters: Option<EmitterEntry>,
32}
33
34#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
35pub struct EmitterEntry {
36    pub file: FileEntry,
37    pub scales: Vec<f32>,
38    pub direct: bool,
39}
40
41pub struct Emitters {
42    pub grid: Grid,
43    pub scales: Vec<f32>,
44    pub direct: bool,
45}
46
47pub struct Artifact {
48    pub manifest: Manifest,
49    pub grids: Vec<Grid>,
50    pub emitters: Option<Grid>,
51    pub rounds: Option<Vec<Rounds>>,
52}
53
54#[derive(Serialize)]
55struct Recorded<'a> {
56    #[serde(flatten)]
57    manifest: &'a Manifest,
58    #[serde(skip_serializing_if = "Option::is_none")]
59    rounds: Option<&'a [Rounds]>,
60}
61
62#[derive(Deserialize)]
63struct RoundsRecord {
64    #[serde(default)]
65    rounds: Option<Vec<Rounds>>,
66}
67
68const EMITTER_FILE: &str = "emitters.bin";
69
70fn hash(bytes: &[u8]) -> String {
71    hex::encode(Sha256::digest(bytes))
72}
73
74fn add_float(hasher: &mut Sha256, value: f32) {
75    hasher.update(value.to_bits().to_le_bytes());
76}
77fn add_vec(hasher: &mut Sha256, value: [f32; 3]) {
78    for channel in value {
79        add_float(hasher, channel);
80    }
81}
82fn add_len(hasher: &mut Sha256, len: usize) {
83    hasher.update((len as u64).to_le_bytes());
84}
85
86pub fn scene_hash(
87    scene: &BakeScene,
88    spec: GridSpec,
89    samples: u32,
90    seed: u32,
91) -> Result<String, String> {
92    let mut h = Sha256::new();
93    h.update(b"pito-engine-bake-probe-v2");
94    h.update(env!("CARGO_PKG_VERSION").as_bytes());
95    h.update(include_str!("trace.rs").as_bytes());
96    h.update(include_str!("grid.rs").as_bytes());
97    h.update(pfx_trace::TRACE_WGSL.as_bytes());
98    h.update(include_str!("batch.wgsl").as_bytes());
99    h.update(pfx_materials::BRDF.as_bytes());
100    h.update(crate::PROBE_WGSL.as_bytes());
101    add_len(&mut h, scene.triangles.len());
102    for triangle in &scene.triangles {
103        for vertex in triangle.vertices {
104            add_vec(&mut h, vertex);
105        }
106        h.update(triangle.material.to_le_bytes());
107    }
108    add_len(&mut h, scene.shapes.len());
109    for shape in &scene.shapes {
110        match *shape {
111            Shape::RoundedBox {
112                center,
113                half,
114                radius,
115                material,
116            } => {
117                h.update([0]);
118                add_vec(&mut h, center);
119                add_vec(&mut h, half);
120                add_float(&mut h, radius);
121                h.update(material.to_le_bytes());
122            }
123            Shape::RoundCone {
124                a,
125                b,
126                radius_a,
127                radius_b,
128                material,
129            } => {
130                h.update([1]);
131                add_vec(&mut h, a);
132                add_vec(&mut h, b);
133                add_float(&mut h, radius_a);
134                add_float(&mut h, radius_b);
135                h.update(material.to_le_bytes());
136            }
137            Shape::Ellipsoid {
138                center,
139                radii,
140                material,
141            } => {
142                h.update([2]);
143                add_vec(&mut h, center);
144                add_vec(&mut h, radii);
145                h.update(material.to_le_bytes());
146            }
147            Shape::Capsule {
148                a,
149                b,
150                radius,
151                material,
152            } => {
153                h.update([3]);
154                add_vec(&mut h, a);
155                add_vec(&mut h, b);
156                add_float(&mut h, radius);
157                h.update(material.to_le_bytes());
158            }
159            Shape::SmoothUnion {
160                left,
161                right,
162                radius,
163                material,
164            } => {
165                h.update([4]);
166                h.update((left as u64).to_le_bytes());
167                h.update((right as u64).to_le_bytes());
168                add_float(&mut h, radius);
169                h.update(material.to_le_bytes());
170            }
171        }
172    }
173    add_len(&mut h, scene.materials.len());
174    for material in &scene.materials {
175        let bytes = serde_json::to_vec(material).map_err(|e| e.to_string())?;
176        add_len(&mut h, bytes.len());
177        h.update(bytes);
178    }
179    add_len(&mut h, scene.anchors.len());
180    for anchor in &scene.anchors {
181        add_float(&mut h, anchor.hour);
182        add_vec(&mut h, anchor.sun.direction);
183        add_vec(&mut h, anchor.sun.color);
184        add_float(&mut h, anchor.sun.intensity);
185        h.update(anchor.sky.width.to_le_bytes());
186        h.update(anchor.sky.height.to_le_bytes());
187        add_len(&mut h, anchor.sky.texels.len());
188        for texel in &anchor.sky.texels {
189            for channel in texel {
190                add_float(&mut h, *channel);
191            }
192        }
193    }
194    add_vec(&mut h, spec.min);
195    add_vec(&mut h, spec.max);
196    add_float(&mut h, spec.spacing);
197    h.update(samples.to_le_bytes());
198    h.update(seed.to_le_bytes());
199    Ok(hex::encode(h.finalize()))
200}
201
202pub fn emitter_hash(
203    scene: &BakeScene,
204    spec: GridSpec,
205    samples: u32,
206    seed: u32,
207    scales: &[f32],
208    direct: bool,
209) -> Result<String, String> {
210    let mut h = Sha256::new();
211    h.update(b"pito-engine-bake-emitters-v1");
212    h.update(scene_hash(scene, spec, samples, seed)?.as_bytes());
213    h.update(include_str!("batch.rs").as_bytes());
214    add_len(&mut h, scales.len());
215    for &scale in scales {
216        add_float(&mut h, scale);
217    }
218    h.update([u8::from(direct)]);
219    Ok(hex::encode(h.finalize()))
220}
221
222fn safe_relative(path: &Path) -> bool {
223    path.components().next().is_some()
224        && path.components().all(|v| matches!(v, Component::Normal(_)))
225}
226
227fn folder(root: &Path, relative: &Path) -> Result<std::path::PathBuf, String> {
228    let root = root.canonicalize().map_err(|e| e.to_string())?;
229    if root.file_name().is_none_or(|v| v != "tmp") || !safe_relative(relative) {
230        return Err("bake output must be relative to caller tmp".into());
231    }
232    let mut path = root;
233    for component in relative.components() {
234        path.push(component.as_os_str());
235        if let Ok(metadata) = fs::symlink_metadata(&path)
236            && metadata.file_type().is_symlink()
237        {
238            return Err("bake output contains a symlink".into());
239        }
240    }
241    Ok(path)
242}
243
244pub fn write_artifact(
245    tmp_root: &Path,
246    relative: &Path,
247    scene: &BakeScene,
248    grids: &[Grid],
249    samples: u32,
250    seed: u32,
251) -> Result<Manifest, String> {
252    let source_hash = scene_hash(
253        scene,
254        grids.first().ok_or("artifact has no grids")?.spec,
255        samples,
256        seed,
257    )?;
258    write_artifact_hashed(tmp_root, relative, scene, grids, samples, seed, source_hash)
259}
260
261pub fn write_artifact_with_emitters(
262    tmp_root: &Path,
263    relative: &Path,
264    scene: &BakeScene,
265    grids: &[Grid],
266    emitters: &Emitters,
267    samples: u32,
268    seed: u32,
269) -> Result<Manifest, String> {
270    let spec = grids.first().ok_or("artifact has no grids")?.spec;
271    let source_hash = emitter_hash(
272        scene,
273        spec,
274        samples,
275        seed,
276        &emitters.scales,
277        emitters.direct,
278    )?;
279    write(
280        tmp_root,
281        relative,
282        scene,
283        grids,
284        Some(emitters),
285        None,
286        samples,
287        seed,
288        source_hash,
289    )
290}
291
292pub fn write_artifact_hashed(
293    tmp_root: &Path,
294    relative: &Path,
295    scene: &BakeScene,
296    grids: &[Grid],
297    samples: u32,
298    seed: u32,
299    source_hash: String,
300) -> Result<Manifest, String> {
301    write(
302        tmp_root,
303        relative,
304        scene,
305        grids,
306        None,
307        None,
308        samples,
309        seed,
310        source_hash,
311    )
312}
313
314pub struct Recording<'a> {
315    pub emitters: Option<&'a Emitters>,
316    pub rounds: &'a [Rounds],
317    pub source_hash: String,
318}
319
320pub fn write_artifact_recorded(
321    tmp_root: &Path,
322    relative: &Path,
323    scene: &BakeScene,
324    grids: &[Grid],
325    recording: Recording<'_>,
326    samples: u32,
327    seed: u32,
328) -> Result<Manifest, String> {
329    write(
330        tmp_root,
331        relative,
332        scene,
333        grids,
334        recording.emitters,
335        Some(recording.rounds),
336        samples,
337        seed,
338        recording.source_hash,
339    )
340}
341
342#[allow(clippy::too_many_arguments)]
343fn write(
344    tmp_root: &Path,
345    relative: &Path,
346    scene: &BakeScene,
347    grids: &[Grid],
348    emitters: Option<&Emitters>,
349    rounds: Option<&[Rounds]>,
350    samples: u32,
351    seed: u32,
352    source_hash: String,
353) -> Result<Manifest, String> {
354    if grids.len() != scene.anchors.len() || grids.is_empty() || samples == 0 {
355        return Err("artifact grids and anchors do not match".into());
356    }
357    if rounds.is_some_and(|rounds| rounds.len() != grids.len()) {
358        return Err("artifact rounds and grids do not match".into());
359    }
360    let spec = grids[0].spec;
361    let dims = spec.dimensions()?;
362    let version = grids[0].version();
363    if grids
364        .iter()
365        .any(|g| g.spec != spec || g.dims != dims || g.version() != version)
366    {
367        return Err("artifact grids differ".into());
368    }
369    if let Some(emitters) = emitters
370        && (emitters.grid.spec != spec
371            || emitters.grid.dims != dims
372            || emitters.grid.version() != version
373            || !valid_scales(&emitters.scales, grids.len()))
374    {
375        return Err("artifact emitters do not match its grids".into());
376    }
377    for grid in grids {
378        if let Some(extras) = &grid.extras {
379            Grid::with_extras(grid.spec, grid.probes.clone(), extras.clone())?;
380        } else {
381            Grid::new(grid.spec, grid.probes.clone())?;
382        }
383    }
384    let hours: Vec<f32> = scene.anchors.iter().map(|anchor| anchor.hour).collect();
385    crate::blend_anchors(&hours, hours[0])?;
386    if hours.iter().any(|hour| !(0.0..=24.0).contains(hour)) {
387        return Err("anchor hour must be between zero and 24".into());
388    }
389    let out = folder(tmp_root, relative)?;
390    if out.exists() {
391        return Err("bake artifact folder already exists".into());
392    }
393    let parent = out.parent().ok_or("artifact has no parent")?;
394    fs::create_dir_all(parent).map_err(|e| e.to_string())?;
395    let name = out
396        .file_name()
397        .ok_or("artifact has no name")?
398        .to_string_lossy();
399    let stage = parent.join(format!(".{name}.bake-partial"));
400    fs::create_dir(&stage).map_err(|e| e.to_string())?;
401    let mut files = Vec::with_capacity(grids.len());
402    for (index, grid) in grids.iter().enumerate() {
403        let path = format!("probe-{index:03}.bin");
404        let bytes = grid.bytes();
405        fs::write(stage.join(&path), &bytes).map_err(|e| e.to_string())?;
406        files.push(FileEntry {
407            path,
408            bytes: bytes.len() as u64,
409            sha256: hash(&bytes),
410        });
411    }
412    let emitters = match emitters {
413        Some(emitters) => {
414            Grid::new(emitters.grid.spec, emitters.grid.probes.clone())?;
415            let bytes = emitters.grid.bytes();
416            fs::write(stage.join(EMITTER_FILE), &bytes).map_err(|e| e.to_string())?;
417            Some(EmitterEntry {
418                file: FileEntry {
419                    path: EMITTER_FILE.into(),
420                    bytes: bytes.len() as u64,
421                    sha256: hash(&bytes),
422                },
423                scales: emitters.scales.clone(),
424                direct: emitters.direct,
425            })
426        }
427        None => None,
428    };
429    let manifest = Manifest {
430        schema_version: version,
431        scene_hash: source_hash,
432        anchors: hours,
433        sample_count: samples,
434        seed,
435        grid: spec,
436        dimensions: dims,
437        format: if version == 2 {
438            "ambient_cube_rgb16f_depth_moments_f16_offset_f16_le_v2"
439        } else {
440            "ambient_cube_rgb16f_visibility_f16_le_v1"
441        }
442        .into(),
443        color_space: "linear_rec709_irradiance".into(),
444        coordinates: "right_handed_y_up_xyz_x_fastest".into(),
445        files,
446        emitters,
447    };
448    let json = serde_json::to_vec_pretty(&Recorded {
449        manifest: &manifest,
450        rounds,
451    })
452    .map_err(|e| e.to_string())?;
453    fs::write(stage.join("manifest.json"), &json).map_err(|e| e.to_string())?;
454    fs::write(stage.join("manifest.sha256"), format!("{}\n", hash(&json)))
455        .map_err(|e| e.to_string())?;
456    fs::rename(stage, out).map_err(|e| e.to_string())?;
457    Ok(manifest)
458}
459
460pub fn read_artifact(dir: &Path) -> Result<Artifact, String> {
461    read_artifact_from(&|name| fs::read(dir.join(name)).ok())
462}
463
464pub fn read_artifact_from(files: &dyn Fn(&str) -> Option<Vec<u8>>) -> Result<Artifact, String> {
465    let fetch = |name: &str| files(name).ok_or_else(|| format!("missing bake file {name}"));
466    let json = fetch("manifest.json")?;
467    let checksum = String::from_utf8(fetch("manifest.sha256")?).map_err(|e| e.to_string())?;
468    if checksum.trim() != hash(&json) {
469        return Err("manifest checksum mismatch".into());
470    }
471    let manifest: Manifest = serde_json::from_slice(&json).map_err(|e| e.to_string())?;
472    let rounds = serde_json::from_slice::<RoundsRecord>(&json)
473        .map_err(|e| e.to_string())?
474        .rounds;
475    if rounds
476        .as_ref()
477        .is_some_and(|rounds| rounds.len() != manifest.files.len())
478    {
479        return Err("manifest rounds do not match its anchors".into());
480    }
481    if !matches!(
482        (manifest.schema_version, manifest.format.as_str()),
483        (1, "ambient_cube_rgb16f_visibility_f16_le_v1")
484            | (2, "ambient_cube_rgb16f_depth_moments_f16_offset_f16_le_v2")
485    ) || manifest.sample_count == 0
486        || manifest.grid.dimensions()? != manifest.dimensions
487        || manifest.anchors.is_empty()
488        || manifest.files.len() != manifest.anchors.len()
489        || manifest.color_space != "linear_rec709_irradiance"
490        || manifest.coordinates != "right_handed_y_up_xyz_x_fastest"
491        || crate::blend_anchors(&manifest.anchors, manifest.anchors[0]).is_err()
492        || manifest
493            .anchors
494            .iter()
495            .any(|hour| !(0.0..=24.0).contains(hour))
496    {
497        return Err("unsupported or invalid bake manifest".into());
498    }
499    let mut grids = Vec::with_capacity(manifest.files.len());
500    for (index, entry) in manifest.files.iter().enumerate() {
501        if entry.path != format!("probe-{index:03}.bin") {
502            return Err("invalid probe file name".into());
503        }
504        let bytes = fetch(&entry.path)?;
505        if bytes.len() as u64 != entry.bytes || hash(&bytes) != entry.sha256 {
506            return Err("probe checksum mismatch".into());
507        }
508        let grid = Grid::from_bytes(&bytes)?;
509        if grid.spec != manifest.grid || grid.version() != manifest.schema_version {
510            return Err("probe grid does not match manifest".into());
511        }
512        grids.push(grid);
513    }
514    let emitters = match &manifest.emitters {
515        Some(entry) => {
516            if entry.file.path != EMITTER_FILE || !valid_scales(&entry.scales, grids.len()) {
517                return Err("invalid emitter layer".into());
518            }
519            let bytes = fetch(EMITTER_FILE)?;
520            if bytes.len() as u64 != entry.file.bytes || hash(&bytes) != entry.file.sha256 {
521                return Err("emitter checksum mismatch".into());
522            }
523            let grid = Grid::from_bytes(&bytes)?;
524            if grid.spec != manifest.grid || grid.version() != manifest.schema_version {
525                return Err("emitter grid does not match manifest".into());
526            }
527            Some(grid)
528        }
529        None => None,
530    };
531    Ok(Artifact {
532        manifest,
533        grids,
534        emitters,
535        rounds,
536    })
537}
538
539pub fn with_emitters(grid: &Grid, emitters: &Grid, weight: f32) -> Result<Grid, String> {
540    if grid.spec != emitters.spec || grid.probes.len() != emitters.probes.len() {
541        return Err("emitter grid does not match".into());
542    }
543    if !weight.is_finite() {
544        return Err("emitter weight must be finite".into());
545    }
546    let probes = grid
547        .probes
548        .iter()
549        .zip(&emitters.probes)
550        .map(|(probe, glow)| {
551            let mut out = probe.clone();
552            for (lobe, light) in out.lobes.iter_mut().zip(glow.lobes) {
553                for (value, add) in lobe.iter_mut().zip(light) {
554                    *value = (*value + weight * add).max(0.0);
555                }
556            }
557            out
558        })
559        .collect();
560    match &grid.extras {
561        Some(extras) => Grid::with_extras(grid.spec, probes, extras.clone()),
562        None => Grid::new(grid.spec, probes),
563    }
564}
565
566fn valid_scales(scales: &[f32], anchors: usize) -> bool {
567    scales.len() == anchors
568        && scales
569            .iter()
570            .all(|scale| scale.is_finite() && *scale >= 0.0)
571}
572
573#[cfg(test)]
574mod tests {
575    use super::*;
576    use crate::{Anchor, Probe, grid::ProbeExtra};
577    use pfx_load::Sky;
578    use pfx_materials::Material;
579    use pfx_trace::Sun;
580    use pfx_trace::bvh::Triangle;
581
582    #[test]
583    fn manifest_round_trip_and_deterministic_bytes() {
584        let spec = GridSpec {
585            min: [0.0; 3],
586            max: [1.0; 3],
587            spacing: 1.0,
588        };
589        let grid = Grid::new(spec, vec![Probe::default(); 8]).unwrap();
590        assert_eq!(grid.bytes(), grid.bytes());
591        let scene = BakeScene {
592            triangles: vec![Triangle {
593                vertices: [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]],
594                material: 0,
595            }],
596            shapes: Vec::new(),
597            materials: vec![Material::default()],
598            anchors: vec![Anchor {
599                hour: 12.0,
600                sky: Sky {
601                    width: 1,
602                    height: 1,
603                    texels: vec![[1.0; 4]],
604                },
605                sun: Sun {
606                    direction: [0.0, 1.0, 0.0],
607                    color: [1.0; 3],
608                    intensity: 1.0,
609                },
610            }],
611        };
612        let manifest = Manifest {
613            schema_version: 1,
614            scene_hash: scene_hash(&scene, spec, 4, 7).unwrap(),
615            anchors: vec![12.0],
616            sample_count: 4,
617            seed: 7,
618            grid: spec,
619            dimensions: [2; 3],
620            format: "ambient_cube_rgb16f_visibility_f16_le_v1".into(),
621            color_space: "linear_rec709_irradiance".into(),
622            coordinates: "right_handed_y_up_xyz_x_fastest".into(),
623            files: vec![FileEntry {
624                path: "probe-000.bin".into(),
625                bytes: grid.bytes().len() as u64,
626                sha256: hash(&grid.bytes()),
627            }],
628            emitters: None,
629        };
630        let json = serde_json::to_vec(&manifest).unwrap();
631        assert_eq!(serde_json::from_slice::<Manifest>(&json).unwrap(), manifest);
632        assert_eq!(scene_hash(&scene, spec, 4, 7).unwrap(), manifest.scene_hash);
633        assert_ne!(scene_hash(&scene, spec, 4, 8).unwrap(), manifest.scene_hash);
634        assert_ne!(scene_hash(&scene, spec, 5, 7).unwrap(), manifest.scene_hash);
635    }
636
637    #[test]
638    fn artifact_reader_accepts_both_probe_versions() {
639        let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tmp");
640        let folder = root.join("bake-version-tests");
641        if folder.exists() {
642            fs::remove_dir_all(&folder).unwrap();
643        }
644        let spec = GridSpec {
645            min: [0.0; 3],
646            max: [0.0; 3],
647            spacing: 1.0,
648        };
649        let scene = BakeScene {
650            triangles: Vec::new(),
651            shapes: Vec::new(),
652            materials: vec![Material::default()],
653            anchors: vec![Anchor {
654                hour: 12.0,
655                sky: Sky {
656                    width: 1,
657                    height: 1,
658                    texels: vec![[1.0; 4]],
659                },
660                sun: Sun {
661                    direction: [0.0, 1.0, 0.0],
662                    color: [1.0; 3],
663                    intensity: 1.0,
664                },
665            }],
666        };
667        let probe = Probe {
668            visibility: [4.0; 6],
669            ..Probe::default()
670        };
671        let older = Grid::new(spec, vec![probe.clone()]).unwrap();
672        let current = Grid::with_extras(
673            spec,
674            vec![probe],
675            vec![ProbeExtra {
676                second: [16.0; 6],
677                offset: [0.25, 0.0, 0.0],
678                enabled: true,
679                backfaces: 2,
680            }],
681        )
682        .unwrap();
683        for (version, grid) in [(1, older), (2, current)] {
684            let relative = Path::new("bake-version-tests").join(format!("v{version}"));
685            let manifest = write_artifact(&root, &relative, &scene, &[grid], 4, 7).unwrap();
686            assert_eq!(manifest.schema_version, version);
687            let loaded = read_artifact(&root.join(relative)).unwrap();
688            assert_eq!(loaded.grids[0].version(), version);
689        }
690        fs::remove_dir_all(folder).unwrap();
691    }
692
693    #[test]
694    fn rounds_are_recorded_beside_the_manifest_and_stay_out_of_the_hash() {
695        let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tmp");
696        fs::create_dir_all(&root).unwrap();
697        for name in ["bake-rounds-test", "bake-unrecorded-test"] {
698            if root.join(name).exists() {
699                fs::remove_dir_all(root.join(name)).unwrap();
700            }
701        }
702        let spec = GridSpec {
703            min: [0.0; 3],
704            max: [0.0; 3],
705            spacing: 1.0,
706        };
707        let scene = BakeScene {
708            triangles: Vec::new(),
709            shapes: Vec::new(),
710            materials: vec![Material::default()],
711            anchors: vec![Anchor {
712                hour: 12.0,
713                sky: Sky {
714                    width: 1,
715                    height: 1,
716                    texels: vec![[1.0; 4]],
717                },
718                sun: Sun {
719                    direction: [0.0, 1.0, 0.0],
720                    color: [1.0; 3],
721                    intensity: 1.0,
722                },
723            }],
724        };
725        let grid = Grid::new(spec, vec![Probe::default()]).unwrap();
726        let rounds = vec![Rounds {
727            enabled: 9,
728            active: vec![9, 9, 9, 6, 2, 0],
729        }];
730        let recorded = write_artifact_recorded(
731            &root,
732            Path::new("bake-rounds-test"),
733            &scene,
734            std::slice::from_ref(&grid),
735            Recording {
736                emitters: None,
737                rounds: &rounds,
738                source_hash: scene_hash(&scene, spec, 4, 7).unwrap(),
739            },
740            4,
741            7,
742        )
743        .unwrap();
744        let plain = write_artifact(
745            &root,
746            Path::new("bake-unrecorded-test"),
747            &scene,
748            std::slice::from_ref(&grid),
749            4,
750            7,
751        )
752        .unwrap();
753        assert_eq!(recorded, plain);
754        let loaded = read_artifact(&root.join("bake-rounds-test")).unwrap();
755        assert_eq!(loaded.rounds.as_deref(), Some(rounds.as_slice()));
756        assert_eq!(loaded.manifest, plain);
757        assert_eq!(rounds[0].run(), 6);
758        assert_eq!(rounds[0].converged(), vec![0, 0, 0, 3, 4, 2]);
759        let unrecorded = read_artifact(&root.join("bake-unrecorded-test")).unwrap();
760        assert!(unrecorded.rounds.is_none());
761        let json = fs::read_to_string(root.join("bake-unrecorded-test/manifest.json")).unwrap();
762        assert!(!json.contains("rounds"));
763        let recorded_json: serde_json::Value = serde_json::from_str(
764            &fs::read_to_string(root.join("bake-rounds-test/manifest.json")).unwrap(),
765        )
766        .unwrap();
767        assert_eq!(recorded_json["rounds"][0]["enabled"], 9);
768        let mismatched = write_artifact_recorded(
769            &root,
770            Path::new("bake-rounds-mismatch-test"),
771            &scene,
772            std::slice::from_ref(&grid),
773            Recording {
774                emitters: None,
775                rounds: &[],
776                source_hash: plain.scene_hash.clone(),
777            },
778            4,
779            7,
780        );
781        assert!(mismatched.unwrap_err().contains("rounds"));
782        for name in ["bake-rounds-test", "bake-unrecorded-test"] {
783            fs::remove_dir_all(root.join(name)).unwrap();
784        }
785    }
786
787    #[test]
788    fn emitter_layers_round_trip_and_leave_plain_manifests_alone() {
789        let spec = GridSpec {
790            min: [0.0; 3],
791            max: [1.0, 0.0, 0.0],
792            spacing: 1.0,
793        };
794        let probe = |value: f32| Probe {
795            lobes: [[value; 3]; 6],
796            visibility: [1.0; 6],
797        };
798        let base = Grid::new(spec, vec![probe(0.25); 2]).unwrap();
799        let glow = Grid::new(spec, vec![probe(0.5); 2]).unwrap();
800        let scene = BakeScene {
801            triangles: Vec::new(),
802            shapes: Vec::new(),
803            materials: vec![Material::default()],
804            anchors: vec![Anchor {
805                hour: 22.0,
806                sky: Sky {
807                    width: 1,
808                    height: 1,
809                    texels: vec![[0.0; 4]],
810                },
811                sun: Sun {
812                    direction: [0.0, 1.0, 0.0],
813                    color: [0.0; 3],
814                    intensity: 0.0,
815                },
816            }],
817        };
818        let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tmp");
819        fs::create_dir_all(&root).unwrap();
820        for name in ["bake-emitters-test", "bake-plain-test"] {
821            if root.join(name).exists() {
822                fs::remove_dir_all(root.join(name)).unwrap();
823            }
824        }
825        let emitters = Emitters {
826            grid: glow.clone(),
827            scales: vec![0.0],
828            direct: false,
829        };
830        let layered = write_artifact_with_emitters(
831            &root,
832            Path::new("bake-emitters-test"),
833            &scene,
834            std::slice::from_ref(&base),
835            &emitters,
836            4,
837            7,
838        )
839        .unwrap();
840        let plain = write_artifact(
841            &root,
842            Path::new("bake-plain-test"),
843            &scene,
844            std::slice::from_ref(&base),
845            4,
846            7,
847        )
848        .unwrap();
849        assert_ne!(layered.scene_hash, plain.scene_hash);
850        assert_eq!(
851            layered.scene_hash,
852            emitter_hash(&scene, spec, 4, 7, &[0.0], false).unwrap()
853        );
854        assert_ne!(
855            layered.scene_hash,
856            emitter_hash(&scene, spec, 4, 7, &[1.0], false).unwrap()
857        );
858        let json = fs::read_to_string(root.join("bake-plain-test/manifest.json")).unwrap();
859        assert!(!json.contains("emitters"));
860        let read = read_artifact(&root.join("bake-emitters-test")).unwrap();
861        assert_eq!(read.emitters, Some(glow));
862        assert_eq!(read.manifest.emitters.as_ref().unwrap().scales, vec![0.0]);
863        assert!(
864            read_artifact(&root.join("bake-plain-test"))
865                .unwrap()
866                .emitters
867                .is_none()
868        );
869        let faded = with_emitters(&base, read.emitters.as_ref().unwrap(), 2.0).unwrap();
870        assert_eq!(faded.probes[0].lobes[0], [1.25; 3]);
871        let removed = with_emitters(&base, read.emitters.as_ref().unwrap(), -1.0).unwrap();
872        assert_eq!(removed.probes[1].lobes[5], [0.0; 3]);
873        assert_eq!(removed.probes[1].visibility, base.probes[1].visibility);
874        fs::write(root.join("bake-emitters-test/emitters.bin"), base.bytes()).unwrap();
875        assert!(
876            read_artifact(&root.join("bake-emitters-test"))
877                .err()
878                .unwrap()
879                .contains("emitter")
880        );
881        let wrong = Emitters {
882            scales: vec![0.0, 1.0],
883            ..emitters
884        };
885        assert!(
886            write_artifact_with_emitters(
887                &root,
888                Path::new("bake-emitters-wrong"),
889                &scene,
890                &[base],
891                &wrong,
892                4,
893                7,
894            )
895            .is_err()
896        );
897        fs::remove_dir_all(root.join("bake-emitters-test")).unwrap();
898        fs::remove_dir_all(root.join("bake-plain-test")).unwrap();
899    }
900
901    fn folder_files(dir: &Path) -> std::collections::HashMap<String, Vec<u8>> {
902        fs::read_dir(dir)
903            .unwrap()
904            .map(|entry| {
905                let entry = entry.unwrap();
906                (
907                    entry.file_name().to_string_lossy().into_owned(),
908                    fs::read(entry.path()).unwrap(),
909                )
910            })
911            .collect()
912    }
913
914    fn same(left: &Artifact, right: &Artifact) {
915        assert_eq!(left.manifest, right.manifest);
916        assert_eq!(left.grids, right.grids);
917        assert_eq!(left.emitters, right.emitters);
918        assert_eq!(left.rounds, right.rounds);
919    }
920
921    #[test]
922    fn bytes_reader_equals_the_path_reader_and_refuses_a_changed_byte() {
923        let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tmp");
924        let folder = root.join("bake-bytes-tests");
925        if folder.exists() {
926            fs::remove_dir_all(&folder).unwrap();
927        }
928        let room = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/data/box-room");
929        let source = crate::SceneSource::load(&room).unwrap();
930        let spec = source.recipe.volumes[0].grid;
931        let grids: Vec<Grid> = source
932            .scene
933            .anchors
934            .iter()
935            .map(|_| Grid::new(spec, vec![Probe::default()]).unwrap())
936            .collect();
937        write_artifact(
938            &root,
939            Path::new("bake-bytes-tests/plain"),
940            &source.scene,
941            &grids,
942            4,
943            7,
944        )
945        .unwrap();
946        let emitters = Emitters {
947            grid: grids[0].clone(),
948            scales: vec![0.0; grids.len()],
949            direct: false,
950        };
951        write_artifact_with_emitters(
952            &root,
953            Path::new("bake-bytes-tests/glowing"),
954            &source.scene,
955            &grids,
956            &emitters,
957            4,
958            7,
959        )
960        .unwrap();
961        for name in ["plain", "glowing"] {
962            let dir = folder.join(name);
963            let files = folder_files(&dir);
964            let from_path = read_artifact(&dir).unwrap();
965            let from_bytes = read_artifact_from(&|file| files.get(file).cloned()).unwrap();
966            same(&from_path, &from_bytes);
967            assert_eq!(from_bytes.emitters.is_some(), name == "glowing");
968            let mut names: Vec<&String> = files.keys().collect();
969            names.sort();
970            for file in names {
971                let mut changed = files.clone();
972                changed.get_mut(file).unwrap()[10] ^= 1;
973                let error = read_artifact_from(&|file| changed.get(file).cloned())
974                    .err()
975                    .unwrap();
976                assert!(error.contains("checksum"), "{file}: {error}");
977                let mut missing = files.clone();
978                missing.remove(file);
979                let error = read_artifact_from(&|file| missing.get(file).cloned())
980                    .err()
981                    .unwrap();
982                assert!(error.contains("missing bake file"), "{file}: {error}");
983            }
984        }
985        fs::remove_dir_all(folder).unwrap();
986    }
987}