1use std::collections::BTreeSet;
2use std::fs;
3use std::path::{Component, Path, PathBuf};
4
5use half::f16;
6use pfx_gpu::Gpu;
7use pfx_load::Mesh;
8use pfx_materials::Material;
9use pfx_trace::bvh::{Bvh, Ray, Triangle};
10use pfx_trace::shapes::{Shape, intersect};
11use pfx_trace::{Camera, Scene, Trace};
12use serde::{Deserialize, Serialize};
13use sha2::{Digest, Sha256};
14
15use crate::{Anchor, BakeScene, FileEntry};
16
17pub const SCHEMA_VERSION: u32 = 1;
18
19#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
20#[serde(deny_unknown_fields)]
21pub struct PropSpec {
22 pub name: String,
23 pub node: Option<String>,
24 pub file: Option<String>,
25 pub frames_per_side: u32,
26 pub atlas_size: u32,
27 #[serde(default)]
28 pub hemisphere: bool,
29 #[serde(default)]
30 pub anchors: Vec<f32>,
31}
32
33#[derive(Deserialize)]
34struct PropList {
35 #[serde(default, rename = "prop")]
36 props: Vec<PropSpec>,
37}
38
39#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
40pub struct PropBounds {
41 pub center: [f32; 3],
42 pub radius: f32,
43}
44
45#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
46pub struct PropManifest {
47 pub schema_version: u32,
48 pub name: String,
49 pub scene_hash: String,
50 pub frames_per_side: u32,
51 pub atlas_size: u32,
52 pub hemisphere: bool,
53 pub bounds: PropBounds,
54 pub anchors: Vec<f32>,
55 pub sample_count: u32,
56 pub seed: u32,
57 pub format: String,
58 pub color_space: String,
59 pub coordinates: String,
60 pub files: Vec<FileEntry>,
61}
62
63#[derive(Debug)]
64pub struct PropArtifact {
65 pub manifest: PropManifest,
66 pub radiance: Vec<Vec<u8>>,
67 pub geometry: Vec<u8>,
68}
69
70fn digest(bytes: &[u8]) -> String {
71 hex::encode(Sha256::digest(bytes))
72}
73
74fn label(name: &str) -> bool {
75 !name.is_empty()
76 && name
77 .chars()
78 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
79}
80
81impl PropSpec {
82 pub fn validate(&self, default_anchors: &[f32]) -> Result<(), String> {
83 if !label(&self.name) {
84 return Err("prop name must be an ASCII label".into());
85 }
86 if self.node.is_some() == self.file.is_some() {
87 return Err(format!("prop {} needs exactly one node or file", self.name));
88 }
89 if self.node.as_ref().is_some_and(String::is_empty) {
90 return Err("prop node must not be empty".into());
91 }
92 if self
93 .file
94 .as_ref()
95 .is_some_and(|v| !(v.ends_with(".gltf") || v.ends_with(".glb")))
96 {
97 return Err("prop file must be glTF or GLB".into());
98 }
99 if self.frames_per_side < 2
100 || self.frames_per_side > 32
101 || self.atlas_size < 16
102 || self.atlas_size > 8192
103 || !self.atlas_size.is_multiple_of(self.frames_per_side)
104 || self.atlas_size / self.frames_per_side < 8
105 {
106 return Err(
107 "prop atlas needs 2..32 frames per side and at least 8 texels per frame".into(),
108 );
109 }
110 let hours = if self.anchors.is_empty() {
111 default_anchors
112 } else {
113 &self.anchors
114 };
115 if hours.is_empty()
116 || hours
117 .iter()
118 .any(|v| !v.is_finite() || !(0.0..=24.0).contains(v))
119 || hours.windows(2).any(|v| v[0] >= v[1])
120 {
121 return Err("prop anchors must be sorted, distinct hours from 0 to 24".into());
122 }
123 Ok(())
124 }
125
126 pub fn hours<'a>(&'a self, defaults: &'a [f32]) -> &'a [f32] {
127 if self.anchors.is_empty() {
128 defaults
129 } else {
130 &self.anchors
131 }
132 }
133}
134
135pub fn parse_props(bytes: &[u8], default_anchors: &[f32]) -> Result<Vec<PropSpec>, String> {
136 let text = std::str::from_utf8(bytes).map_err(|e| format!("bake.toml: {e}"))?;
137 let parsed: PropList = toml::from_str(text).map_err(|e| format!("bake.toml: {e}"))?;
138 let mut names = BTreeSet::new();
139 for prop in &parsed.props {
140 prop.validate(default_anchors)?;
141 if !names.insert(&prop.name) {
142 return Err("prop names must be unique".into());
143 }
144 }
145 Ok(parsed.props)
146}
147
148fn inside(root: &Path, path: &str) -> Result<PathBuf, String> {
149 let relative = Path::new(path);
150 if relative.components().next().is_none()
151 || relative
152 .components()
153 .any(|v| !matches!(v, Component::Normal(_)))
154 {
155 return Err(format!("{path} is outside the scene folder"));
156 }
157 let full = root
158 .join(relative)
159 .canonicalize()
160 .map_err(|e| e.to_string())?;
161 if !full.starts_with(root) || !full.is_file() {
162 return Err(format!("{path} is outside the scene folder"));
163 }
164 Ok(full)
165}
166
167fn transform(matrix: [[f32; 4]; 4], point: [f32; 3]) -> [f32; 3] {
168 [0, 1, 2].map(|axis| matrix[3][axis] + (0..3).map(|k| matrix[k][axis] * point[k]).sum::<f32>())
169}
170
171fn mesh_triangles(mesh: &Mesh, node: Option<&str>) -> Result<Vec<Triangle>, String> {
172 let roots = if let Some(name) = node {
173 let matches = mesh
174 .nodes
175 .iter()
176 .enumerate()
177 .filter(|(_, item)| item.name == name)
178 .map(|(index, _)| index as u32)
179 .collect::<Vec<_>>();
180 if matches.len() != 1 {
181 return Err(format!("prop node {name} must name exactly one node"));
182 }
183 matches
184 } else {
185 mesh.roots.clone()
186 };
187 let mut stack = roots;
188 let mut seen = BTreeSet::new();
189 let mut result = Vec::new();
190 while let Some(index) = stack.pop() {
191 if !seen.insert(index) {
192 return Err("prop scene has repeated nodes".into());
193 }
194 let item = mesh
195 .nodes
196 .get(index as usize)
197 .ok_or("prop node is missing")?;
198 stack.extend(item.children.iter().copied());
199 let Some(group) = item.group else { continue };
200 let matrix = mesh.world(index).ok_or("prop transform is invalid")?;
201 for primitive in &mesh.groups[group as usize].primitives {
202 for indices in primitive.indices.chunks_exact(3) {
203 result.push(Triangle {
204 vertices: [indices[0], indices[1], indices[2]]
205 .map(|id| transform(matrix, primitive.positions[id as usize])),
206 material: primitive.material.unwrap_or(mesh.materials.len() as u32),
207 });
208 }
209 }
210 }
211 if result.is_empty() {
212 return Err("prop has no triangles".into());
213 }
214 Ok(result)
215}
216
217fn gltf_materials(bytes: &[u8]) -> Result<Vec<Material>, String> {
218 let gltf = gltf::Gltf::from_slice(bytes).map_err(|e| e.to_string())?;
219 let mut result = Vec::new();
220 for item in gltf.materials() {
221 let pbr = item.pbr_metallic_roughness();
222 let base = pbr.base_color_factor();
223 result.push(Material {
224 base: [base[0], base[1], base[2]],
225 roughness: pbr.roughness_factor(),
226 metalness: pbr.metallic_factor(),
227 emission: item.emissive_factor(),
228 ..Material::default()
229 });
230 }
231 result.push(Material::default());
232 Ok(result)
233}
234
235pub fn load_prop_scene(
236 folder: &Path,
237 main_file: &str,
238 spec: &PropSpec,
239 anchors: &[Anchor],
240) -> Result<(BakeScene, PropBounds), String> {
241 let root = folder.canonicalize().map_err(|e| e.to_string())?;
242 let file = spec.file.as_deref().unwrap_or(main_file);
243 let bytes = fs::read(inside(&root, file)?).map_err(|e| e.to_string())?;
244 let mesh = Mesh::parse_with(&bytes, |uri| {
245 let parent = Path::new(file).parent().unwrap_or(Path::new(""));
246 let relative = parent.join(uri);
247 let name = relative.to_str().ok_or("prop buffer name is invalid")?;
248 fs::read(inside(&root, name)?).map_err(|e| e.to_string())
249 })?;
250 let triangles = mesh_triangles(&mesh, spec.node.as_deref())?;
251 let bounds = bounds(&triangles, &[])?;
252 Ok((
253 BakeScene {
254 triangles,
255 shapes: Vec::new(),
256 materials: gltf_materials(&bytes)?,
257 anchors: anchors.to_vec(),
258 },
259 bounds,
260 ))
261}
262
263pub fn bounds(triangles: &[Triangle], shapes: &[Shape]) -> Result<PropBounds, String> {
264 if triangles.is_empty() && shapes.is_empty() {
265 return Err("prop has no geometry".into());
266 }
267 let mut lo = [f32::INFINITY; 3];
268 let mut hi = [f32::NEG_INFINITY; 3];
269 for point in triangles.iter().flat_map(|v| v.vertices) {
270 for axis in 0..3 {
271 lo[axis] = lo[axis].min(point[axis]);
272 hi[axis] = hi[axis].max(point[axis]);
273 }
274 }
275 for shape in shapes {
276 let (center, half) = match *shape {
277 Shape::RoundedBox { center, half, .. } => (center, half),
278 Shape::Ellipsoid { center, radii, .. } => (center, radii),
279 Shape::Capsule { a, b, radius, .. } => {
280 let center = [0, 1, 2].map(|i| (a[i] + b[i]) * 0.5);
281 let half = [0, 1, 2].map(|i| (a[i] - b[i]).abs() * 0.5 + radius);
282 (center, half)
283 }
284 Shape::RoundCone {
285 a,
286 b,
287 radius_a,
288 radius_b,
289 ..
290 } => {
291 let center = [0, 1, 2].map(|i| (a[i] + b[i]) * 0.5);
292 let radius = radius_a.max(radius_b);
293 let half = [0, 1, 2].map(|i| (a[i] - b[i]).abs() * 0.5 + radius);
294 (center, half)
295 }
296 Shape::SmoothUnion { .. } => continue,
297 };
298 for axis in 0..3 {
299 lo[axis] = lo[axis].min(center[axis] - half[axis]);
300 hi[axis] = hi[axis].max(center[axis] + half[axis]);
301 }
302 }
303 if lo.iter().chain(hi.iter()).any(|v| !v.is_finite()) {
304 return Err("prop bounds are invalid".into());
305 }
306 let center = [0, 1, 2].map(|i| (lo[i] + hi[i]) * 0.5);
307 let radius = (0..3)
308 .map(|i| (hi[i] - center[i]).powi(2))
309 .sum::<f32>()
310 .sqrt();
311 if radius <= 0.0 {
312 return Err("prop bounds have no size".into());
313 }
314 Ok(PropBounds { center, radius })
315}
316
317fn dot(a: [f32; 3], b: [f32; 3]) -> f32 {
318 (0..3).map(|i| a[i] * b[i]).sum()
319}
320fn cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
321 [
322 a[1] * b[2] - a[2] * b[1],
323 a[2] * b[0] - a[0] * b[2],
324 a[0] * b[1] - a[1] * b[0],
325 ]
326}
327fn normalize(v: [f32; 3]) -> [f32; 3] {
328 let length = dot(v, v).sqrt().max(1e-20);
329 v.map(|x| x / length)
330}
331
332fn oct_sign(value: f32) -> f32 {
333 if value < 0.0 { -1.0 } else { 1.0 }
334}
335
336pub fn oct_encode(direction: [f32; 3]) -> [f32; 2] {
337 let scale = direction.iter().map(|v| v.abs()).sum::<f32>().max(1e-20);
338 let mut p = [direction[0] / scale, direction[2] / scale];
339 if direction[1] < 0.0 {
340 p = [
341 (1.0 - p[1].abs()) * oct_sign(p[0]),
342 (1.0 - p[0].abs()) * oct_sign(p[1]),
343 ];
344 }
345 p
346}
347
348pub fn oct_decode(p: [f32; 2]) -> [f32; 3] {
349 let y = 1.0 - p[0].abs() - p[1].abs();
350 if y < 0.0 {
351 normalize([
352 (1.0 - p[1].abs()) * oct_sign(p[0]),
353 y,
354 (1.0 - p[0].abs()) * oct_sign(p[1]),
355 ])
356 } else {
357 normalize([p[0], y, p[1]])
358 }
359}
360
361pub fn frame_direction(spec: &PropSpec, x: u32, y: u32) -> [f32; 3] {
362 let n = spec.frames_per_side as f32;
363 let p = [
364 (x as f32 + 0.5) * 2.0 / n - 1.0,
365 (y as f32 + 0.5) * 2.0 / n - 1.0,
366 ];
367 let mut dir = oct_decode(p);
368 if spec.hemisphere {
369 dir[1] = dir[1].abs();
370 }
371 dir
372}
373
374fn camera(bounds: PropBounds, direction: [f32; 3]) -> Camera {
375 let origin = [0, 1, 2].map(|i| bounds.center[i] + direction[i] * bounds.radius * 3.0);
376 let forward = direction.map(|v| -v);
377 let nominal_up = if direction[1].abs() > 0.95 {
378 [0.0, 0.0, 1.0]
379 } else {
380 [0.0, 1.0, 0.0]
381 };
382 let right = normalize(cross(forward, nominal_up));
383 let up = normalize(cross(right, forward));
384 let scale = bounds.radius / (bounds.radius * 3.0);
385 Camera {
386 origin,
387 forward,
388 right: right.map(|v| v * scale),
389 up: up.map(|v| v * scale),
390 }
391}
392
393fn ray_depth(scene: &BakeScene, bvh: &Bvh, camera: Camera, x: f32, y: f32, size: u32) -> f32 {
394 let u = x * 2.0 / size as f32 - 1.0;
395 let v = 1.0 - y * 2.0 / size as f32;
396 let direction =
397 normalize([0, 1, 2].map(|i| camera.forward[i] + camera.right[i] * u + camera.up[i] * v));
398 let ray = Ray {
399 origin: camera.origin,
400 direction,
401 };
402 let mesh = bvh
403 .intersect(&scene.triangles, ray, f32::INFINITY)
404 .map(|v| v.distance)
405 .unwrap_or(f32::INFINITY);
406 intersect(&scene.shapes, ray, mesh)
407 .map(|v| v.distance)
408 .unwrap_or(mesh)
409}
410
411fn hit_depth(
412 scene: &BakeScene,
413 bvh: &Bvh,
414 camera: Camera,
415 x: u32,
416 y: u32,
417 size: u32,
418 coverage: f32,
419) -> f32 {
420 let depth = ray_depth(scene, bvh, camera, x as f32 + 0.5, y as f32 + 0.5, size);
421 if depth.is_finite() || coverage <= 0.0 {
422 return depth;
423 }
424 for dy in [0.25, 0.75] {
425 for dx in [0.25, 0.75] {
426 let depth = ray_depth(scene, bvh, camera, x as f32 + dx, y as f32 + dy, size);
427 if depth.is_finite() {
428 return depth;
429 }
430 }
431 }
432 depth
433}
434
435fn sky_radiance(sky: &pfx_load::Sky, camera: Camera, x: u32, y: u32, size: u32) -> [f32; 3] {
436 let u = (x as f32 + 0.5) * 2.0 / size as f32 - 1.0;
437 let v = 1.0 - (y as f32 + 0.5) * 2.0 / size as f32;
438 let direction =
439 normalize([0, 1, 2].map(|i| camera.forward[i] + camera.right[i] * u + camera.up[i] * v));
440 let longitude =
441 (direction[0].atan2(-direction[2]) / std::f32::consts::TAU + 0.5).rem_euclid(1.0);
442 let latitude = direction[1].clamp(-1.0, 1.0).acos() / std::f32::consts::PI;
443 let sx = ((longitude * sky.width as f32) as u32).min(sky.width - 1);
444 let sy = ((latitude * sky.height as f32) as u32).min(sky.height - 1);
445 let texel = sky.texels[(sy * sky.width + sx) as usize];
446 [texel[0], texel[1], texel[2]]
447}
448
449pub fn prop_hash(
450 scene: &BakeScene,
451 spec: &PropSpec,
452 bounds: PropBounds,
453 samples: u32,
454 seed: u32,
455) -> Result<String, String> {
456 let mut h = Sha256::new();
457 h.update(b"pito-engine-impostor-v1");
458 h.update(env!("CARGO_PKG_VERSION"));
459 h.update(pfx_trace::TRACE_WGSL);
460 h.update(pfx_materials::BRDF);
461 h.update(serde_json::to_vec(spec).map_err(|e| e.to_string())?);
462 h.update(serde_json::to_vec(&bounds).map_err(|e| e.to_string())?);
463 for triangle in &scene.triangles {
464 for point in triangle.vertices {
465 for value in point {
466 h.update(value.to_bits().to_le_bytes());
467 }
468 }
469 h.update(triangle.material.to_le_bytes());
470 }
471 for shape in &scene.shapes {
472 h.update(format!("{shape:?}"));
473 }
474 let used = scene
475 .triangles
476 .iter()
477 .map(|v| v.material)
478 .chain(scene.shapes.iter().map(|v| v.material()))
479 .collect::<BTreeSet<_>>();
480 for index in used {
481 h.update(index.to_le_bytes());
482 let material = scene
483 .materials
484 .get(index as usize)
485 .ok_or("prop references a missing material")?;
486 h.update(serde_json::to_vec(material).map_err(|e| e.to_string())?);
487 }
488 for anchor in &scene.anchors {
489 h.update(anchor.hour.to_bits().to_le_bytes());
490 for value in anchor.sun.direction.into_iter().chain(anchor.sun.color) {
491 h.update(value.to_bits().to_le_bytes());
492 }
493 h.update(anchor.sun.intensity.to_bits().to_le_bytes());
494 h.update(anchor.sky.width.to_le_bytes());
495 h.update(anchor.sky.height.to_le_bytes());
496 for pixel in &anchor.sky.texels {
497 for value in pixel {
498 h.update(value.to_bits().to_le_bytes());
499 }
500 }
501 }
502 h.update(samples.to_le_bytes());
503 h.update(seed.to_le_bytes());
504 Ok(hex::encode(h.finalize()))
505}
506
507pub fn bake_prop(
508 gpu: &Gpu,
509 scene: &BakeScene,
510 spec: &PropSpec,
511 bounds: PropBounds,
512 samples: u32,
513 seed: u32,
514) -> Result<PropArtifact, String> {
515 bake_prop_with_turn(gpu, scene, spec, bounds, samples, seed, |_| Ok(()))
516}
517
518pub fn bake_prop_with_turn(
519 gpu: &Gpu,
520 scene: &BakeScene,
521 spec: &PropSpec,
522 bounds: PropBounds,
523 samples: u32,
524 seed: u32,
525 mut turn: impl FnMut(usize) -> Result<(), String>,
526) -> Result<PropArtifact, String> {
527 spec.validate(&scene.anchors.iter().map(|a| a.hour).collect::<Vec<_>>())?;
528 if samples == 0 || scene.anchors.is_empty() {
529 return Err("prop needs samples and anchors".into());
530 }
531 if !spec.anchors.is_empty()
532 && spec.anchors
533 != scene
534 .anchors
535 .iter()
536 .map(|anchor| anchor.hour)
537 .collect::<Vec<_>>()
538 {
539 return Err("prop anchors do not match the trace scene".into());
540 }
541 let size = spec.atlas_size as usize;
542 let tile = spec.atlas_size / spec.frames_per_side;
543 let bvh = Bvh::build(&scene.triangles);
544 let mut geometry = vec![0u8; size * size * 16];
545 let mut radiance = Vec::new();
546 for (anchor_index, anchor) in scene.anchors.iter().enumerate() {
547 let mut atlas = vec![0u8; size * size * 8];
548 for fy in 0..spec.frames_per_side {
549 for fx in 0..spec.frames_per_side {
550 let view = camera(bounds, frame_direction(spec, fx, fy));
551 let trace_scene = Scene {
552 triangles: scene.triangles.clone(),
553 shapes: scene.shapes.clone(),
554 materials: scene.materials.clone(),
555 sky: anchor.sky.clone(),
556 camera: view,
557 sun: anchor.sun,
558 };
559 let mut tracer = Trace::new(gpu, &trace_scene, tile, tile)?;
560 let frame = fy * spec.frames_per_side + fx;
561 tracer.sample(
562 gpu,
563 samples,
564 seed.wrapping_add((anchor_index as u32).wrapping_mul(0x9e37_79b9))
565 .wrapping_add(frame),
566 )?;
567 let output = tracer.readback(gpu)?;
568 for y in 0..tile {
569 for x in 0..tile {
570 let src = ((y * tile + x) * 16) as usize;
571 let dst_pixel =
572 ((fy * tile + y) * spec.atlas_size + fx * tile + x) as usize;
573 let coverage = f32::from_le_bytes(
574 output.normal[src + 12..src + 16].try_into().unwrap(),
575 )
576 .clamp(0.0, 1.0);
577 let depth = if anchor_index == 0 {
578 hit_depth(scene, &bvh, view, x, y, tile, coverage)
579 } else {
580 0.0
581 };
582 let sky = sky_radiance(&anchor.sky, view, x, y, tile);
583 for channel in 0..3 {
584 let color = f32::from_le_bytes(
585 output.color[src + channel * 4..src + channel * 4 + 4]
586 .try_into()
587 .unwrap(),
588 )
589 .max(0.0);
590 let premultiplied = (color - sky[channel] * (1.0 - coverage)).max(0.0);
591 let encoded = f16::from_f32(premultiplied).to_bits().to_le_bytes();
592 atlas[dst_pixel * 8 + channel * 2..dst_pixel * 8 + channel * 2 + 2]
593 .copy_from_slice(&encoded);
594 if anchor_index == 0 {
595 geometry[dst_pixel * 16 + channel * 4
596 ..dst_pixel * 16 + channel * 4 + 4]
597 .copy_from_slice(
598 &output.normal[src + channel * 4..src + channel * 4 + 4],
599 );
600 }
601 }
602 atlas[dst_pixel * 8 + 6..dst_pixel * 8 + 8]
603 .copy_from_slice(&f16::from_f32(coverage).to_bits().to_le_bytes());
604 if anchor_index == 0 {
605 geometry[dst_pixel * 16 + 12..dst_pixel * 16 + 16]
606 .copy_from_slice(&depth.to_le_bytes());
607 }
608 }
609 }
610 }
611 }
612 radiance.push(atlas);
613 if anchor_index + 1 < scene.anchors.len() {
614 turn(anchor_index)?;
615 }
616 }
617 let manifest = PropManifest {
618 schema_version: SCHEMA_VERSION,
619 name: spec.name.clone(),
620 scene_hash: prop_hash(scene, spec, bounds, samples, seed)?,
621 frames_per_side: spec.frames_per_side,
622 atlas_size: spec.atlas_size,
623 hemisphere: spec.hemisphere,
624 bounds,
625 anchors: scene.anchors.iter().map(|a| a.hour).collect(),
626 sample_count: samples,
627 seed,
628 format: "radiance_rgba16f_geometry_normal_xyz_depth_f32_le_v1".into(),
629 color_space: "linear_rec709_radiance".into(),
630 coordinates: "right_handed_y_up_octahedral_full_or_upper_hemisphere".into(),
631 files: Vec::new(),
632 };
633 Ok(PropArtifact {
634 manifest,
635 radiance,
636 geometry,
637 })
638}
639
640pub fn write_prop_artifact(
641 tmp: &Path,
642 relative: &Path,
643 artifact: &PropArtifact,
644) -> Result<PropManifest, String> {
645 let root = tmp.canonicalize().map_err(|e| e.to_string())?;
646 if root.file_name().is_none_or(|v| v != "tmp")
647 || relative.components().next().is_none()
648 || relative
649 .components()
650 .any(|v| !matches!(v, Component::Normal(_)))
651 {
652 return Err("prop output must be relative to caller tmp".into());
653 }
654 let mut out = root;
655 for component in relative.components() {
656 out.push(component.as_os_str());
657 if fs::symlink_metadata(&out).is_ok_and(|meta| meta.file_type().is_symlink()) {
658 return Err("prop output contains a symlink".into());
659 }
660 }
661 if out.exists() {
662 return Err("prop artifact folder already exists".into());
663 }
664 let parent = out.parent().ok_or("prop output has no parent")?;
665 fs::create_dir_all(parent).map_err(|e| e.to_string())?;
666 let stage = parent.join(format!(
667 ".{}.prop-partial",
668 out.file_name().unwrap().to_string_lossy()
669 ));
670 fs::create_dir(&stage).map_err(|e| e.to_string())?;
671 let mut manifest = artifact.manifest.clone();
672 manifest.files.clear();
673 for (path, bytes) in std::iter::once(("geometry.bin".to_string(), &artifact.geometry)).chain(
674 artifact
675 .radiance
676 .iter()
677 .enumerate()
678 .map(|(i, bytes)| (format!("radiance-{i:03}.bin"), bytes)),
679 ) {
680 fs::write(stage.join(&path), bytes).map_err(|e| e.to_string())?;
681 manifest.files.push(FileEntry {
682 path,
683 bytes: bytes.len() as u64,
684 sha256: digest(bytes),
685 });
686 }
687 let json = serde_json::to_vec_pretty(&manifest).map_err(|e| e.to_string())?;
688 fs::write(stage.join("manifest.json"), &json).map_err(|e| e.to_string())?;
689 fs::write(
690 stage.join("manifest.sha256"),
691 format!("{}\n", digest(&json)),
692 )
693 .map_err(|e| e.to_string())?;
694 fs::rename(stage, out).map_err(|e| e.to_string())?;
695 Ok(manifest)
696}
697
698pub fn read_prop_artifact(folder: &Path) -> Result<PropArtifact, String> {
699 let json = fs::read(folder.join("manifest.json")).map_err(|e| e.to_string())?;
700 let checksum = fs::read_to_string(folder.join("manifest.sha256")).map_err(|e| e.to_string())?;
701 if checksum.trim() != digest(&json) {
702 return Err("prop manifest checksum mismatch".into());
703 }
704 let manifest: PropManifest = serde_json::from_slice(&json).map_err(|e| e.to_string())?;
705 if manifest.schema_version != SCHEMA_VERSION
706 || !label(&manifest.name)
707 || manifest.frames_per_side < 2
708 || manifest.frames_per_side > 32
709 || manifest.atlas_size < 16
710 || manifest.atlas_size > 8192
711 || !manifest.atlas_size.is_multiple_of(manifest.frames_per_side)
712 || manifest.atlas_size / manifest.frames_per_side < 8
713 || manifest.sample_count == 0
714 || manifest.scene_hash.len() != 64
715 || !manifest.scene_hash.bytes().all(|b| b.is_ascii_hexdigit())
716 || manifest.bounds.center.iter().any(|v| !v.is_finite())
717 || !manifest.bounds.radius.is_finite()
718 || manifest.bounds.radius <= 0.0
719 || manifest.anchors.is_empty()
720 || manifest.anchors.windows(2).any(|v| v[0] >= v[1])
721 || manifest
722 .anchors
723 .iter()
724 .any(|v| !v.is_finite() || !(0.0..=24.0).contains(v))
725 || manifest.format != "radiance_rgba16f_geometry_normal_xyz_depth_f32_le_v1"
726 || manifest.color_space != "linear_rec709_radiance"
727 || manifest.coordinates != "right_handed_y_up_octahedral_full_or_upper_hemisphere"
728 || manifest.files.len() != manifest.anchors.len() + 1
729 {
730 return Err("unsupported or invalid prop manifest".into());
731 }
732 let pixels = manifest.atlas_size as usize * manifest.atlas_size as usize;
733 let mut data = Vec::new();
734 for (i, entry) in manifest.files.iter().enumerate() {
735 let expected = if i == 0 {
736 "geometry.bin".to_string()
737 } else {
738 format!("radiance-{:03}.bin", i - 1)
739 };
740 let size = pixels * if i == 0 { 16 } else { 8 };
741 if entry.path != expected || entry.bytes != size as u64 {
742 return Err("prop file entry is invalid".into());
743 }
744 if entry.sha256.len() != 64 || !entry.sha256.bytes().all(|b| b.is_ascii_hexdigit()) {
745 return Err("prop file checksum is invalid".into());
746 }
747 let bytes = fs::read(folder.join(&entry.path)).map_err(|e| e.to_string())?;
748 if bytes.len() != size || digest(&bytes) != entry.sha256 {
749 return Err("prop file checksum mismatch".into());
750 }
751 data.push(bytes);
752 }
753 let geometry = data.remove(0);
754 Ok(PropArtifact {
755 manifest,
756 radiance: data,
757 geometry,
758 })
759}
760
761#[cfg(test)]
762mod tests {
763 use super::*;
764
765 #[test]
766 fn octahedral_round_trip() {
767 for direction in [
768 [1.0, 0.0, 0.0],
769 [-1.0, 0.0, 0.0],
770 [0.0, 1.0, 0.0],
771 [0.0, -1.0, 0.0],
772 [0.0, 0.0, 1.0],
773 [0.0, 0.0, -1.0],
774 ] {
775 assert!(dot(direction, oct_decode(oct_encode(direction))) > 0.9999);
776 }
777 for x in -8..=8 {
778 for y in -8..=8 {
779 let direction = normalize([x as f32 + 0.13, y as f32 + 0.27, 2.5]);
780 let decoded = oct_decode(oct_encode(direction));
781 assert!(dot(direction, decoded) > 0.9999);
782 }
783 }
784 }
785
786 #[test]
787 fn recipe_parses_props() {
788 let recipe = b"scene = 'room.glb'\nanchors = [8.0, 16.0]\n[[prop]]\nname = 'desk'\nnode = 'Desk'\nframes_per_side = 4\natlas_size = 256\nanchors = [9.0, 17.0]\n";
789 let props = parse_props(recipe, &[8.0, 16.0]).unwrap();
790 assert_eq!(props[0].hours(&[8.0, 16.0]), &[9.0, 17.0]);
791 let bad = std::str::from_utf8(recipe)
792 .unwrap()
793 .replace("node = 'Desk'", "file = 'desk.glb'\nnode = 'Desk'");
794 assert!(parse_props(bad.as_bytes(), &[8.0]).is_err());
795 }
796
797 #[test]
798 fn prop_artifact_verifies_bytes() {
799 let root = Path::new(env!("CARGO_MANIFEST_DIR"))
800 .join("../../tmp")
801 .join(format!("impostor-artifact-test-{}", std::process::id()));
802 if root.exists() {
803 fs::remove_dir_all(&root).unwrap();
804 }
805 fs::create_dir_all(&root).unwrap();
806 let tmp = root.join("tmp");
807 fs::create_dir_all(&tmp).unwrap();
808 let artifact = PropArtifact {
809 manifest: PropManifest {
810 schema_version: SCHEMA_VERSION,
811 name: "box".into(),
812 scene_hash: "a".repeat(64),
813 frames_per_side: 2,
814 atlas_size: 16,
815 hemisphere: false,
816 bounds: PropBounds {
817 center: [0.0; 3],
818 radius: 1.0,
819 },
820 anchors: vec![12.0],
821 sample_count: 4,
822 seed: 7,
823 format: "radiance_rgba16f_geometry_normal_xyz_depth_f32_le_v1".into(),
824 color_space: "linear_rec709_radiance".into(),
825 coordinates: "right_handed_y_up_octahedral_full_or_upper_hemisphere".into(),
826 files: Vec::new(),
827 },
828 radiance: vec![vec![0; 16 * 16 * 8]],
829 geometry: vec![0; 16 * 16 * 16],
830 };
831 write_prop_artifact(&tmp, Path::new("box"), &artifact).unwrap();
832 let folder = tmp.join("box");
833 assert_eq!(
834 read_prop_artifact(&folder).unwrap().radiance,
835 artifact.radiance
836 );
837 fs::write(folder.join("radiance-000.bin"), vec![1; 16 * 16 * 8]).unwrap();
838 assert!(
839 read_prop_artifact(&folder)
840 .unwrap_err()
841 .contains("checksum")
842 );
843 fs::remove_dir_all(root).unwrap();
844 }
845}