#[cfg(test)]
use concinnity_core::decode::{ByteReader, checked_product};
pub(crate) const CUBE_PAYLOAD_MAGIC: u32 = u32::from_le_bytes(*b"CUBE");
pub(crate) const CUBE_FORMAT_RGBA32F: u32 = 0;
pub(crate) const CUBE_PAYLOAD_HEADER_BYTES: usize = 16;
#[cfg(test)]
pub(crate) fn deserialise(bytes: &[u8]) -> Result<(u32, &[u8]), String> {
let mut r = ByteReader::open_payload(
bytes,
CUBE_PAYLOAD_MAGIC,
CUBE_PAYLOAD_HEADER_BYTES,
"cubemap",
)?;
let face_size = r.u32()?;
let mip_count = r.u32()?;
let format_id = r.u32()?;
if mip_count != 1 {
return Err(format!(
"cubemap payload mip_count {} unsupported (only single-mip supported today)",
mip_count
));
}
if format_id != CUBE_FORMAT_RGBA32F {
return Err(format!(
"cubemap payload format_id {} unsupported (only RGBA32F supported today)",
format_id
));
}
let cube_bytes = checked_product(
"cubemap",
&[6, face_size as usize, face_size as usize, 4, 4],
)?;
r.seek(CUBE_PAYLOAD_HEADER_BYTES)?;
let faces = r.take(cube_bytes).map_err(|_| {
format!(
"cubemap payload too short for face_size {}: need {} bytes, got {}",
face_size,
CUBE_PAYLOAD_HEADER_BYTES + cube_bytes,
bytes.len()
)
})?;
Ok((face_size, faces))
}
pub(crate) fn load_file(path: &str) -> Result<HdrImage, String> {
use std::io::Read;
let mut file = std::fs::File::open(path)
.map_err(|e| format!("failed to open HDR source '{}': {}", path, e))?;
let mut bytes = Vec::new();
file.read_to_end(&mut bytes)
.map_err(|e| format!("failed to read HDR source '{}': {}", path, e))?;
decode_hdr(&bytes).map_err(|e| format!("failed to decode HDR '{}': {}", path, e))
}
pub(crate) use concinnity_core::bake::environment_map::source::{HdrImage, equirect_to_cube};
pub(crate) fn decode_hdr(bytes: &[u8]) -> Result<HdrImage, String> {
let mut cursor = 0usize;
let magic = read_line(bytes, &mut cursor)?;
if !(magic.starts_with("#?RADIANCE") || magic.starts_with("#?RGBE")) {
return Err(format!("missing Radiance magic header, got {:?}", magic));
}
let mut format_seen = false;
loop {
let line = read_line(bytes, &mut cursor)?;
if line.is_empty() {
break;
}
if let Some(rest) = line.strip_prefix("FORMAT=") {
format_seen = true;
if rest.trim() != "32-bit_rle_rgbe" {
return Err(format!("unsupported Radiance FORMAT {:?}", rest));
}
}
}
if !format_seen {
return Err("Radiance header missing FORMAT line".into());
}
let res_line = read_line(bytes, &mut cursor)?;
let (width, height, flip_y, flip_x) = parse_resolution(&res_line)?;
let mut pixels = vec![[0.0f32, 0.0, 0.0]; (width as usize) * (height as usize)];
let mut scanline = vec![0u8; (width as usize) * 4];
for scan_y in 0..height as usize {
read_scanline(bytes, &mut cursor, &mut scanline, width)?;
let dst_y = if flip_y {
height as usize - 1 - scan_y
} else {
scan_y
};
for x in 0..width as usize {
let src_x = if flip_x { width as usize - 1 - x } else { x };
let off = src_x * 4;
let r = scanline[off];
let g = scanline[off + 1];
let b = scanline[off + 2];
let e = scanline[off + 3];
pixels[dst_y * width as usize + x] = rgbe_to_float(r, g, b, e);
}
}
Ok(HdrImage {
width,
height,
pixels,
})
}
fn read_line(bytes: &[u8], cursor: &mut usize) -> Result<String, String> {
let start = *cursor;
while *cursor < bytes.len() && bytes[*cursor] != b'\n' {
*cursor += 1;
}
if *cursor >= bytes.len() {
return Err("unexpected end of HDR header".into());
}
let line = std::str::from_utf8(&bytes[start..*cursor])
.map_err(|_| "non-UTF8 in HDR header".to_string())?
.trim_end_matches('\r')
.to_string();
*cursor += 1;
Ok(line)
}
fn parse_resolution(line: &str) -> Result<(u32, u32, bool, bool), String> {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() != 4 {
return Err(format!("malformed Radiance resolution line {:?}", line));
}
let parse_axis = |tag: &str, val: &str| -> Result<u32, String> {
if tag.len() != 2 {
return Err(format!("bad axis tag {:?}", tag));
}
val.parse::<u32>()
.map_err(|e| format!("bad axis value {:?}: {}", val, e))
};
let (y_tag, y_val, x_tag, x_val) = (parts[0], parts[1], parts[2], parts[3]);
if !(y_tag.ends_with('Y') && x_tag.ends_with('X')) {
return Err(format!("unsupported Radiance orientation {:?}", line));
}
let height = parse_axis(y_tag, y_val)?;
let width = parse_axis(x_tag, x_val)?;
let flip_y = y_tag.starts_with('+');
let flip_x = x_tag.starts_with('-');
Ok((width, height, flip_y, flip_x))
}
fn read_scanline(
bytes: &[u8],
cursor: &mut usize,
out: &mut [u8],
width: u32,
) -> Result<(), String> {
if (width as usize) * 4 != out.len() {
return Err("scanline buffer size mismatch".into());
}
if *cursor + 4 > bytes.len() {
return Err("unexpected end of HDR pixel data".into());
}
let rle_marker =
bytes[*cursor] == 0x02 && bytes[*cursor + 1] == 0x02 && (bytes[*cursor + 2] & 0x80) == 0;
let rle_width = if rle_marker {
((bytes[*cursor + 2] as u32) << 8) | bytes[*cursor + 3] as u32
} else {
0
};
if rle_marker && rle_width == width && (8..=0x7fff).contains(&width) {
*cursor += 4;
for ch in 0..4usize {
let mut written = 0usize;
while written < width as usize {
if *cursor >= bytes.len() {
return Err("RLE scanline truncated".into());
}
let lead = bytes[*cursor];
*cursor += 1;
if lead > 128 {
let run = (lead - 128) as usize;
if written + run > width as usize {
return Err("RLE run overruns scanline".into());
}
if *cursor >= bytes.len() {
return Err("RLE run byte missing".into());
}
let val = bytes[*cursor];
*cursor += 1;
for _ in 0..run {
out[written * 4 + ch] = val;
written += 1;
}
} else {
let run = lead as usize;
if written + run > width as usize {
return Err("RLE literal overruns scanline".into());
}
if *cursor + run > bytes.len() {
return Err("RLE literal truncated".into());
}
for i in 0..run {
out[(written + i) * 4 + ch] = bytes[*cursor + i];
}
*cursor += run;
written += run;
}
}
}
Ok(())
} else {
let needed = (width as usize) * 4;
if *cursor + needed > bytes.len() {
return Err("raw scanline truncated".into());
}
out.copy_from_slice(&bytes[*cursor..*cursor + needed]);
*cursor += needed;
Ok(())
}
}
fn rgbe_to_float(r: u8, g: u8, b: u8, e: u8) -> [f32; 3] {
if e == 0 {
return [0.0, 0.0, 0.0];
}
let scale = (2.0f32).powi(e as i32 - 128 - 8);
[
(r as f32 + 0.5) * scale,
(g as f32 + 0.5) * scale,
(b as f32 + 0.5) * scale,
]
}
#[cfg(test)]
pub(crate) mod test_fixtures {
pub(crate) fn solid_hdr_blob(width: u32, height: u32, rgb: [f32; 3]) -> Vec<u8> {
let maxv = rgb[0].max(rgb[1]).max(rgb[2]);
let raw_exp = ((maxv.to_bits() >> 23) & 0xff) as i32;
let mantissa = f32::from_bits((maxv.to_bits() & 0x7f_ffff) | (126 << 23));
let scale = (mantissa * 256.0) / maxv;
let pixel = [
(rgb[0] * scale) as u8,
(rgb[1] * scale) as u8,
(rgb[2] * scale) as u8,
(raw_exp - 126 + 128) as u8,
];
let mut blob = Vec::new();
blob.extend_from_slice(b"#?RADIANCE\nFORMAT=32-bit_rle_rgbe\n\n");
blob.extend_from_slice(format!("-Y {} +X {}\n", height, width).as_bytes());
for _ in 0..(width * height) {
blob.extend_from_slice(&pixel);
}
blob
}
}
#[cfg(test)]
mod tests {
use super::*;
fn synth_rgbe_one_pixel(r: f32, g: f32, b: f32) -> [u8; 4] {
let maxv = r.max(g).max(b);
if maxv < 1e-32 {
return [0, 0, 0, 0];
}
let (mantissa, exp) = frexp_f32(maxv);
let scale = (mantissa * 256.0) / maxv;
[
(r * scale) as u8,
(g * scale) as u8,
(b * scale) as u8,
(exp + 128) as u8,
]
}
fn frexp_f32(x: f32) -> (f32, i32) {
let bits = x.to_bits();
let raw_exp = ((bits >> 23) & 0xff) as i32;
let exp = raw_exp - 126;
let mantissa_bits = (bits & 0x7f_ffff) | (126 << 23);
let mantissa = f32::from_bits(mantissa_bits);
(mantissa, exp)
}
fn raw_hdr_blob(width: u32, height: u32, pixels: &[[u8; 4]]) -> Vec<u8> {
let mut blob = Vec::new();
blob.extend_from_slice(b"#?RADIANCE\n");
blob.extend_from_slice(b"FORMAT=32-bit_rle_rgbe\n\n");
blob.extend_from_slice(format!("-Y {} +X {}\n", height, width).as_bytes());
for p in pixels {
blob.extend_from_slice(p);
}
blob
}
#[test]
fn decode_hdr_solid_color_old_format() {
let pixel = synth_rgbe_one_pixel(1.0, 0.5, 0.25);
let pixels: Vec<[u8; 4]> = std::iter::repeat_n(pixel, 4 * 2).collect();
let blob = raw_hdr_blob(4, 2, &pixels);
let img = decode_hdr(&blob).expect("decode");
assert_eq!(img.width, 4);
assert_eq!(img.height, 2);
for p in &img.pixels {
assert!((p[0] - 1.0).abs() < 0.02, "R was {}", p[0]);
assert!((p[1] - 0.5).abs() < 0.02, "G was {}", p[1]);
assert!((p[2] - 0.25).abs() < 0.02, "B was {}", p[2]);
}
}
#[test]
fn decode_hdr_rejects_bad_magic() {
let blob = b"#?NOTHDR\nFORMAT=32-bit_rle_rgbe\n\n-Y 1 +X 1\n\x00\x00\x00\x00".to_vec();
let err = decode_hdr(&blob).unwrap_err();
assert!(err.contains("magic"), "got: {}", err);
}
fn cube_payload(face_size: u32, mip_count: u32, format_id: u32, faces: usize) -> Vec<u8> {
let mut v = Vec::new();
v.extend_from_slice(&CUBE_PAYLOAD_MAGIC.to_le_bytes());
v.extend_from_slice(&face_size.to_le_bytes());
v.extend_from_slice(&mip_count.to_le_bytes());
v.extend_from_slice(&format_id.to_le_bytes());
let face_bytes = (face_size as usize) * (face_size as usize) * 16;
v.resize(CUBE_PAYLOAD_HEADER_BYTES + faces * face_bytes, 0);
v
}
#[test]
fn deserialise_rejects_a_payload_shorter_than_the_header() {
let err = deserialise(&[0u8; 8]).unwrap_err();
assert_eq!(
err,
"cubemap payload too short: 8 bytes (need at least 16 for header)"
);
}
#[test]
fn deserialise_rejects_a_foreign_magic() {
let mut v = cube_payload(2, 1, CUBE_FORMAT_RGBA32F, 6);
v[0..4].copy_from_slice(&0xdead_beefu32.to_le_bytes());
let err = deserialise(&v).unwrap_err();
assert!(err.contains("0xdeadbeef"), "got: {err}");
}
#[test]
fn deserialise_rejects_a_multi_mip_payload() {
let err = deserialise(&cube_payload(2, 2, CUBE_FORMAT_RGBA32F, 6)).unwrap_err();
assert!(err.contains("mip_count 2"), "got: {err}");
}
#[test]
fn deserialise_rejects_an_unknown_format_id() {
let err = deserialise(&cube_payload(2, 1, 7, 6)).unwrap_err();
assert!(err.contains("format_id 7"), "got: {err}");
}
#[test]
fn deserialise_rejects_a_payload_missing_faces() {
let err = deserialise(&cube_payload(4, 1, CUBE_FORMAT_RGBA32F, 4)).unwrap_err();
assert_eq!(
err,
"cubemap payload too short for face_size 4: need 1552 bytes, got 1040"
);
}
#[test]
fn deserialise_rejects_a_face_size_that_overflows_its_footprint() {
let mut v = CUBE_PAYLOAD_MAGIC.to_le_bytes().to_vec();
v.extend_from_slice(&u32::MAX.to_le_bytes());
v.extend_from_slice(&1u32.to_le_bytes());
v.extend_from_slice(&CUBE_FORMAT_RGBA32F.to_le_bytes());
let err = deserialise(&v).unwrap_err();
assert!(err.contains("overflow"), "got: {err}");
}
#[test]
fn decode_hdr_rejects_an_unsupported_format_line() {
let blob = b"#?RADIANCE\nFORMAT=32-bit_rle_xyze\n\n-Y 1 +X 1\n\x00\x00\x00\x00".to_vec();
let err = decode_hdr(&blob).unwrap_err();
assert_eq!(err, "unsupported Radiance FORMAT \"32-bit_rle_xyze\"");
}
#[test]
fn decode_hdr_requires_a_format_line() {
let blob = b"#?RADIANCE\nEXPOSURE=1.0\n\n-Y 1 +X 1\n\x00\x00\x00\x00".to_vec();
let err = decode_hdr(&blob).unwrap_err();
assert_eq!(err, "Radiance header missing FORMAT line");
}
#[test]
fn decode_hdr_rejects_a_header_with_no_line_terminator() {
let err = decode_hdr(b"#?RADIANCE").unwrap_err();
assert_eq!(err, "unexpected end of HDR header");
}
#[test]
fn decode_hdr_rejects_a_header_with_no_blank_terminator() {
let err = decode_hdr(b"#?RADIANCE\nFORMAT=32-bit_rle_rgbe\n").unwrap_err();
assert_eq!(err, "unexpected end of HDR header");
}
#[test]
fn decode_hdr_rejects_a_header_with_no_resolution_line() {
let err = decode_hdr(b"#?RADIANCE\nFORMAT=32-bit_rle_rgbe\n\n").unwrap_err();
assert_eq!(err, "unexpected end of HDR header");
}
#[test]
fn decode_hdr_rejects_a_non_utf8_header_line() {
let err = decode_hdr(b"#?RADIANCE\n\xff\xfe\n\n-Y 1 +X 1\n").unwrap_err();
assert_eq!(err, "non-UTF8 in HDR header");
}
#[test]
fn decode_hdr_rejects_a_non_numeric_width() {
let blob = b"#?RADIANCE\nFORMAT=32-bit_rle_rgbe\n\n-Y 1 +X wide\n".to_vec();
let err = decode_hdr(&blob).unwrap_err();
assert!(err.starts_with("bad axis value \"wide\""), "got: {err}");
}
#[test]
fn decode_hdr_rejects_a_malformed_resolution_line() {
let blob = b"#?RADIANCE\nFORMAT=32-bit_rle_rgbe\n\n-Y 1\n".to_vec();
let err = decode_hdr(&blob).unwrap_err();
assert_eq!(err, "malformed Radiance resolution line \"-Y 1\"");
}
#[test]
fn decode_hdr_rejects_an_x_before_y_resolution_line() {
let blob = b"#?RADIANCE\nFORMAT=32-bit_rle_rgbe\n\n+X 1 -Y 1\n".to_vec();
let err = decode_hdr(&blob).unwrap_err();
assert_eq!(err, "unsupported Radiance orientation \"+X 1 -Y 1\"");
}
#[test]
fn decode_hdr_rejects_an_over_long_axis_tag() {
let blob = b"#?RADIANCE\nFORMAT=32-bit_rle_rgbe\n\n--Y 1 +X 1\n".to_vec();
let err = decode_hdr(&blob).unwrap_err();
assert_eq!(err, "bad axis tag \"--Y\"");
}
#[test]
fn decode_hdr_rejects_a_non_numeric_axis_value() {
let blob = b"#?RADIANCE\nFORMAT=32-bit_rle_rgbe\n\n-Y tall +X 1\n".to_vec();
let err = decode_hdr(&blob).unwrap_err();
assert!(err.starts_with("bad axis value \"tall\""), "got: {err}");
}
#[test]
fn plus_y_stores_scanlines_bottom_up() {
let dark = synth_rgbe_one_pixel(0.25, 0.25, 0.25);
let bright = synth_rgbe_one_pixel(4.0, 4.0, 4.0);
let mut blob = Vec::new();
blob.extend_from_slice(b"#?RADIANCE\nFORMAT=32-bit_rle_rgbe\n\n+Y 2 +X 1\n");
blob.extend_from_slice(&dark);
blob.extend_from_slice(&bright);
let img = decode_hdr(&blob).expect("decode");
assert!(img.pixels[0][0] > 2.0, "got {}", img.pixels[0][0]);
assert!(img.pixels[1][0] < 1.0, "got {}", img.pixels[1][0]);
}
#[test]
fn minus_x_stores_pixels_right_to_left() {
let dark = synth_rgbe_one_pixel(0.25, 0.25, 0.25);
let bright = synth_rgbe_one_pixel(4.0, 4.0, 4.0);
let mut blob = Vec::new();
blob.extend_from_slice(b"#?RADIANCE\nFORMAT=32-bit_rle_rgbe\n\n-Y 1 -X 2\n");
blob.extend_from_slice(&dark);
blob.extend_from_slice(&bright);
let img = decode_hdr(&blob).expect("decode");
assert!(img.pixels[0][0] > 2.0, "got {}", img.pixels[0][0]);
assert!(img.pixels[1][0] < 1.0, "got {}", img.pixels[1][0]);
}
#[test]
fn a_zero_exponent_pixel_decodes_to_black() {
let black = synth_rgbe_one_pixel(0.0, 0.0, 0.0);
assert_eq!(black, [0, 0, 0, 0]);
let img = decode_hdr(&raw_hdr_blob(1, 1, &[black])).expect("decode");
assert_eq!(img.pixels[0], [0.0, 0.0, 0.0]);
assert_eq!(rgbe_to_float(200, 100, 50, 0), [0.0, 0.0, 0.0]);
}
fn rle_hdr_blob(width: u32, channels: [Vec<u8>; 4]) -> Vec<u8> {
let mut blob = Vec::new();
blob.extend_from_slice(b"#?RADIANCE\nFORMAT=32-bit_rle_rgbe\n\n");
blob.extend_from_slice(format!("-Y 1 +X {}\n", width).as_bytes());
blob.extend_from_slice(&[0x02, 0x02, (width >> 8) as u8, (width & 0xFF) as u8]);
for packets in channels {
blob.extend_from_slice(&packets);
}
blob
}
fn run_packet(count: u8, value: u8) -> Vec<u8> {
vec![128 + count, value]
}
#[test]
fn rle_run_packets_fill_a_whole_scanline() {
let blob = rle_hdr_blob(
8,
[
run_packet(8, 200),
run_packet(8, 100),
run_packet(8, 50),
run_packet(8, 136),
],
);
let img = decode_hdr(&blob).expect("decode");
assert_eq!(img.width, 8);
for p in &img.pixels {
assert_eq!(*p, [200.5, 100.5, 50.5]);
}
}
#[test]
fn rle_literal_packets_copy_per_pixel_values() {
let literal = |base: u8| {
let mut v = vec![8u8];
v.extend((0..8u8).map(|i| base + i));
v
};
let blob = rle_hdr_blob(
8,
[literal(0), literal(10), literal(20), run_packet(8, 136)],
);
let img = decode_hdr(&blob).expect("decode");
for (i, p) in img.pixels.iter().enumerate() {
let i = i as f32;
assert_eq!(*p, [i + 0.5, i + 10.5, i + 20.5], "pixel {i}");
}
}
#[test]
fn rejects_an_rle_scanline_that_stops_mid_channel() {
let blob = rle_hdr_blob(8, [run_packet(8, 1), Vec::new(), Vec::new(), Vec::new()]);
let err = decode_hdr(&blob).unwrap_err();
assert_eq!(err, "RLE scanline truncated");
}
#[test]
fn rejects_an_rle_run_longer_than_the_scanline() {
let blob = rle_hdr_blob(8, [run_packet(9, 1), Vec::new(), Vec::new(), Vec::new()]);
let err = decode_hdr(&blob).unwrap_err();
assert_eq!(err, "RLE run overruns scanline");
}
#[test]
fn rejects_an_rle_run_with_no_value_byte() {
let blob = rle_hdr_blob(8, [vec![128 + 8], Vec::new(), Vec::new(), Vec::new()]);
let err = decode_hdr(&blob).unwrap_err();
assert_eq!(err, "RLE run byte missing");
}
#[test]
fn rejects_an_rle_literal_longer_than_the_scanline() {
let mut packet = vec![9u8];
packet.extend([0u8; 9]);
let blob = rle_hdr_blob(8, [packet, Vec::new(), Vec::new(), Vec::new()]);
let err = decode_hdr(&blob).unwrap_err();
assert_eq!(err, "RLE literal overruns scanline");
}
#[test]
fn rejects_a_truncated_rle_literal_packet() {
let blob = rle_hdr_blob(8, [vec![8, 1, 2, 3], Vec::new(), Vec::new(), Vec::new()]);
let err = decode_hdr(&blob).unwrap_err();
assert_eq!(err, "RLE literal truncated");
}
#[test]
fn rejects_a_truncated_raw_scanline() {
let blob = b"#?RADIANCE\nFORMAT=32-bit_rle_rgbe\n\n-Y 1 +X 4\n\x01\x02\x03\x04".to_vec();
let err = decode_hdr(&blob).unwrap_err();
assert_eq!(err, "raw scanline truncated");
}
#[test]
fn rejects_a_scanline_header_past_the_end_of_the_file() {
let blob = b"#?RADIANCE\nFORMAT=32-bit_rle_rgbe\n\n-Y 1 +X 1\n\x00\x00".to_vec();
let err = decode_hdr(&blob).unwrap_err();
assert_eq!(err, "unexpected end of HDR pixel data");
}
#[test]
fn read_scanline_rejects_a_buffer_that_does_not_match_the_width() {
let mut cursor = 0usize;
let mut out = [0u8; 8];
let err = read_scanline(&[0u8; 32], &mut cursor, &mut out, 4).unwrap_err();
assert_eq!(err, "scanline buffer size mismatch");
}
}