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}