diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 51eb9c5e..e5a76862 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -86,6 +86,16 @@ Generated-in-code assets are original and have no file to list: | LICENSE-MIT, LICENSE-APACHE (licence text) | CADCraft | — | | .github/workflows/release.yml, .github/workflows/packaging-lint.yml and packaging/ (release pipeline: Linux AppImage/deb/rpm/Flatpak/tar.gz, FreeBSD, macOS dmg, Windows MSI/portable zip, web zip; AppRun, nfpm, Flatpak, WiX and Info.plist templates) | PhotoCraft (https://github.com/storytold/photocraft) `.github/workflows/{release,packaging-lint}.yml`, `packaging/` | Apache-2.0 | +## Incremental HDR codec adapter + +`crates/render/src/lighting/hdr.rs` adapts the scanline/component/old-RLE decoding +state machines from the Radiance decoder in `image` 0.25.10 +([source](https://docs.rs/crate/image/0.25.10/source/src/codecs/hdr/decoder.rs)), +MIT OR Apache-2.0. Metadata parsing still uses that library. The adapter makes row +work cooperative and adds checked run lengths, zero-run rejection, width validation, +header/input/radiance bounds and cancellation. Original licence texts are retained +in THIRD-PARTY-LICENSES.txt; no images or artwork from that project are copied. + ## Recovered contributions | SolidCraft file | Original contribution | Licence | diff --git a/Cargo.toml b/Cargo.toml index 292c303b..06ee804b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,7 +51,7 @@ quick-xml = "0.41" ttf-parser = { version = "0.25", default-features = false, features = ["std"] } # images -image = { version = "0.25", default-features = false, features = ["png"] } +image = { version = "0.25", default-features = false, features = ["png", "hdr"] } # ui egui = "0.36" diff --git a/NOTICE b/NOTICE index af0676c1..4ff0526b 100644 --- a/NOTICE +++ b/NOTICE @@ -19,6 +19,11 @@ licensed MIT OR Apache-2.0; ATTRIBUTION.md lists the files. The release packaging (.github/workflows/release.yml, packaging/) is adapted from PhotoCraft (https://github.com/storytold/photocraft), licensed under the Apache License 2.0. +The incremental Radiance RGBE row adapter in crates/render/src/lighting/hdr.rs adapts +decoding state machines from image 0.25.10 (https://github.com/image-rs/image), +MIT OR Apache-2.0. ATTRIBUTION.md describes the bounded/cancellable changes; the +original licence texts are included in THIRD-PARTY-LICENSES.txt. + Most bundled assets are drawn in code or contributed originally; third-party fonts (Liberation Sans for sketch text, BIZ UDPGothic for web Japanese display) are licensed under SIL OFL 1.1. The per-file list is in ATTRIBUTION.md. diff --git a/ROADMAP.md b/ROADMAP.md index 089e0255..30e4aff8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -61,6 +61,9 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings. ## Render foundation +- A bounded cooperative Radiance RGBE input utility is available in L1 render; + it does not yet enable HDRI/PBR rendering or a document import command. See + [the input contract](docs/render-hdr-input.md) and #813 for integration work. - R1 adds renderer-independent backend/profile metadata, strict request resolution and bounded image/scratch admission checks, exposed by the read-only `render.backends` command. It describes the current CPU raster snapshot path without evaluating the model. diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 9329525e..cb9d89cd 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -7,6 +7,7 @@ mod backend; mod camera; mod curve_display; +pub mod lighting; mod raster; pub use backend::{ diff --git a/crates/render/src/lighting/hdr.rs b/crates/render/src/lighting/hdr.rs new file mode 100644 index 00000000..3d245375 --- /dev/null +++ b/crates/render/src/lighting/hdr.rs @@ -0,0 +1,305 @@ +//! Incremental RGBE scanline adapter for image 0.25.10's Radiance codec. +//! Row/RLE handling is adapted from its `read_scanline`, `decode_component` and +//! `decode_old_rle` (MIT OR Apache-2.0; see ATTRIBUTION.md and NOTICE). Metadata still +//! uses the library. Changes add cooperative steps, checked runs, width validation +//! and bounded radiance; no third-party asserts or input indexing are retained. + +use super::{HdrError, HdrErrorCode as ErrorCode, Result, check_cancelled}; +use serde::Serialize; +use std::{io::Cursor, sync::Arc}; + +pub const MAX_HDR_BYTES: usize = 16 * 1024 * 1024; +pub const MAX_HDR_WIDTH: u32 = 2048; +pub const MAX_HDR_HEIGHT: u32 = 1024; +pub const MAX_HDR_PIXELS: u64 = 2 * 1024 * 1024; +const MAX_HEADER_BYTES: usize = 64 * 1024; +const MAX_RADIANCE: f64 = 1e8; + +fn invalid(message: impl Into) -> HdrError { + HdrError::new(ErrorCode::InvalidInput, message) +} +fn limit(message: impl Into) -> HdrError { + HdrError::new(ErrorCode::LimitExceeded, message) +} + +#[derive(Clone, Debug, Serialize)] +pub struct HdrInfo { + pub width: u32, + pub height: u32, + pub encoded_bytes: u64, + pub decoded_rgb_bytes: u64, + /// Stored RGBE values are divided by these declared header corrections. + pub exposure: f64, + pub color_correction: [f64; 3], + /// Source chromaticities (Rx Ry Gx Gy Bx By Wx Wy); absent means unspecified. + pub primaries: Option<[f64; 8]>, + #[serde(skip)] + offset: usize, +} + +/// Immutable input. Construction only inspects a bounded header; `validate` also checks +/// every encoded row and corrected radiance before an import commits it to a document. +#[derive(Clone)] +pub struct HdrSource { + bytes: Arc>, + info: HdrInfo, +} + +impl HdrSource { + pub fn new(bytes: Vec) -> Result { + if bytes.is_empty() || bytes.len() > MAX_HDR_BYTES || bytes.capacity() > MAX_HDR_BYTES { + return Err(limit("HDR input must contain at most 16 MiB")); + } + let mut cursor = Cursor::new(bytes.get(..bytes.len().min(MAX_HEADER_BYTES)).ok_or_else(|| invalid("invalid HDR header range"))?); + let metadata = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + image::codecs::hdr::HdrDecoder::new(&mut cursor).map(|decoder| decoder.metadata()) + })) + .map_err(|_| invalid("HDR metadata decoder failed"))? + .map_err(|e| invalid(format!("invalid or oversized Radiance header: {e}")))?; + let pixels = u64::from(metadata.width).checked_mul(u64::from(metadata.height)).ok_or_else(|| limit("HDR dimensions overflow"))?; + if metadata.width == 0 + || metadata.height == 0 + || metadata.width > MAX_HDR_WIDTH + || metadata.height > MAX_HDR_HEIGHT + || pixels > MAX_HDR_PIXELS + { + return Err(limit("HDR preview input exceeds 2048x1024 / 2 million pixels")); + } + if metadata.width != metadata.height.saturating_mul(2) || metadata.orientation != ((1, 0), (0, 1)) { + return Err(invalid("HDR preview requires a 2:1 equirectangular image with standard -Y +X orientation")); + } + let exposure = f64::from(metadata.exposure.unwrap_or(1.0)); + let (r, g, b) = metadata.color_correction.unwrap_or((1.0, 1.0, 1.0)); + let correction = [f64::from(r), f64::from(g), f64::from(b)]; + if [exposure].into_iter().chain(correction).any(|x| !x.is_finite() || x <= 0.0) + || metadata.pixel_aspect_ratio.is_some_and(|x| !x.is_finite() || (x - 1.0).abs() > 1e-6) + { + return Err(invalid("HDR exposure, colour correction and square-pixel metadata must be finite and positive")); + } + let offset = usize::try_from(cursor.position()).map_err(|_| invalid("HDR data offset overflow"))?; + if offset.checked_add(4).is_none_or(|end| end > bytes.len()) { + return Err(invalid("HDR input has no pixel data")); + } + let mut primaries = None; + for (key, value) in &metadata.custom_attributes { + if key == "PRIMARIES" { + if primaries.is_some() { + return Err(invalid("duplicate HDR primaries")); + } + let mut parsed = [0.0; 8]; + let mut values = value.split_whitespace(); + for out in &mut parsed { + *out = values + .next() + .ok_or_else(|| invalid("missing HDR chromaticity"))? + .parse::() + .map_err(|_| invalid("invalid HDR chromaticity"))?; + if !out.is_finite() || !(0.0..=1.0).contains(out) { + return Err(invalid("invalid HDR chromaticity")); + } + } + if values.next().is_some() { + return Err(invalid("extra HDR chromaticity")); + } + primaries = Some(parsed); + } + } + let info = HdrInfo { + width: metadata.width, + height: metadata.height, + encoded_bytes: bytes.len() as u64, + decoded_rgb_bytes: pixels.checked_mul(12).ok_or_else(|| limit("HDR decoded size overflow"))?, + exposure, + color_correction: correction, + primaries, + offset, + }; + Ok(Self { bytes: Arc::new(bytes), info }) + } + pub fn info(&self) -> &HdrInfo { + &self.info + } + pub fn encoded_bytes(&self) -> &[u8] { + &self.bytes + } + pub fn validate(&self, cancelled: impl Fn() -> bool) -> Result<()> { + check_cancelled(&cancelled)?; + let mut rows = Rows::new(self.clone())?; + let mut steps = 0u64; + while !rows.done() { + check_cancelled(&cancelled)?; + if rows.ready() { + for x in 0..self.info.width as usize { + check_cancelled(&cancelled)?; + rows.linear(x)?; + } + rows.next_row()?; + } else { + rows.step()?; + } + steps = steps.checked_add(1).ok_or_else(|| limit("HDR decode work overflow"))?; + if steps > MAX_HDR_PIXELS * 6 + 4096 { + return Err(limit("HDR decode work budget exceeded")); + } + } + Ok(()) + } +} + +#[cfg(test)] +#[path = "hdr_tests.rs"] +mod tests; + +enum Stage { + Start, + Channel { channel: usize, x: usize }, + Old { x: usize, previous: [u8; 4], shift: u32, repeat: usize }, + Ready, + Done, +} + +pub(super) struct Rows { + source: HdrSource, + offset: usize, + row: u32, + pixels: Vec<[u8; 4]>, + scales: [f64; 256], + stage: Stage, +} + +impl Rows { + pub(super) fn new(source: HdrSource) -> Result { + let mut pixels = Vec::new(); + pixels + .try_reserve_exact(source.info.width as usize) + .map_err(|_| HdrError::new(ErrorCode::AllocationFailed, "could not reserve HDR scanline"))?; + pixels.resize(source.info.width as usize, [0; 4]); + Ok(Self { + offset: source.info.offset, + source, + row: 0, + pixels, + scales: std::array::from_fn(|e| 2f64.powi(e as i32 - 136)), + stage: Stage::Start, + }) + } + pub(super) fn info(&self) -> &HdrInfo { + &self.source.info + } + #[cfg(test)] + pub(super) fn row(&self) -> u32 { + self.row + } + pub(super) fn ready(&self) -> bool { + matches!(self.stage, Stage::Ready) + } + pub(super) fn done(&self) -> bool { + matches!(self.stage, Stage::Done) + } + fn byte(&mut self) -> Result { + let b = self.source.bytes.get(self.offset).copied().ok_or_else(|| invalid("truncated HDR scanline"))?; + self.offset = self.offset.checked_add(1).ok_or_else(|| invalid("HDR offset overflow"))?; + Ok(b) + } + fn pixel(&mut self) -> Result<[u8; 4]> { + Ok([self.byte()?, self.byte()?, self.byte()?, self.byte()?]) + } + fn put(&mut self, x: usize, pixel: [u8; 4]) -> Result<()> { + *self.pixels.get_mut(x).ok_or_else(|| invalid("HDR run exceeds its row"))? = pixel; + Ok(()) + } + /// One packet (at most 128 component writes), or one old-style repeated pixel. + pub(super) fn step(&mut self) -> Result<()> { + let width = self.pixels.len(); + let stage = std::mem::replace(&mut self.stage, Stage::Done); + self.stage = match stage { + Stage::Start => { + let first = self.pixel()?; + if first.get(..2) == Some(&[2, 2]) && first.get(2).is_some_and(|v| *v < 128) { + let declared = usize::from(*first.get(2).ok_or_else(|| invalid("missing HDR width"))?) * 256 + + usize::from(*first.get(3).ok_or_else(|| invalid("missing HDR width"))?); + if declared != width { + return Err(invalid("HDR scanline width differs from its header")); + } + Stage::Channel { channel: 0, x: 0 } + } else { + if first.get(..3) == Some(&[1, 1, 1]) { + return Err(invalid("HDR row starts with an old run marker")); + } + self.put(0, first)?; + if width == 1 { Stage::Ready } else { Stage::Old { x: 1, previous: first, shift: 0, repeat: 0 } } + } + } + Stage::Channel { channel, x } => { + let code = self.byte()?; + if code == 0 { + return Err(invalid("zero-length HDR RLE packet")); + } + let run = code > 128; + let count = usize::from(if run { code - 128 } else { code }); + let end = x.checked_add(count).filter(|end| *end <= width).ok_or_else(|| invalid("HDR RLE packet exceeds its row"))?; + let repeated = if run { self.byte()? } else { 0 }; + for at in x..end { + let value = if run { repeated } else { self.byte()? }; + *self.pixels.get_mut(at).and_then(|p| p.get_mut(channel)).ok_or_else(|| invalid("invalid HDR component address"))? = value; + } + if end == width { + if channel == 3 { Stage::Ready } else { Stage::Channel { channel: channel + 1, x: 0 } } + } else { + Stage::Channel { channel, x: end } + } + } + Stage::Old { x, previous, shift, repeat } => { + if repeat > 0 { + self.put(x, previous)?; + if x + 1 == width { Stage::Ready } else { Stage::Old { x: x + 1, previous, shift, repeat: repeat - 1 } } + } else { + let pixel = self.pixel()?; + if pixel.get(..3) == Some(&[1, 1, 1]) { + let count = u64::from(*pixel.get(3).ok_or_else(|| invalid("missing HDR repeat count"))?) + .checked_shl(shift) + .filter(|n| *n <= (width - x) as u64) + .ok_or_else(|| invalid("old HDR run exceeds its row"))?; + let next_shift = shift.checked_add(8).filter(|s| *s <= 32).ok_or_else(|| invalid("old HDR run multiplier overflow"))?; + Stage::Old { x, previous, shift: next_shift, repeat: count as usize } + } else { + self.put(x, pixel)?; + if x + 1 == width { Stage::Ready } else { Stage::Old { x: x + 1, previous: pixel, shift: 0, repeat: 0 } } + } + } + } + Stage::Ready => return Err(invalid("HDR row must be consumed before decoding another")), + Stage::Done => return Err(invalid("HDR input already consumed")), + }; + Ok(()) + } + pub(super) fn linear(&self, x: usize) -> Result<[f32; 3]> { + if !self.ready() { + return Err(invalid("HDR row is not ready")); + } + let pixel = self.pixels.get(x).ok_or_else(|| invalid("invalid HDR pixel address"))?; + let exponent = *pixel.get(3).ok_or_else(|| invalid("missing RGBE exponent"))?; + if exponent == 0 { + return Ok([0.0; 3]); + } + let scale = self.scales.get(usize::from(exponent)).copied().ok_or_else(|| invalid("invalid RGBE exponent"))? / self.source.info.exposure; + let mut rgb = [0.0; 3]; + for (channel, out) in rgb.iter_mut().enumerate() { + let value = f64::from(*pixel.get(channel).ok_or_else(|| invalid("invalid RGBE channel"))?) * scale + / self.source.info.color_correction.get(channel).copied().ok_or_else(|| invalid("invalid HDR correction"))?; + if !value.is_finite() || !(0.0..=MAX_RADIANCE).contains(&value) { + return Err(limit("corrected HDR radiance exceeds 100 million")); + } + *out = value as f32; + } + Ok(rgb) + } + pub(super) fn next_row(&mut self) -> Result<()> { + if !self.ready() { + return Err(invalid("HDR row is not ready")); + } + self.row = self.row.checked_add(1).ok_or_else(|| invalid("HDR row overflow"))?; + self.stage = if self.row == self.source.info.height { Stage::Done } else { Stage::Start }; + Ok(()) + } +} diff --git a/crates/render/src/lighting/hdr_tests.rs b/crates/render/src/lighting/hdr_tests.rs new file mode 100644 index 00000000..842af9db --- /dev/null +++ b/crates/render/src/lighting/hdr_tests.rs @@ -0,0 +1,172 @@ +use super::*; +use crate::lighting::{HdrDecodeTask, HdrTaskPoll}; + +#[test] +fn cooperative_task_matches_codec_and_is_consumed_once() { + for bytes in [uncompressed(""), rle(), literal_rle(), old_rle()] { + let expected = image::load_from_memory_with_format(&bytes, image::ImageFormat::Hdr).unwrap().into_rgb32f(); + let mut task = HdrDecodeTask::new(HdrSource::new(bytes).unwrap()).unwrap(); + let mut calls = 0; + loop { + calls += 1; + assert!(calls < 1000); + match task.step(1, || false).unwrap() { + HdrTaskPoll::Pending(progress) => assert!(progress.decoded_pixels < progress.total_pixels), + HdrTaskPoll::Complete(result) => { + assert_eq!(result.pixels().len(), expected.pixels().len()); + for (actual, expected) in result.pixels().iter().zip(expected.pixels()) { + assert_eq!(actual, &expected.0); + } + break; + } + } + } + assert!(calls > 8); + assert_eq!(task.step(1, || false).err().unwrap().code, ErrorCode::Consumed); + } +} + +#[test] +fn cancellation_or_bad_input_cannot_publish_partial_pixels_later() { + let mut task = HdrDecodeTask::new(HdrSource::new(rle()).unwrap()).unwrap(); + assert!(matches!(task.step(7, || false).unwrap(), HdrTaskPoll::Pending(_))); + assert_eq!(task.step(1, || true).err().unwrap().code, ErrorCode::Cancelled); + assert_eq!(task.step(16384, || false).err().unwrap().code, ErrorCode::Consumed); + let mut bytes = rle(); + bytes.pop(); + let mut task = HdrDecodeTask::new(HdrSource::new(bytes).unwrap()).unwrap(); + assert_eq!(task.step(16384, || false).err().unwrap().code, ErrorCode::InvalidInput); + assert_eq!(task.step(1, || false).err().unwrap().code, ErrorCode::Consumed); + + let checks = std::cell::Cell::new(0); + let mut task = HdrDecodeTask::new(HdrSource::new(rle()).unwrap()).unwrap(); + let result = task.step(16384, || { + checks.set(checks.get() + 1); + checks.get() == 10 + }); + assert_eq!(result.err().unwrap().code, ErrorCode::Cancelled); + assert_eq!(checks.get(), 10); + assert_eq!(task.step(1, || false).err().unwrap().code, ErrorCode::Consumed); +} + +#[test] +fn source_primaries_are_preserved_without_assuming_srgb() { + let source = HdrSource::new(uncompressed("PRIMARIES=0.64 0.33 0.3 0.6 0.15 0.06 0.3127 0.329\n")).unwrap(); + assert_eq!(source.info().primaries, Some([0.64, 0.33, 0.3, 0.6, 0.15, 0.06, 0.3127, 0.329])); + assert!(HdrSource::new(uncompressed("PRIMARIES=NaN 0 0 0 0 0 0 0\n")).is_err()); + let mut excessive_capacity = Vec::with_capacity(MAX_HDR_BYTES + 1); + excessive_capacity.extend_from_slice(&rle()); + assert_eq!(HdrSource::new(excessive_capacity).err().unwrap().code, ErrorCode::LimitExceeded); +} + +fn header(width: u32, height: u32, extra: &str) -> Vec { + format!("#?RADIANCE\nFORMAT=32-bit_rle_rgbe\n{extra}\n-Y {height} +X {width}\n").into_bytes() +} +fn uncompressed(extra: &str) -> Vec { + let mut bytes = header(4, 2, extra); + for _ in 0..8 { + bytes.extend_from_slice(&[128, 64, 32, 131]); + } + bytes +} +fn rle() -> Vec { + let mut bytes = header(8, 4, ""); + for _ in 0..4 { + bytes.extend_from_slice(&[2, 2, 0, 8]); + for value in [128, 64, 32, 129] { + bytes.extend_from_slice(&[136, value]); + } + } + bytes +} + +fn literal_rle() -> Vec { + let mut bytes = header(8, 4, ""); + for _ in 0..4 { + bytes.extend_from_slice(&[2, 2, 0, 8]); + for value in [128, 64, 32, 129] { + bytes.push(8); + bytes.extend_from_slice(&[value; 8]); + } + } + bytes +} + +fn old_rle() -> Vec { + let mut bytes = header(8, 4, ""); + for _ in 0..4 { + bytes.extend_from_slice(&[128, 64, 32, 129, 1, 1, 1, 7]); + } + bytes +} + +#[test] +fn incremental_rows_match_the_original_codec_without_clipping_hdr_values() { + for bytes in [uncompressed(""), rle(), literal_rle(), old_rle()] { + let expected = image::load_from_memory_with_format(&bytes, image::ImageFormat::Hdr).unwrap().into_rgb32f(); + let source = HdrSource::new(bytes).unwrap(); + source.validate(|| false).unwrap(); + let mut rows = Rows::new(source).unwrap(); + while !rows.done() { + if rows.ready() { + for x in 0..rows.info().width as usize { + assert_eq!(rows.linear(x).unwrap(), expected.get_pixel(x as u32, rows.row()).0); + } + rows.next_row().unwrap(); + } else { + rows.step().unwrap(); + } + } + } +} + +#[test] +fn header_exposure_and_colour_correction_restore_linear_radiance() { + let source = HdrSource::new(uncompressed("EXPOSURE=2\nCOLORCORR=1 2 4\n")).unwrap(); + let mut rows = Rows::new(source).unwrap(); + while !rows.ready() { + rows.step().unwrap(); + } + assert_eq!(rows.linear(0).unwrap(), [2.0, 0.5, 0.125]); +} + +#[test] +fn malformed_runs_truncation_nonfinite_metadata_and_hostile_sizes_are_refused() { + let mut truncated = rle(); + truncated.pop(); + let mut wrong_width = rle(); + let offset = HdrSource::new(wrong_width.clone()).unwrap().info.offset; + wrong_width[offset + 3] = 9; + let mut zero_run = rle(); + zero_run[offset + 4] = 0; + let mut overflow = header(8, 4, ""); + overflow.extend_from_slice(&[128, 64, 32, 129]); + for _ in 0..6 { + overflow.extend_from_slice(&[1, 1, 1, 0]); + } + for bytes in [truncated, wrong_width, zero_run, overflow] { + assert!(HdrSource::new(bytes).unwrap().validate(|| false).is_err()); + } + for bytes in [header(u32::MAX, 1, ""), uncompressed("EXPOSURE=NaN\n"), uncompressed("PIXASPECT=0\n")] { + assert!(HdrSource::new(bytes).is_err()); + } + let mut too_large = rle(); + too_large.resize(MAX_HDR_BYTES + 1, 0); + assert_eq!(HdrSource::new(too_large).err().unwrap().code, ErrorCode::LimitExceeded); + let mut too_bright = uncompressed(""); + let offset = HdrSource::new(too_bright.clone()).unwrap().info.offset; + too_bright[offset + 3] = 255; + assert_eq!(HdrSource::new(too_bright).unwrap().validate(|| false).unwrap_err().code, ErrorCode::LimitExceeded); + let source = HdrSource::new(rle()).unwrap(); + + assert_eq!(source.validate(|| true).unwrap_err().code, ErrorCode::Cancelled); +} + +#[test] +fn header_work_is_bounded_before_accepting_metadata() { + let mut bytes = b"#?RADIANCE\nFORMAT=32-bit_rle_rgbe\n".to_vec(); + bytes.extend(std::iter::repeat_n(b'x', MAX_HEADER_BYTES + 1)); + bytes.extend_from_slice(b"\n\n-Y 2 +X 4\n"); + bytes.extend_from_slice(&[128, 64, 32, 129]); + assert!(HdrSource::new(bytes).is_err()); +} diff --git a/crates/render/src/lighting/mod.rs b/crates/render/src/lighting/mod.rs new file mode 100644 index 00000000..8ff4403a --- /dev/null +++ b/crates/render/src/lighting/mod.rs @@ -0,0 +1,43 @@ +//! Device-independent, bounded linear HDR input preparation. + +mod hdr; +mod task; +pub use hdr::{HdrInfo, HdrSource, MAX_HDR_BYTES, MAX_HDR_HEIGHT, MAX_HDR_PIXELS, MAX_HDR_WIDTH}; +pub use task::{DecodedHdr, HdrDecodeTask, HdrProgress, HdrTaskPoll}; + +use serde::Serialize; +use std::fmt; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum HdrErrorCode { + InvalidInput, + LimitExceeded, + AllocationFailed, + Cancelled, + Consumed, + Internal, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct HdrError { + pub code: HdrErrorCode, + pub message: String, +} + +impl HdrError { + pub fn new(code: HdrErrorCode, message: impl Into) -> Self { + Self { code, message: message.into() } + } +} +impl fmt::Display for HdrError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.message) + } +} +impl std::error::Error for HdrError {} +pub type Result = std::result::Result; + +fn check_cancelled(cancelled: &impl Fn() -> bool) -> Result<()> { + if cancelled() { Err(HdrError::new(HdrErrorCode::Cancelled, "HDR decoding cancelled")) } else { Ok(()) } +} diff --git a/crates/render/src/lighting/task.rs b/crates/render/src/lighting/task.rs new file mode 100644 index 00000000..f5c2fdf4 --- /dev/null +++ b/crates/render/src/lighting/task.rs @@ -0,0 +1,84 @@ +use super::{HdrError, HdrErrorCode, HdrInfo, HdrSource, MAX_HDR_PIXELS, Result, check_cancelled, hdr::Rows}; + +/// Linear RGB in the source's colour space, without gamut conversion or tone mapping. +pub struct DecodedHdr { + info: HdrInfo, + pixels: Vec<[f32; 3]>, +} +impl DecodedHdr { + pub fn info(&self) -> &HdrInfo { + &self.info + } + pub fn pixels(&self) -> &[[f32; 3]] { + &self.pixels + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct HdrProgress { + pub decoded_pixels: u64, + pub total_pixels: u64, +} +pub enum HdrTaskPoll { + Pending(HdrProgress), + Complete(DecodedHdr), +} + +/// Caller-owned cooperative preparation; no scheduler, GPU or document dependency. +pub struct HdrDecodeTask { + rows: Option, + pixels: Vec<[f32; 3]>, + x: usize, + work: u64, +} +impl HdrDecodeTask { + pub fn new(source: HdrSource) -> Result { + let count = u64::from(source.info().width) * u64::from(source.info().height); + let count = usize::try_from(count).map_err(|_| HdrError::new(HdrErrorCode::LimitExceeded, "HDR pixel count overflow"))?; + let mut pixels = Vec::new(); + pixels.try_reserve_exact(count).map_err(|_| HdrError::new(HdrErrorCode::AllocationFailed, "could not reserve HDR pixels"))?; + Ok(Self { rows: Some(Rows::new(source)?), pixels, x: 0, work: 0 }) + } + + /// Each unit consumes one RLE packet (at most 128 bytes) or one RGB pixel. + /// Error/cancellation consumes the task and releases its partial buffers. + pub fn step(&mut self, units: usize, cancelled: impl Fn() -> bool) -> Result { + let result = self.advance(units, cancelled); + if result.is_err() { + self.rows = None; + self.pixels = Vec::new(); + } + result + } + fn advance(&mut self, units: usize, cancelled: impl Fn() -> bool) -> Result { + let rows = self.rows.as_mut().ok_or_else(|| HdrError::new(HdrErrorCode::Consumed, "HDR task already consumed"))?; + for _ in 0..units.clamp(1, 16384) { + check_cancelled(&cancelled)?; + self.work = self + .work + .checked_add(1) + .filter(|n| *n <= MAX_HDR_PIXELS * 6 + 4096) + .ok_or_else(|| HdrError::new(HdrErrorCode::LimitExceeded, "HDR decode work budget exceeded"))?; + if rows.ready() { + self.pixels.push(rows.linear(self.x)?); + self.x += 1; + if self.x == rows.info().width as usize { + rows.next_row()?; + self.x = 0; + } + } else { + rows.step()?; + } + if rows.done() { + check_cancelled(&cancelled)?; + let info = rows.info().clone(); + self.rows = None; + return Ok(HdrTaskPoll::Complete(DecodedHdr { info, pixels: std::mem::take(&mut self.pixels) })); + } + } + Ok(HdrTaskPoll::Pending(HdrProgress { + decoded_pixels: self.pixels.len() as u64, + total_pixels: u64::from(rows.info().width) * u64::from(rows.info().height), + })) + } +} diff --git a/crates/ui-egui/src/ref_images.rs b/crates/ui-egui/src/ref_images.rs index 2ed13236..4916a8c7 100644 --- a/crates/ui-egui/src/ref_images.rs +++ b/crates/ui-egui/src/ref_images.rs @@ -28,6 +28,15 @@ thread_local! { static VERSION: std::cell::Cell = const { std::cell::Cell::new(0) }; } +// Codec features are unified across crates. Enabling bounded HDR preparation must +// not route HDR through this synchronous whole-image canvas decoder. +fn decode_reference(bytes: &[u8]) -> Option { + match image::guess_format(bytes).ok()? { + image::ImageFormat::Png | image::ImageFormat::Jpeg => image::load_from_memory(bytes).ok(), + _ => None, + } +} + fn pixels(c: &Canvas) -> Option { PIXELS.with(|m| { let mut m = m.borrow_mut(); @@ -36,7 +45,7 @@ fn pixels(c: &Canvas) -> Option { { return d.clone(); } - let d = c.bytes().and_then(|b| image::load_from_memory(&b).ok()).map(|i| { + let d = c.bytes().and_then(|b| decode_reference(&b)).map(|i| { let i = if i.width() > MAX_SIDE || i.height() > MAX_SIDE { i.thumbnail(MAX_SIDE, MAX_SIDE) } else { i }; let rgba = i.to_rgba8(); let v = VERSION.with(|v| { @@ -138,3 +147,19 @@ pub fn show(app: &SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &Proj) painter.add(Shape::mesh(mesh)); } } + +#[cfg(test)] +mod codec_tests { + use super::decode_reference; + + #[test] + fn hdr_feature_does_not_enable_synchronous_canvas_hdr_decoding() { + let mut hdr = b"#?RADIANCE\nFORMAT=32-bit_rle_rgbe\n\n-Y 1 +X 1\n".to_vec(); + hdr.extend_from_slice(&[128, 64, 32, 129]); + assert!(image::load_from_memory(&hdr).is_ok()); + assert!(decode_reference(&hdr).is_none()); + let mut png = std::io::Cursor::new(Vec::new()); + image::DynamicImage::new_rgba8(1, 1).write_to(&mut png, image::ImageFormat::Png).unwrap(); + assert!(decode_reference(png.get_ref()).is_some()); + } +} diff --git a/docs/render-hdr-input.md b/docs/render-hdr-input.md new file mode 100644 index 00000000..0eeb425e --- /dev/null +++ b/docs/render-hdr-input.md @@ -0,0 +1,47 @@ +# Bounded Radiance input + +`solidcraft_render::lighting` provides a caller-owned cooperative RGBE decoder. +This is input preparation only: it enables no environment import command, document +format, PBR material, lighting capability, GPU upload or image-render backend. +The existing canvas decoder explicitly accepts PNG/JPEG only, so Cargo feature +unification cannot send HDR through its synchronous whole-image loading path. +It is the independent input foundation for #813 / #620, coordinated with the +maintainer's renderer work described in #792. + +`HdrSource::new(Vec)` checks a bounded header using the existing `image` +metadata codec. Success does **not** mean the pixel stream is valid. Use +`validate(cancelled)` for a synchronous full-stream check, or construct +`HdrDecodeTask` and repeatedly call `step(units, cancelled)` for cooperative +preparation. Only `Complete(DecodedHdr)` contains publishable pixels. Cancellation +and errors release partial buffers; subsequent calls return `Consumed`, including +after successful completion. The cancellation callback must itself be bounded. +The consumer owns scheduling and must not perform synchronous `validate` on a +latency-sensitive UI thread. + +Input limits are 16 MiB of encoded Vec capacity, a 64 KiB header, 2048x1024 and +2,097,152 pixels. Only 2:1, square-pixel, standard `-Y +X` RGBE is accepted. +EXR, XYZE and other orientations are unsupported. A call accepts 1–16,384 work +units; each unit decodes one packet (at most 128 component writes) or converts one +pixel. An overall work cap bounds malformed old-style runs. Corrected channels +must be finite, nonnegative and at most 100,000,000. Limits are intentional preview +policy, not a statement about all valid Radiance files. + +The float pixel allocation is at most 24 MiB, with one RGBE scanline at most 8 KiB +and a shared encoded source at most 16 MiB. Construction reserves float storage +fallibly; initialization happens incrementally. This is a per-task estimate, +not a process memory quota. The consumer must admit concurrent jobs, source +ownership, decoded copies, colour conversion, GPU textures and IBL maps within +its own total budget before starting. + +RGBE conversion follows `image` 0.25.10's mantissa/exponent convention, with no +tone mapping or clipping to [0,1]. Stored values are divided by header EXPOSURE +and COLORCORR. Physical units are not guaranteed by arbitrary input files. +`HdrInfo::primaries` preserves explicit PRIMARIES chromaticities; absence is +unspecified. Output is linear RGB in the source colour space, **not implicitly +linear sRGB**. Consumers must choose and validate a working-space transform; +basic finite/range validation of chromaticities here does not prove an invertible +or physically meaningful gamut. No gamut conversion is performed by this utility. + +The scanline adapter is derived from the permissively licensed `image` codec; +see ATTRIBUTION.md, NOTICE and THIRD-PARTY-LICENSES.txt. Test images are original +inline byte fixtures. No commercial-product images or materials are included.