use crate::codec::hdr::{
CUBE_FORMAT_RGBA32F, CUBE_PAYLOAD_HEADER_BYTES, CUBE_PAYLOAD_MAGIC, equirect_to_cube,
};
fn cubemap_source(args: &serde_json::Value) -> Result<&str, String> {
let source = args.get("source").and_then(|v| v.as_str()).unwrap_or("");
if source.is_empty() {
return Err("CubemapTexture requires a `source` path".into());
}
if !source.to_ascii_lowercase().ends_with(".hdr") {
return Err(format!(
"CubemapTexture source '{}' must be a Radiance .hdr file",
source
));
}
Ok(source)
}
pub(crate) fn validate_cubemap_args(args: &serde_json::Value) -> Result<(), String> {
cubemap_source(args)?;
let face_size = args
.get("face_size")
.and_then(|v| v.as_u64())
.unwrap_or(256);
if !(8..=4096).contains(&face_size) {
return Err(format!(
"CubemapTexture face_size {} out of range (8..=4096)",
face_size
));
}
if !face_size.is_power_of_two() {
return Err(format!(
"CubemapTexture face_size {} must be a power of two",
face_size
));
}
Ok(())
}
pub(crate) fn compile_cubemap_payload(args: &serde_json::Value) -> Result<Vec<u8>, String> {
validate_cubemap_args(args)?;
let source = cubemap_source(args)?;
let face_size = args
.get("face_size")
.and_then(|v| v.as_u64())
.unwrap_or(256) as u32;
let hdr = crate::codec::hdr::load_file(source)?;
let faces = equirect_to_cube(&hdr, face_size);
Ok(serialise_faces(face_size, &faces))
}
fn serialise_faces(face_size: u32, faces: &[Vec<f32>; 6]) -> Vec<u8> {
let face_floats = (face_size as usize) * (face_size as usize) * 4;
let mut buf = Vec::with_capacity(CUBE_PAYLOAD_HEADER_BYTES + 6 * face_floats * 4);
buf.extend_from_slice(&CUBE_PAYLOAD_MAGIC.to_le_bytes());
buf.extend_from_slice(&face_size.to_le_bytes());
buf.extend_from_slice(&1u32.to_le_bytes());
buf.extend_from_slice(&CUBE_FORMAT_RGBA32F.to_le_bytes());
for face in faces {
debug_assert_eq!(face.len(), face_floats);
buf.extend_from_slice(bytemuck::cast_slice::<f32, u8>(face));
}
buf
}
#[cfg(test)]
mod tests {
use super::*;
use crate::codec::hdr::{HdrImage, deserialise};
#[test]
fn payload_round_trip_via_deserialise() {
let pixel = [0.7f32, 0.3, 0.2];
let hdr = HdrImage {
width: 16,
height: 8,
pixels: vec![pixel; 16 * 8],
};
let faces = equirect_to_cube(&hdr, 8);
let blob = serialise_faces(8, &faces);
let (face_size, face_bytes) = deserialise(&blob).expect("deserialise");
assert_eq!(face_size, 8);
assert_eq!(face_bytes.len(), 6 * 8 * 8 * 4 * 4);
let p0 = f32::from_le_bytes(face_bytes[0..4].try_into().unwrap());
let p1 = f32::from_le_bytes(face_bytes[4..8].try_into().unwrap());
let p2 = f32::from_le_bytes(face_bytes[8..12].try_into().unwrap());
let p3 = f32::from_le_bytes(face_bytes[12..16].try_into().unwrap());
assert!((p0 - pixel[0]).abs() < 1e-4);
assert!((p1 - pixel[1]).abs() < 1e-4);
assert!((p2 - pixel[2]).abs() < 1e-4);
assert!((p3 - 1.0).abs() < 1e-6);
}
#[test]
fn validate_cubemap_args_rejects_bad_face_size() {
let args = serde_json::json!({ "source": "foo.hdr", "face_size": 300 });
let err = validate_cubemap_args(&args).unwrap_err();
assert!(err.contains("power of two"), "got: {}", err);
}
#[test]
fn validate_cubemap_args_requires_hdr_extension() {
let args = serde_json::json!({ "source": "foo.png" });
let err = validate_cubemap_args(&args).unwrap_err();
assert!(err.contains(".hdr"), "got: {}", err);
}
#[test]
fn validate_cubemap_args_accepts_defaults() {
let args = serde_json::json!({ "source": "studio.hdr" });
validate_cubemap_args(&args).expect("defaults should validate");
}
#[test]
fn validate_cubemap_args_requires_a_source() {
let err = validate_cubemap_args(&serde_json::json!({})).unwrap_err();
assert!(err.contains("requires a `source`"), "got: {err}");
}
#[test]
fn validate_cubemap_args_rejects_out_of_range_face_size() {
let args = serde_json::json!({ "source": "studio.hdr", "face_size": 4 });
let err = validate_cubemap_args(&args).unwrap_err();
assert!(err.contains("out of range"), "got: {err}");
}
#[test]
fn compile_cubemap_payload_surfaces_a_missing_hdr() {
let args = serde_json::json!({ "source": "/no/such/dir/missing.hdr", "face_size": 8 });
let err = compile_cubemap_payload(&args).unwrap_err();
assert!(err.contains("failed to open HDR"), "got: {err}");
}
fn write_hdr(dir: &tempfile::TempDir, name: &str, bytes: &[u8]) -> String {
let path = dir.path().join(name);
std::fs::write(&path, bytes).expect("write hdr");
path.to_string_lossy().into_owned()
}
#[test]
fn compile_cubemap_payload_resamples_a_source_hdr_into_six_faces() {
let dir = tempfile::tempdir().expect("tempdir");
let rgb = [0.5f32, 0.25, 0.125];
let src = write_hdr(
&dir,
"studio.hdr",
&crate::codec::hdr::test_fixtures::solid_hdr_blob(16, 8, rgb),
);
let args = serde_json::json!({ "source": src, "face_size": 8 });
let payload = compile_cubemap_payload(&args).expect("compile");
let (face_size, face_bytes) = deserialise(&payload).expect("deserialise");
assert_eq!(face_size, 8);
assert_eq!(face_bytes.len(), 6 * 8 * 8 * 4 * 4);
for (i, texel) in face_bytes.chunks_exact(4).enumerate() {
let v = f32::from_le_bytes(texel.try_into().unwrap());
let want = if i % 4 == 3 { 1.0 } else { rgb[i % 4] };
assert!((v - want).abs() < 0.01, "float {i} was {v}, want {want}");
}
}
#[test]
fn compile_cubemap_payload_surfaces_a_corrupt_hdr() {
let dir = tempfile::tempdir().expect("tempdir");
let src = write_hdr(&dir, "broken.hdr", b"not a radiance file\n");
let args = serde_json::json!({ "source": src, "face_size": 8 });
let err = compile_cubemap_payload(&args).unwrap_err();
assert!(err.contains("failed to decode HDR"), "got: {err}");
assert!(err.contains("missing Radiance magic"), "got: {err}");
}
}