From 241dac15427eecdcb7066e78ed8247f2678fcc8e Mon Sep 17 00:00:00 2001 From: selune <35337584+0xlillith@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:35:52 +0200 Subject: [PATCH] Render: bounded PNG/JPEG texture input admission (#909) Bounded in-memory PNG/JPEG texture decoding with explicit base-colour/data-map roles; RGBA8 pixels are published only after a complete decode. Input only: no material assignment, GPU shading or persistence. Refs #900. Contributed by @0xlillith. Co-authored-by: lillith <35337584+0xlillith@users.noreply.github.com> --- ROADMAP.md | 5 + crates/render/Cargo.toml | 2 +- crates/render/src/lib.rs | 1 + crates/render/src/texture_input.rs | 152 +++++++++++++++++++++++ crates/render/src/texture_input/tests.rs | 65 ++++++++++ docs/render-texture-input.md | 74 +++++++++++ 6 files changed, 298 insertions(+), 1 deletion(-) create mode 100644 crates/render/src/texture_input.rs create mode 100644 crates/render/src/texture_input/tests.rs create mode 100644 docs/render-texture-input.md diff --git a/ROADMAP.md b/ROADMAP.md index c48aeb9a..0b4d738c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -89,6 +89,11 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings. ## Render foundation +- Bounded PNG/JPEG texture admission (#900) validates in-memory sources and returns + completed RGBA8 pixels with explicit color/data roles. It is an input utility only: + ICC/16-bit normalization, local wood mapping, resource persistence and the renderer + consumer remain separate work. See [limits and licensed-file probes](docs/render-texture-input.md). + - The initial opaque surface numerical contract for #1004 provides bounded linear color/metallic/roughness/IOR admission and an original tested direct-light BRDF. It does not enable PBR rendering or change stored appearances. See diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml index 3cbd4b97..2f278cf9 100644 --- a/crates/render/Cargo.toml +++ b/crates/render/Cargo.toml @@ -11,7 +11,7 @@ workspace = true [dependencies] solidcraft-geom = { workspace = true } serde = { workspace = true } -image = { workspace = true } +image = { workspace = true, features = ["jpeg"] } [dev-dependencies] serde_json = { workspace = true } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 34e2ee49..4721f02a 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -10,6 +10,7 @@ mod curve_display; pub mod lighting; mod raster; pub mod surface; +pub mod texture_input; pub use backend::{ Availability, BackendDescriptor, BackendId, ExecutorDescriptor, ExecutorId, MAX_RENDER_ID_BYTES, RenderFeature, RenderFormat, RenderLimits, diff --git a/crates/render/src/texture_input.rs b/crates/render/src/texture_input.rs new file mode 100644 index 00000000..034f83d5 --- /dev/null +++ b/crates/render/src/texture_input.rs @@ -0,0 +1,152 @@ +//! Bounded texture admission, independent of documents, files, schedulers and GPU handles. +//! This utility does not advertise texture/PBR support or define a persistent pack schema. +use image::{ImageDecoder, ImageFormat, ImageReader, Limits}; +use serde::{Deserialize, Serialize}; +use std::{io::Cursor, sync::Arc}; + +pub const MAX_TEXTURE_BYTES: usize = 16 * 1024 * 1024; +pub const MAX_TEXTURE_EDGE: u32 = 2048; +const MAX_CODEC_BYTES: u64 = 64 * 1024 * 1024; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MapRole { + BaseColor, + NormalOpenGl, + Roughness, + Metallic, + AmbientOcclusion, +} +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Interpretation { + Srgb, + Data, +} +impl MapRole { + pub fn interpretation(self) -> Interpretation { + if self == Self::BaseColor { Interpretation::Srgb } else { Interpretation::Data } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TextureError(pub String); +impl std::fmt::Display for TextureError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} +impl std::error::Error for TextureError {} +type Result = std::result::Result; +fn invalid(message: impl Into) -> TextureError { + TextureError(message.into()) +} + +#[derive(Clone, Debug, Serialize)] +pub struct TextureInfo { + pub width: u32, + pub height: u32, + pub encoded_bytes: usize, + pub rgba_bytes: u64, + pub role: MapRole, + pub interpretation: Interpretation, +} + +/// Header-admitted source. Full decoding must succeed before publishing pixels. +#[derive(Clone)] +pub struct TextureSource { + bytes: Arc>, + format: ImageFormat, + info: TextureInfo, +} +fn reader(bytes: &[u8], format: ImageFormat) -> ImageReader> { + let mut reader = ImageReader::with_format(Cursor::new(bytes), format); + let mut limits = Limits::default(); + limits.max_image_width = Some(MAX_TEXTURE_EDGE); + limits.max_image_height = Some(MAX_TEXTURE_EDGE); + limits.max_alloc = Some(MAX_CODEC_BYTES); + reader.limits(limits); + reader +} +fn guarded(work: impl FnOnce() -> Result) -> Result { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(work)).map_err(|_| invalid("texture decoder failed"))? +} +impl TextureSource { + pub fn new(bytes: Vec, role: MapRole) -> Result { + if bytes.is_empty() || bytes.len() > MAX_TEXTURE_BYTES || bytes.capacity() > MAX_TEXTURE_BYTES { + return Err(invalid("texture input must contain at most 16 MiB")); + } + let format = image::guess_format(&bytes).map_err(|_| invalid("texture must be PNG or JPEG"))?; + if !matches!(format, ImageFormat::Png | ImageFormat::Jpeg) { + return Err(invalid("texture must be PNG or JPEG; HDR uses the Radiance input utility")); + } + // Whole-image codecs may return pixels before noticing a missing trailer. This + // marker check rejects that truncation; it is not a general container validator. + let complete = match format { + ImageFormat::Png => bytes.ends_with(b"\x00\x00\x00\x00IEND\xae\x42\x60\x82"), + ImageFormat::Jpeg => bytes.ends_with(b"\xff\xd9"), + _ => false, + }; + if !complete { + return Err(invalid("texture is missing its final terminator")); + } + let (width, height) = guarded(|| { + let mut decoder = reader(&bytes, format).into_decoder().map_err(|e| invalid(e.to_string()))?; + if !matches!(decoder.color_type(), image::ColorType::L8 | image::ColorType::La8 | image::ColorType::Rgb8 | image::ColorType::Rgba8) { + return Err(invalid("only 8-bit texture channels are supported")); + } + if decoder.icc_profile().map_err(|e| invalid(e.to_string()))?.is_some() { + return Err(invalid("embedded ICC profiles need explicit color conversion before admission")); + } + if decoder.orientation().map_err(|e| invalid(e.to_string()))? != image::metadata::Orientation::NoTransforms { + return Err(invalid("texture orientation must be normalized before admission")); + } + Ok(decoder.dimensions()) + })?; + if width == 0 || height == 0 || width > MAX_TEXTURE_EDGE || height > MAX_TEXTURE_EDGE { + return Err(invalid("texture dimensions exceed 2048x2048")); + } + let rgba_bytes = u64::from(width) * u64::from(height) * 4; + let info = TextureInfo { width, height, encoded_bytes: bytes.len(), rgba_bytes, role, interpretation: role.interpretation() }; + Ok(Self { bytes: Arc::new(bytes), format, info }) + } + pub fn info(&self) -> &TextureInfo { + &self.info + } + /// Bounded whole-image codec work, intended for a caller-owned worker. Cancellation is + /// checked at the codec boundary, not during PNG/JPEG decoding. No partial image escapes. + pub fn decode(&self, cancelled: impl Fn() -> bool) -> Result { + if cancelled() { + return Err(invalid("texture decoding cancelled")); + } + let image = guarded(|| reader(&self.bytes, self.format).decode().map_err(|e| invalid(e.to_string())))?; + if cancelled() { + return Err(invalid("texture decoding cancelled")); + } + let pixels = guarded(|| Ok(image.into_rgba8().into_raw()))?; + if cancelled() { + return Err(invalid("texture decoding cancelled")); + } + if pixels.len() as u64 != self.info.rgba_bytes { + return Err(invalid("decoded texture dimensions changed")); + } + Ok(DecodedTexture { info: self.info.clone(), pixels }) + } +} +/// Completed pixels retain encoded channel values. sRGB conversion, normal basis handling +/// and sampling belong to the eventual renderer adapter, not this input utility. +pub struct DecodedTexture { + info: TextureInfo, + pixels: Vec, +} +impl DecodedTexture { + pub fn info(&self) -> &TextureInfo { + &self.info + } + pub fn rgba(&self) -> &[u8] { + &self.pixels + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/render/src/texture_input/tests.rs b/crates/render/src/texture_input/tests.rs new file mode 100644 index 00000000..f4ad8ca8 --- /dev/null +++ b/crates/render/src/texture_input/tests.rs @@ -0,0 +1,65 @@ +use super::*; + +fn png(width: u32, height: u32) -> Vec { + let image = image::RgbaImage::from_pixel(width, height, image::Rgba([64, 128, 192, 255])); + let mut out = Cursor::new(Vec::new()); + image.write_to(&mut out, ImageFormat::Png).unwrap(); + out.into_inner() +} +#[test] +fn texture_input_preserves_channels_and_explicit_map_interpretation() { + for role in [MapRole::BaseColor, MapRole::NormalOpenGl, MapRole::Roughness, MapRole::Metallic, MapRole::AmbientOcclusion] { + let source = TextureSource::new(png(3, 2), role).unwrap(); + let decoded = source.decode(|| false).unwrap(); + assert_eq!((decoded.info().width, decoded.info().height, decoded.info().rgba_bytes), (3, 2, 24)); + assert_eq!(decoded.info().interpretation, if role == MapRole::BaseColor { Interpretation::Srgb } else { Interpretation::Data }); + assert!(decoded.rgba().as_chunks::<4>().0.iter().all(|p| *p == [64, 128, 192, 255])); + } +} +#[test] +fn texture_input_rejects_dimensions_formats_and_corrupt_pixels() { + assert!(TextureSource::new(png(MAX_TEXTURE_EDGE + 1, 1), MapRole::BaseColor).is_err()); + assert!(TextureSource::new(Vec::new(), MapRole::BaseColor).is_err()); + assert!(TextureSource::new(b"#?RADIANCE\nFORMAT=32-bit_rle_rgbe\n\n-Y 1 +X 2\nabcdefgh".to_vec(), MapRole::BaseColor).is_err()); + let bytes = png(3, 2); + for n in 0..bytes.len() { + if let Ok(source) = TextureSource::new(bytes.get(..n).unwrap().to_vec(), MapRole::BaseColor) { + assert!(source.decode(|| false).is_err(), "truncated PNG accepted at {n}"); + } + } +} +#[test] +fn texture_input_cancellation_never_publishes_partial_pixels() { + let source = TextureSource::new(png(3, 2), MapRole::NormalOpenGl).unwrap(); + assert!(source.decode(|| true).is_err()); + let calls = std::cell::Cell::new(0); + assert!( + source + .decode(|| { + calls.set(calls.get() + 1); + calls.get() == 2 + }) + .is_err() + ); + assert!(source.clone().decode(|| false).is_ok()); +} +#[test] +fn texture_input_jpeg_is_supported_without_hdr_feature_confusion() { + let image = image::RgbImage::from_pixel(2, 2, image::Rgb([64, 128, 192])); + let mut out = Cursor::new(Vec::new()); + image.write_to(&mut out, ImageFormat::Jpeg).unwrap(); + let source = TextureSource::new(out.into_inner(), MapRole::BaseColor).unwrap(); + assert_eq!(source.decode(|| false).unwrap().rgba().len(), 16); +} + +#[test] +fn texture_input_caps_encoded_storage_and_refuses_precision_loss() { + let mut bytes = Vec::with_capacity(MAX_TEXTURE_BYTES + 1); + bytes.extend(png(1, 1)); + assert!(TextureSource::new(bytes, MapRole::Roughness).is_err()); + assert!(TextureSource::new(vec![0; MAX_TEXTURE_BYTES + 1], MapRole::BaseColor).is_err()); + let image = image::DynamicImage::ImageLuma16(image::ImageBuffer::from_pixel(1, 1, image::Luma([32768u16]))); + let mut out = Cursor::new(Vec::new()); + image.write_to(&mut out, ImageFormat::Png).unwrap(); + assert!(TextureSource::new(out.into_inner(), MapRole::NormalOpenGl).is_err()); +} diff --git a/docs/render-texture-input.md b/docs/render-texture-input.md new file mode 100644 index 00000000..c36516e6 --- /dev/null +++ b/docs/render-texture-input.md @@ -0,0 +1,74 @@ +# Bounded texture input (resource admission) + +`solidcraft_render::texture_input` is an independent input utility for #900, coordinated +with #792 and #813. It accepts in-memory PNG/JPEG bytes and supplies completed RGBA8 +pixels. It does not change a document, read files, assign materials, add a resource-pack +format, schedule work, upload GPU data or advertise PBR/triplanar support. + +`TextureSource::new(bytes, role)` admits bounded headers; `decode(cancelled)` validates +the whole image before returning `DecodedTexture`. Sources are immutable and shared. +Header admission alone is not proof of valid pixel data. PNG/JPEG codecs run as bounded +whole-image operations, so a host must use its own worker rather than block the UI. +Cancellation is checked before/after decoding and conversion; it cannot interrupt a +codec internally. This is not the cooperative row API of the separate HDR utility. + +Roles are `base_color`, `normal_open_gl`, `roughness`, `metallic`, `ambient_occlusion`. +Base color declares sRGB interpretation; other roles retain data-channel values. +The utility does not apply a transfer function, normalize normals, swap DirectX normal +channels, unpack combined maps or infer semantics from file names. Missing maps, +material defaults and sampling/basis choices belong to the eventual renderer adapter. + +Initial limits and exclusions: + +- Nonempty encoded storage, including Vec capacity, at most 16 MiB. +- PNG/JPEG only, with a required final PNG IEND / JPEG EOI marker. This does not + constitute a complete container-framing/trailing-data validator. +- Positive dimensions, each at most 2048; completed RGBA8 is at most 16 MiB. +- Codec allocation limit 64 MiB, separately from retained encoded bytes and output + conversion. This is not a combined CPU/GPU memory guarantee. +- Eight-bit channels only. Sixteen-bit files refuse rather than silently quantize. +- Embedded ICC profiles and nontrivial EXIF orientation refuse. Explicit color-space + conversion/orientation normalization is required before admission; no ICC transform + or PNG gamma correction is performed here. Untagged base color is a caller-declared + sRGB input, not an inferred or certified source color space. +- Third-party decoding/conversion panics are contained and partial buffers never escape. + GPU allocation, browser scheduling and document/session generations remain host concerns. + +The numerical opaque-surface contract is available in `surface`; its product consumer +is being integrated separately in #1046 under #1004. The texture/environment adapter +and its resource ownership are still separate work coordinated in #813. +Wood repeat size/rotation, definition-local grain frames, durable resource identity, +source/license receipts, document packaging and setup-owned HDR rotation/exposure must +use that agreed consumer representation. This input utility does not reserve any of +those fields or a document version. The scalar library from #863 remains distinct. + +HDR environments use existing `lighting::HdrSource` / `HdrDecodeTask`, not this PNG/JPEG +path: Radiance RGBE, at most 16 MiB and 2048x1024, standard 2:1 lat-long, finite corrected +radiance. See [HDR input](render-hdr-input.md). Successful decoding alone enables neither +IBL nor GPU preview. The current CPU snapshot executor remains unchanged and must not +claim unsupported HDRI/PBR output. + +## Licensed-file probe (2026-10-14) + +Files were downloaded outside the checkout for validation only, from the public API +with an identifying SolidCraft User-Agent. No assets are bundled by this change. +[Poly Haven assets are CC0](https://polyhaven.com/license); live API integration has +separate credit/identity conditions. These results are input checks, not shaded images: + +| Resource | Author / source | Result | +|---|---|---| +| Black Oak Veneer, 1K base-color PNG | Jenelle van Heerden; [asset](https://polyhaven.com/a/black_oak_veneer) | Refused: 16-bit channel precision is outside this initial utility. | +| Black Oak Veneer, 1K base-color JPEG | Same asset/author | Refused: embedded ICC requires explicit color conversion. | +| Joburg Central Sunset, 1K Radiance HDR | Greg Zaal and Dimitrios Savva; [asset](https://polyhaven.com/a/sunset_jhbcentral) | Existing HDR utility fully validates 1024x512, 1,573,705 encoded bytes, 6,291,456 linear RGB bytes. No lighting result is claimed. | + +The refusals are deliberate evidence of remaining normalization work, not successful +wood integration. Provider MD5 values were checked against selected downloads and local +SHA-256 receipts retained with source/license/retrieval metadata. Neither is a claim of +publisher authentication. Shipped assets would require per-file ATTRIBUTION.md entries; +private/user resources need their own portable provenance records. Reuse the maintainer's +starter set when available rather than introduce a competing set. + +Tests generate original small PNG/JPEG files in code and check exact channel values, +role interpretation, dimensions/storage limits, precision rejection, truncation, +cancellation and continued source reuse. These tests do not establish GPU performance, +interactive browser behavior or the final resource-pack schema.