Skip to main content

pfx_bake/
impostor.rs

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}