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>
This commit is contained in:
1 parent
cccbe1255d
commit
241dac1542
6 files changed
+298
-1
No files matched your search
@@ -89,6 +89,11 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
|
|||||||
|
|
||||||
## Render foundation
|
## 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
|
- The initial opaque surface numerical contract for #1004 provides bounded linear
|
||||||
color/metallic/roughness/IOR admission and an original tested direct-light BRDF.
|
color/metallic/roughness/IOR admission and an original tested direct-light BRDF.
|
||||||
It does not enable PBR rendering or change stored appearances. See
|
It does not enable PBR rendering or change stored appearances. See
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ workspace = true
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
solidcraft-geom = { workspace = true }
|
solidcraft-geom = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
image = { workspace = true }
|
image = { workspace = true, features = ["jpeg"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
@@ -10,6 +10,7 @@ mod curve_display;
|
|||||||
pub mod lighting;
|
pub mod lighting;
|
||||||
mod raster;
|
mod raster;
|
||||||
pub mod surface;
|
pub mod surface;
|
||||||
|
pub mod texture_input;
|
||||||
|
|
||||||
pub use backend::{
|
pub use backend::{
|
||||||
Availability, BackendDescriptor, BackendId, ExecutorDescriptor, ExecutorId, MAX_RENDER_ID_BYTES, RenderFeature, RenderFormat, RenderLimits,
|
Availability, BackendDescriptor, BackendId, ExecutorDescriptor, ExecutorId, MAX_RENDER_ID_BYTES, RenderFeature, RenderFormat, RenderLimits,
|
||||||
|
|||||||
@@ -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<T> = std::result::Result<T, TextureError>;
|
||||||
|
fn invalid(message: impl Into<String>) -> 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<Vec<u8>>,
|
||||||
|
format: ImageFormat,
|
||||||
|
info: TextureInfo,
|
||||||
|
}
|
||||||
|
fn reader(bytes: &[u8], format: ImageFormat) -> ImageReader<Cursor<&[u8]>> {
|
||||||
|
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<T>(work: impl FnOnce() -> Result<T>) -> Result<T> {
|
||||||
|
std::panic::catch_unwind(std::panic::AssertUnwindSafe(work)).map_err(|_| invalid("texture decoder failed"))?
|
||||||
|
}
|
||||||
|
impl TextureSource {
|
||||||
|
pub fn new(bytes: Vec<u8>, role: MapRole) -> Result<Self> {
|
||||||
|
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<DecodedTexture> {
|
||||||
|
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<u8>,
|
||||||
|
}
|
||||||
|
impl DecodedTexture {
|
||||||
|
pub fn info(&self) -> &TextureInfo {
|
||||||
|
&self.info
|
||||||
|
}
|
||||||
|
pub fn rgba(&self) -> &[u8] {
|
||||||
|
&self.pixels
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn png(width: u32, height: u32) -> Vec<u8> {
|
||||||
|
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());
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
Reference in new issue
Block a user