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}